0. 作品介紹

作者 李佩芳 西門國小

適合班級教師分配掃地工作


21 0 0
預設封面圖 預設圖片

2. 資料表規則

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

第1分頁: 學生資料 A 欄: 學號 B 欄: 姓名 C 欄: 備註 第2分頁: 掃地區域 A 欄: 區域名稱 B 欄: 類型 C 欄: 需要人數 D 欄: 攜帶工具 E 欄: 備註

📄 解析結果(自動表格)

分頁:學生資料
A 欄 B 欄 C 欄
學號 姓名 備註
分頁:掃地區域
A 欄 B 欄 C 欄 D 欄 E 欄
區域名稱 類型 需要人數 攜帶工具 備註

3. 開啟 Apps Script

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

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

4. 貼上後端(Code.gs)

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

Code.gs(後端)
// =============================================
// 掃地工作分配表 - Google Apps Script 後端
// =============================================

// ── 全域設定 ──────────────────────────────────
var SHEET_STUDENTS  = '學生名單';
var SHEET_ZONES     = '掃地區域';
var SHEET_RESULT    = '分配結果';
var SHEET_SETTINGS  = '設定';

// ── 網頁入口 ──────────────────────────────────
function doGet() {
  return HtmlService
    .createTemplateFromFile('index')
    .evaluate()
    .setTitle('掃地工作分配表')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// ── 初始化試算表(第一次執行時建立所有工作表)──
function initSpreadsheet() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();

  // 1. 學生名單
  var sStudents = getOrCreateSheet(ss, SHEET_STUDENTS);
  if (sStudents.getLastRow() < 1) {
    sStudents.getRange('A1:C1').setValues([['座號','姓名','備註']]);
    sStudents.getRange('A1:C1').setFontWeight('bold').setBackground('#4A90D9').setFontColor('#ffffff');
    // 預設30人範例
    for (var i = 1; i <= 30; i++) {
      sStudents.getRange(i+1, 1).setValue(i);
      sStudents.getRange(i+1, 2).setValue('學生' + i);
    }
  }

  // 2. 掃地區域
  var sZones = getOrCreateSheet(ss, SHEET_ZONES);
  if (sZones.getLastRow() < 1) {
    sZones.getRange('A1:E1').setValues([['區域名稱','類型(內掃/外掃)','需要人數','攜帶工具','備註']]);
    sZones.getRange('A1:E1').setFontWeight('bold').setBackground('#4A90D9').setFontColor('#ffffff');
    var defaultZones = [
      ['講台&黑板','內掃',2,'黑板擦、抹布',''],
      ['教室中間','內掃',3,'掃把、畚斗',''],
      ['教室後方','內掃',2,'掃把、畚斗、拖把',''],
      ['窗戶走廊','內掃',2,'抹布、掃把',''],
      ['走廊外側','外掃',3,'掃把、畚斗',''],
      ['樓梯間','外掃',3,'掃把、畚斗',''],
      ['廁所前走廊','外掃',2,'掃把',''],
      ['公布欄區域','外掃',2,'掃把、抹布','']
    ];
    sZones.getRange(2, 1, defaultZones.length, 5).setValues(defaultZones);
  }

  // 3. 分配結果
  var sResult = getOrCreateSheet(ss, SHEET_RESULT);
  if (sResult.getLastRow() < 1) {
    sResult.getRange('A1:E1').setValues([['座號','姓名','區域','類型','攜帶工具']]);
    sResult.getRange('A1:E1').setFontWeight('bold').setBackground('#4A90D9').setFontColor('#ffffff');
  }

  // 4. 設定
  var sSettings = getOrCreateSheet(ss, SHEET_SETTINGS);
  if (sSettings.getLastRow() < 1) {
    sSettings.getRange('A1:B1').setValues([['設定項目','值']]);
    sSettings.getRange('A1:B1').setFontWeight('bold').setBackground('#4A90D9').setFontColor('#ffffff');
    var defaults = [
      ['輪替週期(週)', 1],
      ['開始日期', Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd')],
      ['班級名稱', '我的班級'],
      ['最後隨機日期', ''],
      ['輪替次數', 0]
    ];
    sSettings.getRange(2, 1, defaults.length, 2).setValues(defaults);
  }

  return { success: true, message: '初始化完成!' };
}

// ── 取得所有資料(首頁載入用)──────────────────
function getAllData() {
  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    return {
      students:  getStudents(ss),
      zones:     getZones(ss),
      result:    getResult(ss),
      settings:  getSettings(ss),
      today:     getTodayInfo()
    };
  } catch(e) {
    return { error: e.message };
  }
}

