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 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 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。
<!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 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論