0. 作品介紹
預設圖片
2. 資料表規則
請照下面規則建立分頁與欄位。
第1分頁: 學生資料庫
A 欄: 點 A1 格,把以下這行整個複製後貼上座號 姓名 組別 第1堂 第2堂 第3堂 第4堂 第5堂 第6堂 第7堂 第8堂 第9堂 第10堂 第11堂 第12堂 第13堂 第14堂 第15堂 第16堂 第17堂 第18堂 第19堂 第20堂 第21堂 第22堂 第23堂 第24堂 第25堂 第26堂 第27堂 第28堂 第29堂 第30堂 累計點數
B 欄: 從第 2 列開始,依序填入每位學生的資料:
C 欄: A 欄:座號(1、2、3…)
D 欄: B 欄:姓名
E 欄: C 欄:組別(例如「第1組」)
F 欄: D~AG 欄:全部填 0(第1堂到第30堂,共30格)
G 欄: AH 欄:填累計公式
📄 解析結果(自動表格)
分頁:學生資料庫
| A 欄 | B 欄 | C 欄 | D 欄 | E 欄 | F 欄 | G 欄 |
|---|---|---|---|---|---|---|
| 點 A1 格,把以下這行整個複製後貼上座號 姓名 組別 第1堂 第2堂 第3堂 第4堂 第5堂 第6堂 第7堂 第8堂 第9堂 第10堂 第11堂 第12堂 第13堂 第14堂 第15堂 第16堂 第17堂 第18堂 第19堂 第20堂 第21堂 第22堂 第23堂 第24堂 第25堂 第26堂 第27堂 第28堂 第29堂 第30堂 累計點數 | 從第 2 列開始,依序填入每位學生的資料: | A 欄:座號(1、2、3…) | B 欄:姓名 | C 欄:組別(例如「第1組」) | D~AG 欄:全部填 0(第1堂到第30堂,共30格) | AH 欄:填累計公式 |
3. 開啟 Apps Script
從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 Code.gs。
// ============================================================
// 上課集點統計系統 — 後端 Code.gs
// 請將此檔案貼到 Google Apps Script 編輯器
// ============================================================
// ── 全域設定 ──────────────────────────────────────────────
var SHEET_NAME = "學生資料庫";
var TOTAL_LESSONS = 30;
// ── 入口:提供網頁 ─────────────────────────────────────────
function doGet() {
return HtmlService.createHtmlOutputFromFile("index")
.setTitle("上課集點統計")
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
// ── 取得試算表 ──────────────────────────────────────────────
function getSheet() {
return SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
}
// ── 初始化試算表(第一次執行用)────────────────────────────
function initSheet() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
var sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
}
// 建立標題列
var headers = ["座號", "姓名", "組別"];
for (var i = 1; i <= TOTAL_LESSONS; i++) {
headers.push("第" + i + "堂");
}
headers.push("累計點數");
sheet.clearContents();
sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
// 範例學生資料(28位、6組)
var sampleData = [
[1, "王小明", "第1組"], [2, "李小華", "第1組"], [3, "張小美", "第1組"], [4, "陳小強", "第1組"], [5, "林小芬", "第1組"],
[6, "黃小玲", "第2組"], [7, "吳小傑", "第2組"], [8, "鄭小文", "第2組"], [9, "劉小婷", "第2組"], [10, "蔡小剛", "第2組"],
[11, "楊小雯", "第3組"], [12, "謝小峰", "第3組"], [13, "許小萍", "第3組"], [14, "何小龍", "第3組"],
[15, "曾小蓉", "第4組"], [16, "蕭小偉", "第4組"], [17, "江小慧", "第4組"], [18, "彭小賢", "第4組"], [19, "鄧小純", "第4組"],
[20, "趙小豪", "第5組"], [21, "馮小菁", "第5組"], [22, "游小翔", "第5組"], [23, "盧小語", "第5組"],
[24, "洪小恩", "第6組"], [25, "邱小安", "第6組"], [26, "高小宇", "第6組"], [27, "梁小茜", "第6組"], [28, "羅小傑", "第6組"]
];
// 填入資料,堂次欄位初始化為 0,累計欄位之後由公式計算
for (var r = 0; r < sampleData.length; r++) {
var row = sampleData[r];
var fullRow = [row[0], row[1], row[2]];
for (var c = 0; c < TOTAL_LESSONS; c++) {
fullRow.push(0);
}
fullRow.push(0); // 累計點數先填 0,稍後寫公式
sheet.getRange(r + 2, 1, 1, fullRow.length).setValues([fullRow]);
}
// 累計點數欄寫入 SUM 公式(D2 到 AG2,即第4欄到第33欄)
var lastRow = sampleData.length + 1;
var totalCol = 3 + TOTAL_LESSONS + 1; // 座號+姓名+組別+30堂+累計
for (var i = 2; i <= lastRow; i++) {
var formula = "=SUM(D" + i + ":AG" + i + ")";
sheet.getRange(i, totalCol).setFormula(formula);
}
// 格式化標題列
var headerRange = sheet.getRange(1, 1, 1, 3 + TOTAL_LESSONS + 1);
headerRange.setBackground("#3B3B98").setFontColor("#FFFFFF").setFontWeight("bold");
sheet.setFrozenRows(1);
sheet.setFrozenColumns(3);
return "初始化完成!共建立 " + sampleData.length + " 位學生資料。";
}
// ── 取得所有學生資料 ────────────────────────────────────────
function getAllStudents() {
var sheet = getSheet();
if (!sheet) return { error: "找不到工作表,請先執行 initSheet()" };
var data = sheet.getDataRange().getValues();
if (data.length < 2) return { students: [], groups: [] };
var students = [];
var groups = {};
for (var i = 1; i < data.length; i++) {
var row = data[i];
var lessons = [];
for (var j = 3; j < 3 + TOTAL_LESSONS; j++) {
lessons.push(Number(row[j]) || 0);
}
var student = {
row: i + 1,
number: row[0],
name: row[1],
group: row[2],
lessons: lessons,
total: Number(row[3 + TOTAL_LESSONS]) || 0
};
students.push(student);
if (!groups[row[2]]) groups[row[2]] = [];
groups[row[2]].push(student);
}
return {
students: students,
groups: groups,
totalLessons: TOTAL_LESSONS
};
}
// ── 加/扣點(個人)────────────────────────────────────────
function updateStudentPoint(rowIndex, lessonIndex, delta) {
// rowIndex: 試算表列號(從2開始)
// lessonIndex: 第幾堂課(從1開始)
// delta: +1 +2 +3 -1 -2 -3
var sheet = getSheet();
var col = 3 + lessonIndex; // 座號(1)+姓名(2)+組別(3),第lessonIndex堂在第(3+lessonIndex)欄
var cell = sheet.getRange(rowIndex, col);
var current = Number(cell.getValue()) || 0;
var newVal = current + delta;
cell.setValue(newVal);
return { success: true, newVal: newVal };
}
// ── 加/扣點(整組)────────────────────────────────────────
function updateGroupPoint(groupName, lessonIndex, delta) {
var sheet = getSheet();
var data = sheet.getDataRange().getValues();
var col = 3 + lessonIndex;
var updated = 0;
for (var i = 1; i < data.length; i++) {
if (data[i][2] === groupName) {
var cell = sheet.getRange(i + 1, col);
var current = Number(cell.getValue()) || 0;
cell.setValue(current + delta);
updated++;
}
}
return { success: true, updated: updated };
}
// ── 取得本堂課前三名 ───────────────────────────────────────
function getTopThisLesson(lessonIndex) {
var sheet = getSheet();
var data = sheet.getDataRange().getValues();
var col = 3 + lessonIndex - 1; // 0-based index for data array
var results = [];
for (var i = 1; i < data.length; i++) {
results.push({
name: data[i][1],
group: data[i][2],
points: Number(data[i][col]) || 0
});
}
results.sort(function(a, b) { return b.points - a.points; });
return results.slice(0, 3).filter(function(s) { return s.points > 0; });
}
// ── 取得累計排行榜 ─────────────────────────────────────────
function getRanking() {
var sheet = getSheet();
var data = sheet.getDataRange().getValues();
var totalCol = 3 + TOTAL_LESSONS; // 0-based index
var results = [];
for (var i = 1; i < data.length; i++) {
results.push({
number: data[i][0],
name: data[i][1],
group: data[i][2],
total: Number(data[i][totalCol]) || 0
});
}
results.sort(function(a, b) { return b.total - a.total; });
return results;
} 貼到 Apps Script 的 Code.gs 檔案
5. 貼上前端(index.html)
把前端程式貼到 Apps Script 的 index.html。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>上課集點統計</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;900&family=DM+Mono:wght@500&display=swap" rel="stylesheet">
<style>
:root {
--ink: #1a1a2e;
--ink-2: #2d2d4e;
--ink-light: #6b6b8a;
--surface: #f7f6ff;
--surface-2: #eeedf9;
--border: #dddcf0;
--accent: #4f46e5;
--accent-2: #7c3aed;
--accent-light: #ede9fe;
--gold: #f59e0b;
--gold-light: #fef3c7;
--red: #ef4444;
--red-light: #fee2e2;
--green: #10b981;
--green-light: #d1fae5;
--card: #ffffff;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 2px 12px rgba(79,70,229,0.08);
--shadow-md: 0 4px 24px rgba(79,70,229,0.13);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Noto Sans TC', sans-serif;
background: var(--surface);
color: var(--ink);
min-height: 100vh;
padding-bottom: 3rem;
}
/* ── Header ── */
.header {
background: var(--ink);
color: #fff;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 16px rgba(0,0,0,0.2);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.header-logo {
width: 36px; height: 36px; background: var(--accent);
border-radius: 8px; display: flex; align-items: center;
justify-content: center; font-size: 18px;
}
.header-title { font-size: 17px; font-weight: 700; letter-spacing: 0.03em; }
.header-sub { font-size: 12px; color: rgba(255,255,255,0.5); margin-top: 1px; }
/* ── Controls ── */
.controls {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.ctrl-label { font-size: 13px; color: var(--ink-light); font-weight: 500; }
.ctrl-select {
font-family: inherit;
font-size: 14px;
font-weight: 500;
color: var(--ink);
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
cursor: pointer;
outline: none;
}
.ctrl-select:focus { border-color: var(--accent); }
/* 點數選擇 */
.point-btns { display: flex; gap: 4px; }
.point-btn {
font-family: 'DM Mono', monospace;
font-size: 13px;
font-weight: 500;
padding: 5px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--card);
color: var(--ink);
cursor: pointer;
transition: all 0.15s;
}
.point-btn:hover { border-color: var(--accent); color: var(--accent); }
.point-btn.active-add { background: var(--accent); border-color: var(--accent); color: #fff; }
.point-btn.active-sub { background: var(--red); border-color: var(--red); color: #fff; }
.mode-toggle {
display: flex; gap: 4px;
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 3px;
}
.mode-btn {
font-family: inherit; font-size: 13px; font-weight: 500;
padding: 5px 12px; border-radius: 6px; border: none;
cursor: pointer; transition: all 0.15s;
background: transparent; color: var(--ink-light);
}
.mode-btn.active-add { background: var(--accent); color: #fff; }
.mode-btn.active-sub { background: var(--red); color: #fff; }
.ctrl-divider { width: 1px; height: 24px; background: var(--border); }
/* ── Main layout ── */
.main { display: grid; grid-template-columns: 1fr 320px; gap: 1rem; padding: 1rem 1.5rem; max-width: 1400px; margin: 0 auto; }
@media(max-width: 900px) { .main { grid-template-columns: 1fr; } }
/* ── 組別區塊 ── */
.groups-area { display: flex; flex-direction: column; gap: 1rem; }
.group-section {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
}
.group-header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px;
background: var(--ink-2);
color: #fff;
}
.group-name { font-size: 14px; font-weight: 700; }
.group-total-badge {
font-family: 'DM Mono', monospace;
font-size: 12px; font-weight: 500;
background: rgba(255,255,255,0.15);
border-radius: 20px;
padding: 2px 10px;
}
.group-add-btn {
font-family: inherit; font-size: 12px; font-weight: 700;
padding: 4px 12px; border-radius: 6px; border: none;
cursor: pointer; transition: all 0.15s;
background: rgba(255,255,255,0.18); color: #fff; letter-spacing: 0.05em;
}
.group-add-btn:hover { background: rgba(255,255,255,0.32); transform: scale(1.04); }
.group-add-btn:active { transform: scale(0.97); }
.students-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 8px;
padding: 10px;
}
/* ── 學生卡片 ── */
.student-card {
background: var(--surface);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 6px;
display: flex; flex-direction: column; align-items: center;
cursor: pointer; transition: all 0.15s;
position: relative; overflow: hidden;
user-select: none;
}
.student-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow-md); }
.student-card:active { transform: scale(0.96); }
.student-num {
font-family: 'DM Mono', monospace;
font-size: 10px; color: var(--ink-light); margin-bottom: 2px;
}
.student-name { font-size: 13px; font-weight: 700; color: var(--ink); text-align: center; line-height: 1.3; }
.student-this {
font-family: 'DM Mono', monospace;
font-size: 18px; font-weight: 700; color: var(--accent);
margin: 4px 0 2px;
}
.student-total {
font-size: 11px; color: var(--ink-light);
}
.student-total span { font-weight: 700; color: var(--ink); }
/* flash動畫 */
@keyframes flashAdd { 0%{background:#c7d2fe} 100%{background:var(--surface)} }
@keyframes flashSub { 0%{background:#fecaca} 100%{background:var(--surface)} }
.flash-add { animation: flashAdd 0.4s ease-out; }
.flash-sub { animation: flashSub 0.4s ease-out; }
/* ── 右側面板 ── */
.side-panel { display: flex; flex-direction: column; gap: 1rem; }
/* ── 本堂前三名 ── */
.panel-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
box-shadow: var(--shadow);
}
.panel-title {
font-size: 13px; font-weight: 700; color: var(--ink-light);
text-transform: uppercase; letter-spacing: 0.07em;
margin-bottom: 12px; display: flex; align-items: center; gap: 6px;
}
.top3-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: var(--radius-sm);
margin-bottom: 6px; background: var(--surface);
}
.medal { font-size: 20px; width: 28px; text-align: center; }
.top3-name { font-size: 14px; font-weight: 700; flex: 1; }
.top3-pts {
font-family: 'DM Mono', monospace;
font-size: 15px; font-weight: 700; color: var(--accent);
}
.top3-empty { font-size: 13px; color: var(--ink-light); text-align: center; padding: 1rem 0; }
/* ── 累計排行榜 ── */
.rank-list { display: flex; flex-direction: column; gap: 4px; max-height: 480px; overflow-y: auto; }
.rank-item {
display: flex; align-items: center; gap: 8px;
padding: 7px 10px; border-radius: var(--radius-sm);
background: var(--surface); border: 1px solid transparent;
transition: all 0.15s;
}
.rank-item.rank-1 { background: var(--gold-light); border-color: #fde68a; }
.rank-item.rank-2 { background: #f1f5f9; border-color: #e2e8f0; }
.rank-item.rank-3 { background: #fff7ed; border-color: #fed7aa; }
.rank-no {
font-family: 'DM Mono', monospace;
font-size: 11px; font-weight: 700; color: var(--ink-light);
width: 20px; text-align: center;
}
.rank-name { font-size: 13px; font-weight: 700; flex: 1; }
.rank-group { font-size: 11px; color: var(--ink-light); }
.rank-pts {
font-family: 'DM Mono', monospace;
font-size: 14px; font-weight: 700; color: var(--accent);
}
/* ── Toast ── */
#toast {
position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%) translateY(80px);
background: var(--ink); color: #fff;
padding: 10px 20px; border-radius: 30px; font-size: 14px; font-weight: 500;
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
transition: transform 0.3s ease; z-index: 999; white-space: nowrap;
}
#toast.show { transform: translateX(-50%) translateY(0); }
/* ── Loading ── */
#loading {
position: fixed; inset: 0; background: rgba(247,246,255,0.92);
display: flex; align-items: center; justify-content: center;
z-index: 200; flex-direction: column; gap: 12px;
}
.spinner {
width: 36px; height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text { font-size: 14px; color: var(--ink-light); }
/* ── 隨機點名 ── */
.random-bar {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 0.6rem 1.5rem;
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
.random-label { font-size: 13px; color: var(--ink-light); font-weight: 500; white-space: nowrap; }
.random-btn {
font-family: inherit; font-size: 14px; font-weight: 700;
padding: 7px 20px; border-radius: var(--radius-sm);
border: none; cursor: pointer; transition: all 0.15s;
background: var(--accent); color: #fff; letter-spacing: 0.04em;
}
.random-btn:hover { background: var(--accent-2); transform: translateY(-1px); box-shadow: var(--shadow-md); }
.random-btn:active { transform: scale(0.97); }
.random-result {
display: flex; align-items: center; gap: 10px;
background: var(--accent-light);
border: 1.5px solid #c4b5fd;
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: 15px; font-weight: 700; color: var(--accent);
min-width: 160px;
transition: all 0.2s;
}
.random-result.hidden { opacity: 0; pointer-events: none; }
.random-result-name { font-size: 16px; }
.random-result-meta { font-size: 12px; font-weight: 400; color: var(--ink-light); margin-left: 4px; }
.random-history {
font-size: 12px; color: var(--ink-light);
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
}
.random-history-tag {
background: var(--surface-2); border: 1px solid var(--border);
border-radius: 20px; padding: 2px 8px; font-size: 11px;
color: var(--ink-light);
}
.random-clear {
font-family: inherit; font-size: 12px; color: var(--ink-light);
background: none; border: none; cursor: pointer; text-decoration: underline;
padding: 0;
}
.random-clear:hover { color: var(--red); }
.picked-highlight {
border-color: var(--accent) !important;
background: var(--accent-light) !important;
box-shadow: 0 0 0 2px #c4b5fd !important;
}
</style>
</head>
<body>
<!-- Loading -->
<div id="loading">
<div class="spinner"></div>
<div class="loading-text">載入學生資料中…</div>
</div>
<!-- Header -->
<div class="header">
<div class="header-left">
<div class="header-logo">🎯</div>
<div>
<div class="header-title">上課集點統計</div>
<div class="header-sub" id="header-sub">載入中…</div>
</div>
</div>
</div>
<!-- Controls -->
<div class="controls">
<div class="ctrl-label">堂次</div>
<select class="ctrl-select" id="lesson-select">
<option value="">選擇堂次</option>
</select>
<div class="ctrl-divider"></div>
<div class="ctrl-label">模式</div>
<div class="mode-toggle">
<button class="mode-btn active-add" id="mode-add" onclick="setMode('add')">➕ 加點</button>
<button class="mode-btn" id="mode-sub" onclick="setMode('sub')">➖ 扣點</button>
</div>
<div class="ctrl-divider"></div>
<div class="ctrl-label">點數</div>
<div class="point-btns" id="point-btns">
<button class="point-btn active-add" data-val="1" onclick="setPoints(1)">1</button>
<button class="point-btn" data-val="2" onclick="setPoints(2)">2</button>
<button class="point-btn" data-val="3" onclick="setPoints(3)">3</button>
</div>
</div>
<!-- 隨機點名列 -->
<div class="random-bar">
<span class="random-label">🎲 隨機點名</span>
<button class="random-btn" onclick="pickRandom()">抽一位同學</button>
<div class="random-result hidden" id="random-result">
<span>🎯</span>
<span class="random-result-name" id="random-name">—</span>
<span class="random-result-meta" id="random-meta"></span>
</div>
<div class="random-history" id="random-history" style="display:none">
<span style="color:var(--ink-light);font-size:11px">本次已抽:</span>
<span id="random-tags"></span>
<button class="random-clear" onclick="clearHistory()">清除記錄</button>
</div>
</div>
<!-- Main -->
<div class="main">
<!-- 左:組別+學生 -->
<div class="groups-area" id="groups-area"></div>
<!-- 右:排行 -->
<div class="side-panel">
<div class="panel-card">
<div class="panel-title">🏆 本堂課前三名</div>
<div id="top3-list"><div class="top3-empty">尚無點數資料</div></div>
</div>
<div class="panel-card">
<div class="panel-title">📊 累計排行榜</div>
<div class="rank-list" id="rank-list"></div>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast"></div>
<script>
// ── 狀態 ──────────────────────────────────────────────────
var allStudents = [];
var allGroups = {};
var currentLesson = 1;
var currentMode = 'add'; // 'add' | 'sub'
var currentPoints = 1;
var totalLessons = 30;
// ── 初始化 ─────────────────────────────────────────────────
window.onload = function() {
google.script.run
.withSuccessHandler(function(data) {
allStudents = data.students;
allGroups = data.groups;
totalLessons = data.totalLessons;
// 建立堂次下拉
var sel = document.getElementById('lesson-select');
for (var i = 1; i <= totalLessons; i++) {
var opt = document.createElement('option');
opt.value = i;
opt.textContent = '第 ' + i + ' 堂';
if (i === 1) opt.selected = true;
sel.appendChild(opt);
}
sel.addEventListener('change', function() {
currentLesson = parseInt(this.value);
renderAll();
updateHeaderSub();
});
document.getElementById('loading').style.display = 'none';
renderAll();
updateHeaderSub();
})
.withFailureHandler(function(err) {
document.getElementById('loading').innerHTML =
'<div style="color:#ef4444;font-size:14px;padding:2rem;text-align:center;">❌ 載入失敗:' + err.message + '<br>請確認已執行 initSheet()</div>';
})
.getAllStudents();
};
// ── Header 副標 ────────────────────────────────────────────
function updateHeaderSub() {
document.getElementById('header-sub').textContent =
'共 ' + allStudents.length + ' 位學生・第 ' + currentLesson + ' 堂(共 ' + totalLessons + ' 堂)';
}
// ── 模式切換 ───────────────────────────────────────────────
function setMode(mode) {
currentMode = mode;
var addBtn = document.getElementById('mode-add');
var subBtn = document.getElementById('mode-sub');
addBtn.className = 'mode-btn' + (mode === 'add' ? ' active-add' : '');
subBtn.className = 'mode-btn' + (mode === 'sub' ? ' active-sub' : '');
// 更新點數按鈕樣式
updatePointBtnStyles();
}
// ── 點數切換 ───────────────────────────────────────────────
function setPoints(val) {
currentPoints = val;
updatePointBtnStyles();
}
function updatePointBtnStyles() {
var btns = document.querySelectorAll('.point-btn');
btns.forEach(function(btn) {
var isActive = parseInt(btn.dataset.val) === currentPoints;
if (isActive) {
btn.className = 'point-btn ' + (currentMode === 'add' ? 'active-add' : 'active-sub');
} else {
btn.className = 'point-btn';
}
});
}
// ── 取得 delta ─────────────────────────────────────────────
function getDelta() {
return currentMode === 'add' ? currentPoints : -currentPoints;
}
// ── 渲染全部 ───────────────────────────────────────────────
function renderAll() {
renderGroups();
updateRanking();
updateTop3();
}
// ── 渲染組別與學生 ─────────────────────────────────────────
function renderGroups() {
var area = document.getElementById('groups-area');
area.innerHTML = '';
var groupNames = Object.keys(allGroups).sort();
groupNames.forEach(function(groupName) {
var students = allGroups[groupName];
// 計算組別本堂課總點
var groupThisTotal = students.reduce(function(sum, s) {
return sum + (s.lessons[currentLesson - 1] || 0);
}, 0);
var section = document.createElement('div');
section.className = 'group-section';
// 組別標題
var header = document.createElement('div');
header.className = 'group-header';
header.innerHTML =
'<div style="display:flex;align-items:center;gap:10px">' +
'<div class="group-name">🏷 ' + groupName + '</div>' +
'<div class="group-total-badge" id="g-badge-' + encodeId(groupName) + '">本堂 ' + groupThisTotal + ' 點</div>' +
'</div>' +
'<button class="group-add-btn" onclick="clickGroup(\'' + escapeSingle(groupName) + '\')">全組 ' + (currentMode === 'add' ? '+' : '–') + currentPoints + ' 點</button>';
section.appendChild(header);
// 學生格子
var grid = document.createElement('div');
grid.className = 'students-grid';
students.forEach(function(s) {
var card = document.createElement('div');
card.className = 'student-card';
card.id = 'card-' + s.row;
card.innerHTML =
'<div class="student-num">No.' + s.number + '</div>' +
'<div class="student-name">' + s.name + '</div>' +
'<div class="student-this" id="pts-this-' + s.row + '">' + (s.lessons[currentLesson - 1] || 0) + '</div>' +
'<div class="student-total">累計 <span id="pts-total-' + s.row + '">' + s.total + '</span> 點</div>';
card.onclick = (function(student) {
return function() { clickStudent(student); };
})(s);
grid.appendChild(card);
});
section.appendChild(grid);
area.appendChild(section);
});
}
// ── 點擊學生 ───────────────────────────────────────────────
function clickStudent(student) {
var delta = getDelta();
var lesson = currentLesson;
// 樂觀更新 UI
student.lessons[lesson - 1] = (student.lessons[lesson - 1] || 0) + delta;
student.total = student.total + delta;
var thisEl = document.getElementById('pts-this-' + student.row);
var totalEl = document.getElementById('pts-total-' + student.row);
var card = document.getElementById('card-' + student.row);
if (thisEl) thisEl.textContent = student.lessons[lesson - 1];
if (totalEl) totalEl.textContent = student.total;
if (card) {
card.classList.remove('flash-add', 'flash-sub');
void card.offsetWidth;
card.classList.add(delta > 0 ? 'flash-add' : 'flash-sub');
}
updateGroupBadge(student.group);
updateTop3();
updateRanking();
var sign = delta > 0 ? '+' : '';
showToast('✅ ' + student.name + ' ' + sign + delta + ' 點');
// 送出後端
google.script.run
.withFailureHandler(function(err) { showToast('❌ 儲存失敗:' + err.message); })
.updateStudentPoint(student.row, lesson, delta);
}
// ── 點擊組別 ───────────────────────────────────────────────
function clickGroup(groupName) {
var delta = getDelta();
var lesson = currentLesson;
var students = allGroups[groupName];
students.forEach(function(s) {
s.lessons[lesson - 1] = (s.lessons[lesson - 1] || 0) + delta;
s.total = s.total + delta;
var thisEl = document.getElementById('pts-this-' + s.row);
var totalEl = document.getElementById('pts-total-' + s.row);
var card = document.getElementById('card-' + s.row);
if (thisEl) thisEl.textContent = s.lessons[lesson - 1];
if (totalEl) totalEl.textContent = s.total;
if (card) {
card.classList.remove('flash-add', 'flash-sub');
void card.offsetWidth;
card.classList.add(delta > 0 ? 'flash-add' : 'flash-sub');
}
});
updateGroupBadge(groupName);
updateTop3();
updateRanking();
var sign = delta > 0 ? '+' : '';
showToast('✅ ' + groupName + ' 全組 ' + sign + delta + ' 點(共 ' + students.length + ' 人)');
google.script.run
.withFailureHandler(function(err) { showToast('❌ 儲存失敗:' + err.message); })
.updateGroupPoint(groupName, lesson, delta);
}
// ── 更新組別本堂徽章 ──────────────────────────────────────
function updateGroupBadge(groupName) {
var badge = document.getElementById('g-badge-' + encodeId(groupName));
if (!badge) return;
var students = allGroups[groupName];
var total = students.reduce(function(sum, s) {
return sum + (s.lessons[currentLesson - 1] || 0);
}, 0);
badge.textContent = '本堂 ' + total + ' 點';
}
// ── 本堂課前三名 ──────────────────────────────────────────
function updateTop3() {
var sorted = allStudents.slice().sort(function(a, b) {
return (b.lessons[currentLesson - 1] || 0) - (a.lessons[currentLesson - 1] || 0);
});
var top = sorted.slice(0, 3).filter(function(s) {
return (s.lessons[currentLesson - 1] || 0) > 0;
});
var medals = ['🥇', '🥈', '🥉'];
var el = document.getElementById('top3-list');
if (top.length === 0) {
el.innerHTML = '<div class="top3-empty">本堂尚無點數記錄</div>';
return;
}
el.innerHTML = top.map(function(s, i) {
return '<div class="top3-item">' +
'<div class="medal">' + medals[i] + '</div>' +
'<div class="top3-name">' + s.name + '<br><span style="font-size:11px;color:var(--ink-light);font-weight:400">' + s.group + '</span></div>' +
'<div class="top3-pts">' + (s.lessons[currentLesson - 1] || 0) + '</div>' +
'</div>';
}).join('');
}
// ── 累計排行榜 ────────────────────────────────────────────
function updateRanking() {
var sorted = allStudents.slice().sort(function(a, b) {
return b.total - a.total;
});
var el = document.getElementById('rank-list');
el.innerHTML = sorted.map(function(s, i) {
var rankClass = i === 0 ? ' rank-1' : i === 1 ? ' rank-2' : i === 2 ? ' rank-3' : '';
var rankLabel = i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : (i + 1);
return '<div class="rank-item' + rankClass + '">' +
'<div class="rank-no">' + rankLabel + '</div>' +
'<div class="rank-name">' + s.name + '</div>' +
'<div class="rank-group">' + s.group + '</div>' +
'<div class="rank-pts">' + s.total + '</div>' +
'</div>';
}).join('');
}
// ── Toast ─────────────────────────────────────────────────
var toastTimer;
function showToast(msg) {
var el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(function() { el.classList.remove('show'); }, 2000);
}
// ── 隨機點名 ────────────────────────────────────────────────
var pickedHistory = []; // 本次已抽過的 row id
function pickRandom() {
if (allStudents.length === 0) { showToast('⚠️ 尚未載入學生資料'); return; }
// 排除已抽過的,若全部都抽過則重置
var pool = allStudents.filter(function(s) { return pickedHistory.indexOf(s.row) === -1; });
if (pool.length === 0) {
showToast('🔄 全班都抽過了,重新開始!');
pickedHistory = [];
pool = allStudents.slice();
}
var picked = pool[Math.floor(Math.random() * pool.length)];
pickedHistory.push(picked.row);
// 顯示結果
var resultEl = document.getElementById('random-result');
var nameEl = document.getElementById('random-name');
var metaEl = document.getElementById('random-meta');
nameEl.textContent = picked.name;
metaEl.textContent = picked.group + '・No.' + picked.number;
resultEl.classList.remove('hidden');
// 高亮學生卡片
document.querySelectorAll('.student-card').forEach(function(c) {
c.classList.remove('picked-highlight');
});
var card = document.getElementById('card-' + picked.row);
if (card) {
card.classList.add('picked-highlight');
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// 更新已抽記錄標籤
updateHistoryTags();
showToast('🎯 抽到:' + picked.name + '(' + picked.group + ')');
}
function updateHistoryTags() {
var histDiv = document.getElementById('random-history');
var tagsEl = document.getElementById('random-tags');
if (pickedHistory.length === 0) { histDiv.style.display = 'none'; return; }
histDiv.style.display = 'flex';
var names = pickedHistory.map(function(rowId) {
var s = allStudents.find(function(st) { return st.row === rowId; });
return s ? s.name : '';
}).filter(Boolean);
tagsEl.innerHTML = names.map(function(n) {
return '<span class="random-history-tag">' + n + '</span>';
}).join('');
}
function clearHistory() {
pickedHistory = [];
document.getElementById('random-history').style.display = 'none';
document.getElementById('random-result').classList.add('hidden');
document.querySelectorAll('.student-card').forEach(function(c) {
c.classList.remove('picked-highlight');
});
showToast('🗑 點名記錄已清除');
}
// ── 工具 ──────────────────────────────────────────────────
function encodeId(str) { return str.replace(/\s/g, '_').replace(/[^\w]/g, ''); }
function escapeSingle(str) { return str.replace(/'/g, "\\'"); }
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案
6. 部署
部署成 Web App 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論