// ── 讀取學生名單 ──────────────────────────────
function getStudents(ss) {
  var sheet = ss.getSheetByName(SHEET_STUDENTS);
  if (!sheet || sheet.getLastRow() < 2) return [];
  var data = sheet.getRange(2, 1, sheet.getLastRow()-1, 3).getValues();
  return data.filter(function(r){ return r[0] !== ''; }).map(function(r){
    return { id: r[0], name: r[1], note: r[2] };
  });
}

// ── 讀取掃地區域 ──────────────────────────────
function getZones(ss) {
  var sheet = ss.getSheetByName(SHEET_ZONES);
  if (!sheet || sheet.getLastRow() < 2) return [];
  var data = sheet.getRange(2, 1, sheet.getLastRow()-1, 5).getValues();
  return data.filter(function(r){ return r[0] !== ''; }).map(function(r){
    return { name: r[0], type: r[1], count: r[2], tools: r[3], note: r[4] };
  });
}

// ── 讀取分配結果 ──────────────────────────────
function getResult(ss) {
  var sheet = ss.getSheetByName(SHEET_RESULT);
  if (!sheet || sheet.getLastRow() < 2) return [];
  var data = sheet.getRange(2, 1, sheet.getLastRow()-1, 5).getValues();
  return data.filter(function(r){ return r[0] !== ''; }).map(function(r){
    return { id: r[0], name: r[1], zone: r[2], type: r[3], tools: r[4] };
  });
}

// ── 讀取設定 ──────────────────────────────────
function getSettings(ss) {
  var sheet = ss.getSheetByName(SHEET_SETTINGS);
  if (!sheet || sheet.getLastRow() < 2) return {};
  var data = sheet.getRange(2, 1, sheet.getLastRow()-1, 2).getValues();
  var obj = {};
  data.forEach(function(r){
    if (r[0]) {
      var val = r[1];
      // 若值是 Date 物件,轉成 yyyy/MM/dd 字串
      if (val instanceof Date) {
        val = Utilities.formatDate(val, Session.getScriptTimeZone(), 'yyyy/MM/dd');
      }
      obj[r[0]] = val;
    }
  });
  return obj;
}

// ── 今天日期與星期 ────────────────────────────
function getTodayInfo() {
  var now  = new Date();
  var tz   = Session.getScriptTimeZone();
  var days = ['日','一','二','三','四','五','六'];
  return {
    date:    Utilities.formatDate(now, tz, 'yyyy年MM月dd日'),
    weekday: '星期' + days[now.getDay()],
    week:    getWeekNumber(now)
  };
}

