雖然運用 Google 表單就很方便了,
但想說問問 Gemini 是否可以利用 Google 試算表 Apps Script 來製作線上投票系統!
結果就生成下面的成果啦!

Apps Script 程式碼:
// Code.gs - 強效驗證修正版
var ss = SpreadsheetApp.getActiveSpreadsheet();
var configSheet = ss.getSheetByName("Setting");
var voteSheet = ss.getSheetByName("Votes");
function doGet() {
return HtmlService.createTemplateFromFile('Index')
.evaluate()
.setTitle('線上投票系統')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// 修正 1: 移除 'E',避免 Excel/Sheet 將序號誤判為科學記號 (例如 1E20)
function generateShortID() {
// 移除了 I, 1, O, 0, E (避免科學記號)
var chars = 'ABCDFGHJKLMNPQRSTUVWXYZ23456789';
var result = '';
for (var i = 0; i < 4; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
function getInitialData() {
var data = configSheet.getDataRange().getValues();
var questions = [];
var pageTitle = (data.length > 0 && data[0][0]) ? data[0][0] : "線上投票活動";
if (data.length > 2) {
var numColumns = data[0].length;
for (var col = 0; col < numColumns; col++) {
var title = (data.length > 2) ? data[2][col] : "";
var typeRaw = (data.length > 3) ? data[3][col] : "";
if (!title) continue;
var type = 'radio';
if (typeRaw && typeRaw.toString().trim() === '複選') type = 'checkbox';
if (typeRaw && typeRaw.toString().trim() === '簡答') type = 'text';
var options = [];
if (type !== 'text') {
for (var row = 4; row < data.length; row++) {
if (data[row][col]) options.push(data[row][col]);
}
}
questions.push({ id: col, title: title, type: type, options: options });
}
}
return { pageTitle: pageTitle, questions: questions, results: getResults(questions) };
}
// 修正 2: 嚴格的比對邏輯
function checkUserStatus(nickname, accessCode) {
// 強制轉字串 + 去除頭尾空白
nickname = String(nickname).trim();
accessCode = String(accessCode).trim().toUpperCase();
var userAnswers = {};
var status = "NEW";
var savedCode = "";
var lastRow = voteSheet.getLastRow();
if (lastRow >= 2) {
// 讀取範圍:B:序號, C:暱稱, D~:答案
var data = voteSheet.getRange(2, 2, lastRow - 1, voteSheet.getLastColumn() - 1).getValues();
// 倒序搜尋 (從最新的一筆開始找),防止有舊的重複資料干擾
for (var i = data.length - 1; i >= 0; i--) {
// 修正 3: 強制將資料庫取出的值轉為 String 再 trim,防止數字型態干擾
var dbCode = String(data[i][0]).trim().toUpperCase();
var dbName = String(data[i][1]).trim();
if (dbName == nickname) {
// 找到暱稱了
if (accessCode === "") {
status = "EXIST_NO_CODE";
} else {
if (dbCode == accessCode) {
status = "MATCH";
savedCode = dbCode;
// 載入答案
for (var j = 2; j < data[i].length; j++) {
userAnswers[j-2] = data[i][j];
}
} else {
status = "ERROR";
}
}
break; // 找到最新的一筆就停止
}
}
}
return { status: status, savedCode: savedCode, userAnswers: userAnswers };
}
function submitVote(nickname, accessCode, answers) {
if (!nickname) return { success: false, message: "沒有輸入暱稱" };
nickname = String(nickname).trim();
accessCode = String(accessCode).trim().toUpperCase();
var lock = LockService.getScriptLock();
try {
lock.waitLock(10000);
var lastRow = voteSheet.getLastRow();
var rowIndex = -1;
var finalCode = accessCode;
// 找人
if (lastRow >= 2) {
var authData = voteSheet.getRange(2, 2, lastRow - 1, 2).getValues();
// 同樣倒序搜尋
for (var i = authData.length - 1; i >= 0; i--) {
var dbCode = String(authData[i][0]).trim().toUpperCase();
var dbName = String(authData[i][1]).trim();
if (dbName == nickname) {
if (accessCode && dbCode == accessCode) {
rowIndex = i + 2; // 因為陣列是0-base,但Excel是從第2列開始,所以是 i + 2
finalCode = dbCode;
} else if (!accessCode) {
return { success: false, message: "暱稱已被使用,請輸入序號。" };
} else {
return { success: false, message: "序號錯誤。" };
}
break;
}
}
}
var configCols = configSheet.getLastColumn();
if (rowIndex === -1) {
// 新增
if (!finalCode) finalCode = generateShortID();
var newRow = [new Date(), finalCode, nickname]; // 強制寫入字串
for (var k = 0; k < configCols; k++) {
var val = answers[k];
if(Array.isArray(val)) val = val.join(",");
newRow.push(val || "");
}
voteSheet.appendRow(newRow);
} else {
// 更新
voteSheet.getRange(rowIndex, 1).setValue(new Date());
// 序號和暱稱不變,只更新答案
for (var k = 0; k < configCols; k++) {
var val = answers[k];
if(Array.isArray(val)) val = val.join(",");
voteSheet.getRange(rowIndex, 4 + k).setValue(val || "");
}
}
var qData = getInitialData();
return { success: true, userCode: finalCode, results: qData.results };
} catch (e) {
return { success: false, message: "錯誤: " + e.toString() };
} finally {
lock.releaseLock();
}
}
function getResults(questions) {
var lastRow = voteSheet.getLastRow();
var allCounts = {};
questions.forEach(function(q){ allCounts[q.id] = {}; });
if (lastRow < 2) return allCounts;
var voteData = voteSheet.getRange(2, 4, lastRow - 1, questions.length).getValues();
for (var i = 0; i < voteData.length; i++) {
var row = voteData[i];
for (var col = 0; col < row.length; col++) {
var rawAnswer = row[col];
if (rawAnswer && allCounts[col]) {
var qType = questions[col] ? questions[col].type : 'radio';
if (qType === 'text') {
var ans = String(rawAnswer).trim();
if(ans) {
if (!allCounts[col][ans]) allCounts[col][ans] = 0;
allCounts[col][ans]++;
}
} else {
var answers = String(rawAnswer).split(",");
answers.forEach(function(ans) {
ans = ans.trim();
if(ans) {
if (!allCounts[col][ans]) allCounts[col][ans] = 0;
allCounts[col][ans]++;
}
});
}
}
}
}
return allCounts;
}
Index.html
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link rel="stylesheet" href="https://www.w3schools.com/w3css/4/w3.css">
<style>
body { font-family: "Microsoft JhengHei", "Heiti TC", sans-serif; background-color: #FFF3E0; }
.container { max-width: 600px; margin: 30px auto; background: #fff; padding: 25px; border-radius: 20px; box-shadow: 0 8px 16px rgba(0,0,0,0.1); border: 3px solid #FFB74D; position: relative; }
h2, h3 { color: #E65100; font-weight: bold; }
/* 右上角連結 */
.top-link {
position: absolute; top: 20px; right: 20px;
text-decoration: none; color: #2196F3; font-weight: bold; font-size: 0.9em;
border: 1px solid #2196F3; padding: 5px 12px; border-radius: 20px;
transition: 0.3s; cursor: pointer; background: #E3F2FD; z-index: 10;
}
.top-link:hover { background-color: #2196F3; color: white; }
.big-btn { font-size: 20px; padding: 12px; border-radius: 15px; margin-top: 15px; font-weight: bold; }
.input-label { color: #E65100; font-weight: bold; font-size: 1.1em; margin-top: 15px; display:block;}
.input-desc { color: #888; font-size: 0.85em; margin-bottom: 5px; display:block;}
.login-input { font-size: 20px; text-align: center; padding: 10px; border: 2px solid #FF9800; border-radius: 10px; width: 100%; background-color: #FFFDE7; outline: none; }
/* 序號顯示區塊 */
.code-display { font-size: 32px; font-weight: bold; letter-spacing: 5px; color: #E65100; background: #FFE0B2; padding: 10px; border-radius: 10px; margin: 10px 0; border: 2px dashed #E65100; }
.question-block { margin-bottom: 30px; padding: 15px; background: #fff; border-left: 5px solid #FF9800; }
.question-title { font-size: 1.2em; font-weight: bold; color: #555; margin-bottom: 5px; }
.question-type { font-size: 0.8em; color: #999; margin-bottom: 10px; display: inline-block; background: #eee; padding: 2px 6px; border-radius: 4px;}
.option-item { padding: 10px 15px; border: 2px solid #EEE; border-radius: 10px; margin-bottom: 8px; cursor: pointer; background: #FAFAFA; display: flex; align-items: center;}
.option-item:hover { background-color: #FFF8E1; border-color: #FFB74D; }
input[type=radio], input[type=checkbox] { width: 22px; height: 22px; margin-right: 12px; accent-color: #FF9800; }
textarea.text-answer { width: 100%; padding: 10px; border: 2px solid #ddd; border-radius: 10px; background-color: #fafafa; font-size: 16px; resize: vertical; }
/* 看板 */
.dashboard-item { margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
.dashboard-title { font-size: 1.1em; color: #333; margin-bottom: 8px; font-weight: bold; }
.bar-wrapper { margin-bottom: 6px; font-size: 0.9em; }
.bar-bg { background-color: #f1f1f1; border-radius: 5px; height: 20px; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #8BC34A); width: 0%; transition: width 0.8s; text-align: right; color: white; padding-right: 5px; line-height: 20px; font-size: 11px;}
.text-result-list { list-style: none; padding: 0; margin: 0; }
.text-result-item { background: #f9f9f9; padding: 8px 12px; margin-bottom: 5px; border-radius: 5px; font-size: 14px; display: flex; justify-content: space-between; align-items: center;}
.text-count { background: #eee; color: #555; font-size: 12px; padding: 2px 8px; border-radius: 10px; font-weight: bold;}
.hidden { display: none; }
/* 彈窗 */
#success-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); display: none; align-items: center; justify-content: center; z-index: 1000; }
.modal-content { background: white; width: 90%; max-width: 400px; padding: 25px; border-radius: 20px; text-align: center; border: 4px solid #4CAF50; animation: popIn 0.3s; }
@keyframes popIn { from {transform: scale(0.8); opacity: 0;} to {transform: scale(1); opacity: 1;} }
</style>
</head>
<body>
<div class="container">
<a class="top-link" onclick="loadDashboard()">📊 看投票結果</a>
<header class="w3-center">
<h2 id="page-title">載入中...</h2>
</header>
<hr>
<div id="login-section" class="w3-center">
<label class="input-label">你的暱稱</label>
<span class="input-desc">第一次只需填這個</span>
<input type="text" id="input-name" class="login-input" placeholder="例如:01小明" onkeypress="handleEnter(event)" autocomplete="off">
<div id="code-wrapper" class="hidden" style="margin-top: 15px; padding: 10px; background: #FFF3E0; border-radius: 10px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<label class="input-label" style="margin-top:0;">系統序號</label>
<button class="w3-button w3-tiny w3-red w3-round" onclick="resetLogin()">❌ 換一個新暱稱</button>
</div>
<span class="input-desc w3-text-red"><b>暱稱已存在!如果是你,請輸入序號:</b></span>
<input type="text" id="input-code" class="login-input" placeholder="例如: A1B2" onkeypress="handleEnter(event)" autocomplete="off" style="background-color: #fff;">
</div>
<button class="w3-button w3-orange w3-text-white w3-hover-deep-orange w3-block big-btn" onclick="checkUser()">開始 / 驗證</button>
</div>
<div id="vote-section" class="hidden">
<div class="w3-center w3-margin-bottom">
<span class="w3-tag w3-round w3-orange w3-text-white" style="font-size:16px">我是:<span id="display-name"></span></span>
</div>
<p id="status-text" class="w3-center w3-text-blue">請回答以下問題:</p>
<div id="questions-container"></div>
<button id="btn-submit" class="w3-button w3-green w3-hover-teal w3-block big-btn" onclick="submitVote()">送出答案</button>
</div>
<div id="dashboard-section" class="hidden">
<h3 class="w3-center w3-text-green">📊 即時統計結果</h3>
<div class="w3-center w3-margin-bottom">
<button class="w3-button w3-small w3-white w3-border w3-round" onclick="refreshDashboard()">🔄 刷新數據</button>
</div>
<div id="dashboard-container"></div>
<div class="w3-center w3-margin-top">
<button class="w3-button w3-light-grey w3-block" onclick="backToHome()">⬅️ 回到投票首頁</button>
</div>
</div>
<div id="loader" class="hidden w3-center w3-margin-top"><h3 class="w3-text-grey"><i class="w3-spin">⟳</i> 讀取中...</h3></div>
</div>
<div id="success-modal">
<div class="modal-content">
<h2 class="w3-text-green" style="margin-top:0;">✅ 投票成功!</h2>
<p>你的專屬序號是:</p>
<div id="modal-code" class="code-display">----</div>
<p class="w3-small w3-text-red">系統已自動幫您記住!<br>下次打開網頁欄位會自動填入。</p>
<button class="w3-button w3-green w3-round-large w3-block big-btn" onclick="closeModal()">好,繼續看結果</button>
</div>
</div>
<script>
var currentUserName = "";
var currentCode = "";
var allQuestions = [];
window.onload = function() {
// 1. 載入標題
google.script.run.withSuccessHandler(function(data){
document.getElementById('page-title').innerText = data.pageTitle;
document.title = data.pageTitle;
allQuestions = data.questions;
// 2. 恢復記憶功能:自動填入但不登入
var storedName = localStorage.getItem("vote_name");
var storedCode = localStorage.getItem("vote_code");
if (storedName) {
document.getElementById('input-name').value = storedName;
}
if (storedCode) {
document.getElementById('input-code').value = storedCode;
}
}).getInitialData();
};
function handleEnter(e){ if(e.keyCode === 13) checkUser(); }
function loadDashboard() {
toggleLoader(true);
google.script.run.withSuccessHandler(function(data){
toggleLoader(false);
document.getElementById('page-title').innerText = data.pageTitle;
document.getElementById('login-section').classList.add('hidden');
document.getElementById('vote-section').classList.add('hidden');
document.getElementById('dashboard-section').classList.remove('hidden');
renderDashboard(data.questions, data.results);
}).getInitialData();
}
function refreshDashboard() { loadDashboard(); }
function backToHome() {
document.getElementById('dashboard-section').classList.add('hidden');
document.getElementById('login-section').classList.remove('hidden');
window.scrollTo(0,0);
}
function renderDashboard(questions, allResults) {
var container = document.getElementById('dashboard-container');
var html = '';
questions.forEach(function(q) {
var results = allResults[q.id] || {};
var total = 0;
for (var key in results) total += results[key];
html += '<div class="dashboard-item"><div class="dashboard-title">' + (q.id + 1) + '. ' + q.title + '</div>';
if (total === 0) {
html += '<p class="w3-small w3-text-grey" style="padding-left:10px;">(尚無回答)</p>';
} else {
if (q.type === 'text') {
html += '<ul class="text-result-list">';
for (var answerText in results) {
var count = results[answerText];
html += '<li class="text-result-item"><span>' + answerText + '</span>';
if(count > 1) html += '<span class="text-count">' + count + '人</span>';
html += '</li>';
}
html += '</ul>';
} else {
var sortedKeys = Object.keys(results).sort(function(a,b){return results[b]-results[a]});
sortedKeys.forEach(function(key) {
var count = results[key];
var percent = Math.round((count / total) * 100);
html += '<div class="bar-wrapper"><div style="display:flex; justify-content:space-between; font-size:14px;"><span>' + key + '</span><span>' + count + ' 票 (' + percent + '%)</span></div><div class="bar-bg"><div class="bar-fill" style="width:' + percent + '%"></div></div></div>';
});
}
}
html += '</div>';
});
container.innerHTML = html;
}
// 重置登入
function resetLogin() {
document.getElementById('input-name').value = "";
document.getElementById('input-code').value = "";
document.getElementById('code-wrapper').classList.add('hidden');
document.getElementById('input-name').focus();
}
// 檢查用戶
function checkUser() {
var name = document.getElementById('input-name').value;
var code = document.getElementById('input-code').value;
name = name ? name.trim() : "";
code = code ? code.trim() : "";
if (!name) { alert("請輸入暱稱!"); return; }
currentUserName = name;
currentCode = code;
toggleLoader(true);
google.script.run.withSuccessHandler(function(data){
toggleLoader(false);
// 狀況一:需要序號
if (data.status === "EXIST_NO_CODE") {
document.getElementById('code-wrapper').classList.remove('hidden');
document.getElementById('input-code').focus();
return;
}
// 狀況二:序號錯誤
if (data.status === "ERROR") {
alert("序號錯誤!驗證失敗。");
return;
}
// 狀況三:登入成功 (NEW 或 MATCH)
document.getElementById('login-section').classList.add('hidden');
document.getElementById('vote-section').classList.remove('hidden');
document.getElementById('display-name').innerText = currentUserName;
var statusText = document.getElementById('status-text');
var submitBtn = document.getElementById('btn-submit');
if (data.status === "MATCH") {
// === 修改模式 ===
statusText.innerHTML = "驗證成功!正在修改舊答案:";
currentCode = data.savedCode;
// 修改按鈕文字與顏色
submitBtn.innerText = "修改答案";
submitBtn.classList.remove('w3-green');
submitBtn.classList.add('w3-blue');
} else {
// === 新增模式 ===
statusText.innerHTML = "請回答以下問題:";
currentCode = "";
// 修改按鈕文字與顏色
submitBtn.innerText = "送出答案";
submitBtn.classList.remove('w3-blue');
submitBtn.classList.add('w3-green');
}
renderVoteForm(allQuestions, data.userAnswers);
}).checkUserStatus(currentUserName, currentCode);
}
function renderVoteForm(questions, userAnswers) {
var container = document.getElementById('questions-container');
var html = '';
questions.forEach(function(q) {
var typeLabel = '單選';
if(q.type === 'checkbox') typeLabel = '複選';
if(q.type === 'text') typeLabel = '簡答';
html += '<div class="question-block"><div class="question-title">' + (q.id + 1) + '. ' + q.title + '</div><div class="question-type">' + typeLabel + '</div><div id="opts-area-' + q.id + '">';
if (q.type === 'text') {
var val = (userAnswers && userAnswers[q.id]) ? userAnswers[q.id] : '';
html += '<textarea class="text-answer" rows="3" name="q-' + q.id + '" placeholder="請輸入...">' + val + '</textarea>';
} else {
q.options.forEach(function(opt, idx) {
var isChecked = false;
if(userAnswers && userAnswers[q.id]) {
var savedAns = userAnswers[q.id].toString().split(',');
if(savedAns.includes(opt.toString())) isChecked = true;
}
var checkedAttr = isChecked ? 'checked' : '';
var bgClass = isChecked ? 'w3-pale-yellow' : '';
html += '<div class="option-item ' + bgClass + '" onclick="selectInput('+q.id+', '+idx+')"><input type="' + q.type + '" name="q-' + q.id + '" value="' + opt + '" id="q'+q.id+'-opt'+idx+'" ' + checkedAttr + '><label for="q'+q.id+'-opt'+idx+'" style="cursor:pointer; flex-grow:1; margin-left:10px;">' + opt + '</label></div>';
});
}
html += '</div></div>';
});
container.innerHTML = html;
}
function selectInput(qId, optIdx) {
var input = document.getElementById('q'+qId+'-opt'+optIdx);
if(!input) return;
if (input.type === 'radio') input.checked = true;
else input.checked = !input.checked;
}
function submitVote() {
var answers = {};
var allAnswered = true;
allQuestions.forEach(function(q) {
if (q.type === 'text') {
var inputs = document.getElementsByName('q-' + q.id);
if(inputs.length > 0) {
var val = inputs[0].value.trim();
if(val) answers[q.id] = val; else allAnswered = false;
}
} else {
var inputs = document.getElementsByName('q-' + q.id);
if (q.type === 'radio') {
var val = null;
for(var i=0; i<inputs.length; i++){ if(inputs[i].checked) { val = inputs[i].value; break; } }
if(val) answers[q.id] = val; else allAnswered = false;
} else {
var vals = [];
for(var i=0; i<inputs.length; i++){ if(inputs[i].checked) vals.push(inputs[i].value); }
if(vals.length > 0) answers[q.id] = vals; else allAnswered = false;
}
}
});
if (!allAnswered && !confirm("有些題目還沒填,確定要送出嗎?")) return;
toggleLoader(true);
google.script.run.withSuccessHandler(function(response) {
toggleLoader(false);
if (response.success) {
// 儲存到 localStorage
localStorage.setItem("vote_name", currentUserName);
localStorage.setItem("vote_code", response.userCode);
document.getElementById('modal-code').innerText = response.userCode;
document.getElementById('success-modal').style.display = 'flex';
} else {
alert(response.message);
}
}).submitVote(currentUserName, currentCode, answers);
}
function closeModal() {
document.getElementById('success-modal').style.display = 'none';
loadDashboard();
}
function logout() {
localStorage.removeItem("vote_name");
localStorage.removeItem("vote_code");
resetLogin(); // 重置登入畫面
document.getElementById('vote-section').classList.add('hidden');
document.getElementById('login-section').classList.remove('hidden');
currentUserName = "";
currentCode = "";
window.scrollTo(0,0);
alert("已登出!");
}
function toggleLoader(show) {
var loader = document.getElementById('loader');
var opacity = show ? "0.5" : "1";
if(show) loader.classList.remove('hidden'); else loader.classList.add('hidden');
document.getElementById('vote-section').style.opacity = opacity;
document.getElementById('login-section').style.opacity = opacity;
document.getElementById('dashboard-section').style.opacity = opacity;
}
</script>
</body>
</html>
快來試試吧~~

沒有留言:
張貼留言
歡迎大家一起留言討論!