function getWeekNumber(d) {
  var ss       = SpreadsheetApp.getActiveSpreadsheet();
  var settings = getSettings(ss);
  var startStr = settings['開始日期'];
  if (!startStr) return 1;
  var start    = new Date(startStr.replace(/\//g, '-'));
  var diff     = d - start;
  return Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1;
}

// ── 隨機分配 ──────────────────────────────────
function randomAssign() {
  try {
    var ss       = SpreadsheetApp.getActiveSpreadsheet();
    var students = getStudents(ss);
    var zones    = getZones(ss);

    if (students.length === 0) return { error: '學生名單是空的,請先填寫學生名單!' };
    if (zones.length === 0)    return { error: '掃地區域是空的,請先填寫掃地區域!' };

    // 計算總需人數
    var totalNeeded = zones.reduce(function(sum, z){ return sum + parseInt(z.count||1); }, 0);
    if (totalNeeded > students.length) {
      return { error: '需要人數(' + totalNeeded + '人)超過學生人數(' + students.length + '人),請調整區域人數!' };
    }

    // 隨機打亂學生
    var shuffled = students.slice().sort(function(){ return Math.random() - 0.5; });
    var result   = [];
    var idx      = 0;

    zones.forEach(function(z) {
      var need = parseInt(z.count || 1);
      for (var i = 0; i < need; i++) {
        if (idx < shuffled.length) {
          result.push([shuffled[idx].id, shuffled[idx].name, z.name, z.type, z.tools]);
          idx++;
        }
      }
    });

    // 沒被分配到的學生(人數多於需求時)
    while (idx < shuffled.length) {
      result.push([shuffled[idx].id, shuffled[idx].name, '(待分配)', '', '']);
      idx++;
    }

    // 依座號排序
    result.sort(function(a, b){ return a[0] - b[0]; });

    // 寫入分配結果工作表
    var sheet = ss.getSheetByName(SHEET_RESULT);
    if (sheet.getLastRow() > 1) {
      sheet.getRange(2, 1, sheet.getLastRow()-1, 5).clearContent();
    }
    sheet.getRange(2, 1, result.length, 5).setValues(result);

    // 更新最後隨機日期
    updateSetting(ss, '最後隨機日期',
      Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd'));

    return { success: true, result: result.map(function(r){
      return { id: r[0], name: r[1], zone: r[2], type: r[3], tools: r[4] };
    })};
  } catch(e) {
    return { error: e.message };
  }
}

// ── 輪替分配(按輪替次數位移)──────────────────
function rotateAssign() {
  try {
    var ss       = SpreadsheetApp.getActiveSpreadsheet();
    var settings = getSettings(ss);
    var students = getStudents(ss);
    var zones    = getZones(ss);

    if (students.length === 0) return { error: '學生名單是空的!' };
    if (zones.length === 0)    return { error: '掃地區域是空的!' };

    var totalNeeded = zones.reduce(function(sum, z){ return sum + parseInt(z.count||1); }, 0);
    if (totalNeeded > students.length) {
      return { error: '需要人數(' + totalNeeded + '人)超過學生人數(' + students.length + '人)!' };
    }

    var rotateCount = parseInt(settings['輪替次數'] || 0) + 1;
    var offset      = (rotateCount * totalNeeded) % students.length;

    // 依座號排序後位移
    var sorted = students.slice().sort(function(a,b){ return a.id - b.id; });
    var shifted = sorted.slice(offset).concat(sorted.slice(0, offset));

    var result = [];
    var idx    = 0;
    zones.forEach(function(z) {
      var need = parseInt(z.count || 1);
      for (var i = 0; i < need; i++) {
        if (idx < shifted.length) {
          result.push([shifted[idx].id, shifted[idx].name, z.name, z.type, z.tools]);
          idx++;
        }
      }
    });
    while (idx < shifted.length) {
      result.push([shifted[idx].id, shifted[idx].name, '(待分配)', '', '']);
      idx++;
    }
    result.sort(function(a,b){ return a[0] - b[0]; });

    var sheet = ss.getSheetByName(SHEET_RESULT);
    if (sheet.getLastRow() > 1) {
      sheet.getRange(2, 1, sheet.getLastRow()-1, 5).clearContent();
    }
    sheet.getRange(2, 1, result.length, 5).setValues(result);

    updateSetting(ss, '輪替次數', rotateCount);
    updateSetting(ss, '最後隨機日期',
      Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy/MM/dd'));

    return { success: true, rotateCount: rotateCount, result: result.map(function(r){
      return { id: r[0], name: r[1], zone: r[2], type: r[3], tools: r[4] };
    })};
  } catch(e) {
    return { error: e.message };
  }
}

// ── 更新單一設定值 ────────────────────────────
function updateSetting(ss, key, value) {
  var sheet = ss.getSheetByName(SHEET_SETTINGS);
  var data  = sheet.getRange(2, 1, sheet.getLastRow()-1, 1).getValues();
  for (var i = 0; i < data.length; i++) {
    if (data[i][0] === key) {
      sheet.getRange(i+2, 2).setValue(value);
      return;
    }
  }
  sheet.appendRow([key, value]);
}

// ── 儲存設定(從前端呼叫)────────────────────
function saveSettings(newSettings) {
  try {
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    Object.keys(newSettings).forEach(function(k){
      updateSetting(ss, k, newSettings[k]);
    });
    return { success: true };
  } catch(e) {
    return { error: e.message };
  }
}

// ── 工具:取得或建立工作表 ────────────────────
function getOrCreateSheet(ss, name) {
  var sheet = ss.getSheetByName(name);
  if (!sheet) {
    sheet = ss.insertSheet(name);
  }
  return sheet;
}
貼到 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 rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;900&family=DM+Mono:wght@500&display=swap" rel="stylesheet">
<style>
/* ── Reset & Variables ── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --bg:        #F5F0E8;
  --surface:   #FFFDF7;
  --border:    #D4C9B0;
  --ink:       #1A1208;
  --ink-muted: #7A6E5A;
  --ink-light: #B0A490;
  --accent:    #C8441A;
  --accent2:   #2D6A4F;
  --accent3:   #1A4A8A;
  --yellow:    #F5C842;
  --inner-bg:  #E8F4F0;
  --outer-bg:  #E8EEF8;
  --shadow:    4px 4px 0px #1A120820;
  --radius:    6px;
}

body {
  font-family: 'Noto Sans TC', sans-serif;
  background: var(--bg);
  color: var(--ink);
  min-height: 100vh;
  background-image:
    repeating-linear-gradient(0deg, transparent, transparent 27px, #D4C9B022 28px),
    repeating-linear-gradient(90deg, transparent, transparent 27px, #D4C9B022 28px);
  background-size: 28px 28px;
}

/* ── Header ── */
.header {
  background: var(--ink);
  color: var(--bg);
  padding: 18px 28px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  position: sticky;
  top: 0;
  z-index: 100;
  border-bottom: 3px solid var(--accent);
}

.header-left { display: flex; align-items: baseline; gap: 12px; }
.header-title {
  font-size: 1.3rem;
  font-weight: 900;
  letter-spacing: 0.05em;
}
.header-class {
  font-size: 0.85rem;
  opacity: 0.6;
  font-weight: 500;
}

.date-badge {
  background: var(--yellow);
  color: var(--ink);
  padding: 5px 14px;
  border-radius: 99px;
  font-size: 0.85rem;
  font-weight: 700;
  font-family: 'DM Mono', monospace;
  letter-spacing: 0.02em;
  border: 2px solid var(--ink);
  white-space: nowrap;
}

/* ── Main Layout ── */
.container { max-width: 960px; margin: 0 auto; padding: 24px 20px 60px; }

/* ── Action Bar ── */
.action-bar {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
  margin-bottom: 24px;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 9px 18px;
  border-radius: var(--radius);
  font-family: 'Noto Sans TC', sans-serif;
  font-size: 0.9rem;
  font-weight: 700;
  cursor: pointer;
  border: 2px solid var(--ink);
  transition: transform .1s, box-shadow .1s;
  box-shadow: var(--shadow);
  white-space: nowrap;
}
.btn:active { transform: translate(2px,2px); box-shadow: 2px 2px 0 #1A120820; }
.btn-primary  { background: var(--accent);  color: #fff; }
.btn-secondary{ background: var(--accent3); color: #fff; }
.btn-success  { background: var(--accent2); color: #fff; }
.btn-ghost    { background: var(--surface); color: var(--ink); }

/* ── View Toggle ── */
.view-toggle {
  display: flex;
  border: 2px solid var(--ink);
  border-radius: var(--radius);
  overflow: hidden;
  margin-left: auto;
  box-shadow: var(--shadow);
}
.view-btn {
  padding: 8px 16px;
  font-size: 0.85rem;
  font-weight: 700;
  cursor: pointer;
  border: none;
  background: var(--surface);
  color: var(--ink-muted);
  font-family: 'Noto Sans TC', sans-serif;
  transition: background .15s, color .15s;
}
.view-btn:first-child { border-right: 2px solid var(--ink); }
.view-btn.active { background: var(--ink); color: var(--bg); }

/* ── Info Strip ── */
.info-strip {
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: var(--radius);
  padding: 12px 18px;
  margin-bottom: 20px;
  display: flex;
  gap: 24px;
  flex-wrap: wrap;
  font-size: 0.85rem;
  color: var(--ink-muted);
  box-shadow: var(--shadow);
}
.info-strip strong { color: var(--ink); }

/* ── Zone View ── */
.zones-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 16px;
}

.zone-card {
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
  animation: fadeUp .3s ease both;
}
@keyframes fadeUp {
  from { opacity:0; transform:translateY(10px); }
  to   { opacity:1; transform:translateY(0); }
}

.zone-card-header {
  padding: 11px 16px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  border-bottom: 2px solid var(--border);
}
.zone-card-header.inner { background: var(--inner-bg); }
.zone-card-header.outer { background: var(--outer-bg); }

.zone-name { font-weight: 900; font-size: 1rem; }
.zone-type-badge {
  font-size: 0.7rem;
  font-weight: 700;
  padding: 2px 8px;
  border-radius: 99px;
  border: 1.5px solid;
}
.inner .zone-type-badge { background: #C6EEE0; color: var(--accent2); border-color: var(--accent2); }
.outer .zone-type-badge { background: #C6D4EE; color: var(--accent3); border-color: var(--accent3); }

.zone-tools {
  padding: 8px 16px;
  font-size: 0.78rem;
  color: var(--ink-muted);
  border-bottom: 1px dashed var(--border);
  display: flex;
  align-items: center;
  gap: 6px;
}
.zone-tools-icon { opacity: 0.6; }

.zone-members { padding: 10px 16px; display: flex; flex-direction: column; gap: 6px; }
.member-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 10px;
  background: var(--bg);
  border-radius: 4px;
  border: 1px solid var(--border);
}
.member-id {
  font-family: 'DM Mono', monospace;
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--ink-muted);
  min-width: 28px;
}
.member-name { font-weight: 700; font-size: 0.9rem; }

.zone-empty {
  padding: 18px 16px;
  text-align: center;
  color: var(--ink-light);
  font-size: 0.85rem;
}

/* ── List View ── */
.list-table-wrap {
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  overflow: hidden;
}
.list-table { width: 100%; border-collapse: collapse; }
.list-table thead tr {
  background: var(--ink);
  color: var(--bg);
}
.list-table th {
  padding: 12px 16px;
  text-align: left;
  font-size: 0.85rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}
.list-table tbody tr {
  border-bottom: 1px solid var(--border);
  transition: background .1s;
}
.list-table tbody tr:hover { background: #F9F5ED; }
.list-table td {
  padding: 11px 16px;
  font-size: 0.9rem;
}
.list-table td:first-child {
  font-family: 'DM Mono', monospace;
  color: var(--ink-muted);
  font-size: 0.82rem;
}
.td-zone { font-weight: 700; }
.badge-inner {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 99px;
  font-size: 0.72rem;
  font-weight: 700;
  background: #C6EEE0;
  color: var(--accent2);
  border: 1.5px solid var(--accent2);
}
.badge-outer {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 99px;
  font-size: 0.72rem;
  font-weight: 700;
  background: #C6D4EE;
  color: var(--accent3);
  border: 1.5px solid var(--accent3);
}
.td-tools { font-size: 0.8rem; color: var(--ink-muted); }

/* ── Settings Panel ── */
.settings-overlay {
  display: none;
  position: fixed; inset: 0;
  background: #1A120880;
  z-index: 200;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.settings-overlay.open { display: flex; }
.settings-box {
  background: var(--surface);
  border: 3px solid var(--ink);
  border-radius: var(--radius);
  box-shadow: 8px 8px 0 var(--ink);
  width: 100%;
  max-width: 440px;
  max-height: 90vh;
  overflow-y: auto;
  animation: popIn .2s ease;
}
@keyframes popIn {
  from { opacity:0; transform:scale(.92); }
  to   { opacity:1; transform:scale(1); }
}
.settings-header {
  background: var(--ink);
  color: var(--bg);
  padding: 16px 20px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.settings-header h2 { font-size: 1rem; font-weight: 900; }
.close-btn {
  background: none; border: none; color: var(--bg);
  font-size: 1.3rem; cursor: pointer; padding: 2px 6px;
  border-radius: 4px;
}
.close-btn:hover { background: #ffffff30; }
.settings-body { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.field-group { display: flex; flex-direction: column; gap: 6px; }
.field-label { font-size: 0.83rem; font-weight: 700; color: var(--ink-muted); }
.field-input {
  padding: 9px 12px;
  border: 2px solid var(--border);
  border-radius: var(--radius);
  font-family: 'Noto Sans TC', sans-serif;
  font-size: 0.9rem;
  background: var(--bg);
  color: var(--ink);
  transition: border-color .15s;
}
.field-input:focus { outline: none; border-color: var(--ink); }
.settings-footer { padding: 0 20px 20px; }

/* ── Toast ── */
.toast {
  position: fixed;
  bottom: 24px; left: 50%;
  transform: translateX(-50%) translateY(80px);
  background: var(--ink);
  color: var(--bg);
  padding: 12px 24px;
  border-radius: 99px;
  font-size: 0.88rem;
  font-weight: 700;
  z-index: 300;
  transition: transform .3s ease;
  pointer-events: none;
  white-space: nowrap;
  border: 2px solid var(--yellow);
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.error { border-color: #ff6b6b; }

/* ── Loading State ── */
.loading-mask {
  display: none;
  position: fixed; inset: 0;
  background: var(--bg);
  z-index: 400;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
}
.loading-mask.show { display: flex; }
.spinner {
  width: 40px; height: 40px;
  border: 4px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-weight: 700; color: var(--ink-muted); }

/* ── Empty State ── */
.empty-state {
  text-align: center;
  padding: 60px 20px;
  color: var(--ink-muted);
}
.empty-state p { font-size: 1rem; margin-bottom: 8px; }
.empty-state small { font-size: 0.82rem; }

/* ── Responsive ── */
@media (max-width: 600px) {
  .header { padding: 14px 16px; }
  .header-title { font-size: 1.05rem; }
  .container { padding: 16px 12px 50px; }
  .action-bar { gap: 8px; }
  .btn { padding: 8px 13px; font-size: 0.82rem; }
  .list-table th, .list-table td { padding: 9px 10px; }
  .td-tools { display: none; }
}
</style>
</head>
<body>

<!-- Loading -->
<div class="loading-mask show" id="loadingMask">
  <div class="spinner"></div>
  <div class="loading-text">載入中…</div>
</div>

<!-- Header -->
<header class="header">
  <div class="header-left">
    <span class="header-title">🧹 掃地工作分配表</span>
    <span class="header-class" id="headerClass">載入中…</span>
  </div>
  <div class="date-badge" id="dateBadge">載入中…</div>
</header>

<!-- Main -->
<main class="container">

  <!-- Action Bar -->
  <div class="action-bar">
    <button class="btn btn-primary" onclick="doRandom()">🎲 隨機分配</button>
    <button class="btn btn-secondary" onclick="doRotate()">🔄 輪替換區</button>
    <button class="btn btn-ghost" onclick="openSettings()">⚙️ 設定</button>
    <div class="view-toggle">
      <button class="view-btn active" id="btnZoneView" onclick="switchView('zone')">依區域</button>
      <button class="view-btn" id="btnListView" onclick="switchView('list')">依座號</button>
    </div>
  </div>

  <!-- Info Strip -->
  <div class="info-strip" id="infoStrip">
    <span>學生:<strong id="infoStudents">—</strong> 人</span>
    <span>區域:<strong id="infoZones">—</strong> 個</span>
    <span>已分配:<strong id="infoAssigned">—</strong> 人</span>
    <span>輪替第 <strong id="infoRotate">—</strong> 次</span>
    <span>最後更新:<strong id="infoDate">—</strong></span>
  </div>

  <!-- Zone View -->
  <div id="zoneView">
    <div class="zones-grid" id="zonesGrid"></div>
  </div>

  <!-- List View -->
  <div id="listView" style="display:none">
    <div class="list-table-wrap">
      <table class="list-table">
        <thead>
          <tr>
            <th>座號</th>
            <th>姓名</th>
            <th>掃地區域</th>
            <th>類型</th>
            <th>攜帶工具</th>
          </tr>
        </thead>
        <tbody id="listBody"></tbody>
      </table>
    </div>
  </div>

</main>

<!-- Settings Panel -->
<div class="settings-overlay" id="settingsOverlay">
  <div class="settings-box">
    <div class="settings-header">
      <h2>⚙️ 設定</h2>
      <button class="close-btn" onclick="closeSettings()">✕</button>
    </div>
    <div class="settings-body">
      <div class="field-group">
        <label class="field-label">班級名稱</label>
        <input class="field-input" id="setClass" type="text" placeholder="例:三年甲班">
      </div>
      <div class="field-group">
        <label class="field-label">輪替週期(週)</label>
        <input class="field-input" id="setRotatePeriod" type="number" min="1" max="52" placeholder="1">
      </div>
      <div class="field-group">
        <label class="field-label">開始日期</label>
        <input class="field-input" id="setStartDate" type="date">
      </div>
    </div>
    <div class="settings-footer">
      <button class="btn btn-success" style="width:100%" onclick="saveSettings()">💾 儲存設定</button>
    </div>
  </div>
</div>

<!-- Toast -->
<div class="toast" id="toast"></div>

<script>
// ── State ──
var state = { students:[], zones:[], result:[], settings:{}, view:'zone' };

// ── Init ──
window.onload = function() {
  google.script.run
    .withSuccessHandler(function(data) {
      if (data.error) { showToast(data.error, true); hideLoading(); return; }
      state = Object.assign(state, data);
      render();
      hideLoading();
    })
    .withFailureHandler(function(err) {
      showToast('載入失敗:' + err.message, true);
      hideLoading();
    })
    .getAllData();
};

// ── Render ──
function render() {
  // Header
  document.getElementById('headerClass').textContent = state.settings['班級名稱'] || '掃地值日';
  document.getElementById('dateBadge').textContent =
    (state.today.date || '') + ' ' + (state.today.weekday || '');

  // Info strip
  document.getElementById('infoStudents').textContent = state.students.length;
  document.getElementById('infoZones').textContent    = state.zones.length;
  var assigned = state.result.filter(function(r){ return r.zone && r.zone !== '(待分配)'; }).length;
  document.getElementById('infoAssigned').textContent = assigned;
  document.getElementById('infoRotate').textContent   = state.settings['輪替次數'] || 0;
  document.getElementById('infoDate').textContent     = state.settings['最後隨機日期'] || '尚未分配';

  renderZoneView();
  renderListView();
}

// ── Zone View ──
function renderZoneView() {
  var grid = document.getElementById('zonesGrid');
  if (!state.result.length) {
    grid.innerHTML = '<div class="empty-state" style="grid-column:1/-1"><p>尚未分配</p><small>請按「隨機分配」或「輪替換區」開始</small></div>';
    return;
  }
  // Group result by zone
  var byZone = {};
  state.zones.forEach(function(z){ byZone[z.name] = { zone:z, members:[] }; });
  state.result.forEach(function(r){
    if (byZone[r.zone]) byZone[r.zone].members.push(r);
    else if (r.zone) {
      if (!byZone[r.zone]) byZone[r.zone] = { zone:{name:r.zone, type:r.type, tools:r.tools}, members:[] };
      byZone[r.zone].members.push(r);
    }
  });

  var html = '';
  state.zones.forEach(function(z) {
    var entry   = byZone[z.name] || { zone:z, members:[] };
    var isInner = z.type === '內掃';
    var cls     = isInner ? 'inner' : 'outer';
    var members = entry.members.length
      ? entry.members.map(function(m){
          return '<div class="member-row">' +
            '<span class="member-id">No.' + m.id + '</span>' +
            '<span class="member-name">' + m.name + '</span>' +
            '</div>';
        }).join('')
      : '<div class="zone-empty">尚未分配</div>';

    html += '<div class="zone-card" style="animation-delay:' + (state.zones.indexOf(z)*0.05) + 's">' +
      '<div class="zone-card-header ' + cls + '">' +
        '<span class="zone-name">' + z.name + '</span>' +
        '<span class="zone-type-badge">' + z.type + '</span>' +
      '</div>' +
      '<div class="zone-tools"><span class="zone-tools-icon">🧰</span>' + (z.tools || '無') + '</div>' +
      '<div class="zone-members">' + members + '</div>' +
      '</div>';
  });

  // 待分配
  var unassigned = state.result.filter(function(r){ return r.zone === '(待分配)'; });
  if (unassigned.length) {
    html += '<div class="zone-card"><div class="zone-card-header" style="background:#FFF3CD">' +
      '<span class="zone-name">待分配</span>' +
      '<span class="zone-type-badge" style="border-color:#B88A00;color:#B88A00;background:#FFF3CD">—</span>' +
      '</div><div class="zone-members">' +
      unassigned.map(function(m){
        return '<div class="member-row"><span class="member-id">No.' + m.id + '</span>' +
          '<span class="member-name">' + m.name + '</span></div>';
      }).join('') + '</div></div>';
  }

  grid.innerHTML = html;
}

// ── List View ──
function renderListView() {
  var tbody = document.getElementById('listBody');
  if (!state.result.length) {
    tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:40px;color:#aaa">尚未分配,請先執行分配</td></tr>';
    return;
  }
  tbody.innerHTML = state.result.map(function(r){
    var badgeCls = r.type === '內掃' ? 'badge-inner' : (r.type === '外掃' ? 'badge-outer' : '');
    var badge    = r.type ? '<span class="' + badgeCls + '">' + r.type + '</span>' : '—';
    return '<tr>' +
      '<td>' + r.id + '</td>' +
      '<td style="font-weight:700">' + r.name + '</td>' +
      '<td class="td-zone">' + (r.zone || '—') + '</td>' +
      '<td>' + badge + '</td>' +
      '<td class="td-tools">' + (r.tools || '—') + '</td>' +
      '</tr>';
  }).join('');
}

// ── Switch View ──
function switchView(v) {
  state.view = v;
  document.getElementById('zoneView').style.display = v === 'zone' ? '' : 'none';
  document.getElementById('listView').style.display = v === 'list' ? '' : 'none';
  document.getElementById('btnZoneView').classList.toggle('active', v === 'zone');
  document.getElementById('btnListView').classList.toggle('active', v === 'list');
}

// ── Random Assign ──
function doRandom() {
  showLoading('隨機分配中…');
  google.script.run
    .withSuccessHandler(function(data) {
      if (data.error) { showToast(data.error, true); hideLoading(); return; }
      state.result = data.result;
      state.settings['最後隨機日期'] = new Date().toLocaleDateString('zh-TW').replace(/\//g, '/');
      render();
      hideLoading();
      showToast('🎲 隨機分配完成!');
    })
    .withFailureHandler(function(err) { showToast(err.message, true); hideLoading(); })
    .randomAssign();
}

// ── Rotate Assign ──
function doRotate() {
  showLoading('輪替換區中…');
  google.script.run
    .withSuccessHandler(function(data) {
      if (data.error) { showToast(data.error, true); hideLoading(); return; }
      state.result = data.result;
      state.settings['輪替次數'] = data.rotateCount;
      state.settings['最後隨機日期'] = new Date().toLocaleDateString('zh-TW').replace(/\//g, '/');
      render();
      hideLoading();
      showToast('🔄 第 ' + data.rotateCount + ' 次輪替完成!');
    })
    .withFailureHandler(function(err) { showToast(err.message, true); hideLoading(); })
    .rotateAssign();
}

// ── Settings ──
function openSettings() {
  document.getElementById('setClass').value         = state.settings['班級名稱'] || '';
  document.getElementById('setRotatePeriod').value  = state.settings['輪替週期(週)'] || 1;
  var d = state.settings['開始日期'];
  if (d) {
    try {
      // 處理三種可能格式:Date 物件、'yyyy/MM/dd'、'yyyy-MM-dd'
      var dateObj = (d instanceof Date) ? d : new Date(d.toString().replace(/\//g, '-'));
      if (!isNaN(dateObj.getTime())) {
        var yyyy = dateObj.getFullYear();
        var mm   = String(dateObj.getMonth() + 1).padStart(2, '0');
        var dd   = String(dateObj.getDate()).padStart(2, '0');
        document.getElementById('setStartDate').value = yyyy + '-' + mm + '-' + dd;
      }
    } catch(e) { /* 日期格式無法解析,留空即可 */ }
  }
  document.getElementById('settingsOverlay').classList.add('open');
}
function closeSettings() {
  document.getElementById('settingsOverlay').classList.remove('open');
}
function saveSettings() {
  var newSettings = {
    '班級名稱':     document.getElementById('setClass').value,
    '輪替週期(週)': parseInt(document.getElementById('setRotatePeriod').value) || 1,
    '開始日期':     document.getElementById('setStartDate').value.replace(/-/g, '/')
  };
  google.script.run
    .withSuccessHandler(function() {
      Object.assign(state.settings, newSettings);
      render();
      closeSettings();
      showToast('💾 設定已儲存');
    })
    .withFailureHandler(function(err) { showToast(err.message, true); })
    .saveSettings(newSettings);
}
// Close overlay on bg click
document.getElementById('settingsOverlay').addEventListener('click', function(e){
  if (e.target === this) closeSettings();
});

// ── Toast ──
var toastTimer;
function showToast(msg, isError) {
  var el = document.getElementById('toast');
  el.textContent = msg;
  el.className = 'toast' + (isError ? ' error' : '');
  void el.offsetWidth;
  el.classList.add('show');
  clearTimeout(toastTimer);
  toastTimer = setTimeout(function(){ el.classList.remove('show'); }, 3200);
}

// ── Loading ──
function showLoading(msg) {
  var el = document.getElementById('loadingMask');
  el.querySelector('.loading-text').textContent = msg || '處理中…';
  el.classList.add('show');
}
function hideLoading() {
  document.getElementById('loadingMask').classList.remove('show');
}
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

部署成 Web App 後,就會拿到可分享網址。

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

💬 評論區

老師們的交流與提問

載入評論中...