0. 作品介紹
預設圖片
1. AI 提示詞
把這段指令貼給 AI,讓它幫你生成程式碼。
我想要建立一個 【旅遊規劃小工具】
幫我用Google 試算表建立 【旅遊資料庫】
我希望有些資料我可以直接在試算表更改首頁可以直接呈現
需求說明 【
1.我要設計一個旅遊行程規劃工具
2.我可以安排好幾天的行程
3.每個行程都是一張卡片
4.我可以把卡片拖動到不同時間點
5.它會自動幫我調整時間
6.例如我這張卡片設定是1小時行程 我把他跟下一個行程1.5小時的交換 它會自動更新時間安排 900-1000 變成 900-1030
7.旅遊規劃工具他會有很多可以建置的根基本模板
8.基本模板就是 一些固定行程卡片 例如 午餐、早餐、晚餐、交通、集合之類的
】
要把它變成程式碼我要發佈
請完整敘述操作步驟 越詳細越好
作業環境
Google試算表
Google Apps Script
我看不懂程式碼,請給我後端gs完整程式碼、前端index完整程式碼
幫我用Google 試算表建立 【旅遊資料庫】
我希望有些資料我可以直接在試算表更改首頁可以直接呈現
需求說明 【
1.我要設計一個旅遊行程規劃工具
2.我可以安排好幾天的行程
3.每個行程都是一張卡片
4.我可以把卡片拖動到不同時間點
5.它會自動幫我調整時間
6.例如我這張卡片設定是1小時行程 我把他跟下一個行程1.5小時的交換 它會自動更新時間安排 900-1000 變成 900-1030
7.旅遊規劃工具他會有很多可以建置的根基本模板
8.基本模板就是 一些固定行程卡片 例如 午餐、早餐、晚餐、交通、集合之類的
】
要把它變成程式碼我要發佈
請完整敘述操作步驟 越詳細越好
作業環境
Google試算表
Google Apps Script
我看不懂程式碼,請給我後端gs完整程式碼、前端index完整程式碼
2. 資料表規則
請照下面規則建立分頁與欄位。
第1分頁: 行程資料
A 欄: 天數
B 欄: 活動名稱
C 欄: 停留時間
D 欄: 類別
E 欄: 備註
第2分頁: 基礎模板
A 欄: 模板名稱
B 欄: 預設時間(分鐘)
C 欄: 類別
第3分頁: 行程設定
A 欄: 天數ID
B 欄: 開始時間
📄 解析結果(自動表格)
分頁:行程資料
| A 欄 | B 欄 | C 欄 | D 欄 | E 欄 |
|---|---|---|---|---|
| 天數 | 活動名稱 | 停留時間 | 類別 | 備註 |
分頁:基礎模板
| A 欄 | B 欄 | C 欄 |
|---|---|---|
| 模板名稱 | 預設時間(分鐘) | 類別 |
分頁:行程設定
| A 欄 | B 欄 |
|---|---|
| 天數ID | 開始時間 |
3. 開啟 Apps Script
從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。
- 試算表上方選單:擴充功能 → Apps Script
- 刪掉預設的程式碼(或全部選取覆蓋)
-
保留/建立檔案:
- Code.gs
- index.html
4. 貼上後端(Code.gs)
把後端程式貼到 Apps Script 的 Code.gs。
// Code.gs - 旅遊規劃工具 V6 (WebApp 穩定版:固定 SpreadsheetId)
var SPREADSHEET_ID = '換成自己的試算表';
// 0) 取得 Spreadsheet(避免 WebApp 沒有 ActiveSpreadsheet)
function getSS_() {
if (!SPREADSHEET_ID || SPREADSHEET_ID === '請貼上你的試算表ID') {
throw new Error('尚未設定 SPREADSHEET_ID:請把試算表網址中的 /d/.../ 那段ID貼進來');
}
return SpreadsheetApp.openById(SPREADSHEET_ID);
}
function doGet() {
return HtmlService.createTemplateFromFile('index')
.evaluate()
.setTitle('旅遊規劃神器 V6')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
// 1. 確保資料表結構存在
function ensureSheetsExist_() {
var ss = getSS_();
if (!ss.getSheetByName('行程資料')) {
ss.insertSheet('行程資料').appendRow(['天數', '活動名稱', '停留時間', '類別', '備註']);
}
if (!ss.getSheetByName('基礎模板')) {
var t = ss.insertSheet('基礎模板');
t.appendRow(['模板名稱', '預設時間(分鐘)', '類別']);
t.appendRow(['早餐', 60, '用餐']);
t.appendRow(['午餐', 90, '用餐']);
} else {
// 若你以前 header 叫「預設時間」也沒差,不影響資料讀寫
}
if (!ss.getSheetByName('行程設定')) {
ss.insertSheet('行程設定').appendRow(['天數ID', '開始時間']);
}
}
// 2. 核心功能:讀取資料 (包含修復邏輯)
function getAllData() {
ensureSheetsExist_();
var ss = getSS_();
// A. 行程卡片
var sheetItinerary = ss.getSheetByName('行程資料');
var itineraryData = [];
var lastRowIt = sheetItinerary.getLastRow();
if (lastRowIt > 1) {
// 用 getDisplayValues 更不容易遇到時間/數字型別問題
var rawData = sheetItinerary.getRange(2, 1, lastRowIt - 1, 5).getDisplayValues();
itineraryData = rawData.map(function (row) {
return [String(row[0] || ''), row[1] || '', row[2] || '', row[3] || '', row[4] || ''];
});
}
// B. 模板
var sheetTemplate = ss.getSheetByName('基礎模板');
var templateData = [];
var lastRowTpl = sheetTemplate.getLastRow();
if (lastRowTpl > 1) {
templateData = sheetTemplate.getRange(2, 1, lastRowTpl - 1, 3).getDisplayValues();
}
// C. 設定 (天數)
var sheetSettings = ss.getSheetByName('行程設定');
var settingsData = [];
var lastRowSet = sheetSettings.getLastRow();
if (lastRowSet > 1) {
var rawSettings = sheetSettings.getRange(2, 1, lastRowSet - 1, 2).getDisplayValues();
settingsData = rawSettings.map(function (row) {
return [String(row[0] || ''), row[1] || '09:00'];
}).filter(function (r) { return r[0]; });
}
// D. 關鍵修復:找出「遺失的天數」
var existingDayIds = settingsData.map(function (r) { return r[0]; });
var foundDayIds = [];
itineraryData.forEach(function (row) {
var dayId = String(row[0] || '');
if (dayId && existingDayIds.indexOf(dayId) === -1 && foundDayIds.indexOf(dayId) === -1) {
foundDayIds.push(dayId);
}
});
foundDayIds.forEach(function (missingId) {
settingsData.push([missingId, '09:00']);
});
if (settingsData.length === 0) {
settingsData.push(['Day1', '09:00']);
}
return {
itinerary: itineraryData,
templates: templateData,
settings: settingsData
};
}
// 3. 儲存資料
function saveAllData(payload) {
ensureSheetsExist_();
var ss = getSS_();
// --- 防呆 ---
payload = payload || {};
payload.settings = payload.settings || [];
payload.itinerary = payload.itinerary || [];
// 儲存設定
var sheetSettings = ss.getSheetByName('行程設定');
sheetSettings.clearContents();
sheetSettings.appendRow(['天數ID', '開始時間']);
if (payload.settings.length > 0) {
// 確保都是字串,避免 setValues 型別噴掉
var settingsOut = payload.settings.map(function (r) {
return [String(r[0] || ''), String(r[1] || '09:00')];
}).filter(function (r) { return r[0]; });
if (settingsOut.length > 0) {
sheetSettings.getRange(2, 1, settingsOut.length, 2).setValues(settingsOut);
}
}
// 儲存行程
var sheetItinerary = ss.getSheetByName('行程資料');
sheetItinerary.clearContents();
sheetItinerary.appendRow(['天數', '活動名稱', '停留時間', '類別', '備註']);
if (payload.itinerary.length > 0) {
var itOut = payload.itinerary.map(function (r) {
return [
String(r[0] || ''),
String(r[1] || ''),
String(r[2] || ''),
String(r[3] || ''),
String(r[4] || '')
];
}).filter(function (r) { return r[0] && r[1]; }); // 至少要有 dayId + name
if (itOut.length > 0) {
sheetItinerary.getRange(2, 1, itOut.length, 5).setValues(itOut);
}
}
SpreadsheetApp.flush();
return '儲存成功!';
}
// 4. 新增模板
function saveNewTemplate(name, duration, type) {
ensureSheetsExist_();
var ss = getSS_();
ss.getSheetByName('基礎模板').appendRow([name, duration, type]);
SpreadsheetApp.flush();
return '模板新增成功!';
} 貼到 Apps Script 的 Code.gs 檔案
5. 貼上前端(index.html)
把前端程式貼到 Apps Script 的 index.html。
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<style>
/* 保持原有美觀樣式 */
:root { --primary: #2196F3; --bg: #f0f2f5; --card-bg: #fff; }
body { font-family: 'Noto Sans TC', sans-serif; background-color: var(--bg); margin: 0; padding: 20px; color: #333; }
.header { background: var(--card-bg); padding: 15px 20px; border-radius: 12px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 20px; }
h2 { margin: 0; font-size: 1.4rem; color: #1a1a1a; }
.btn { padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; font-weight: 500; font-size: 0.9rem; transition: 0.2s; }
.btn-primary { background-color: var(--primary); color: white; }
.btn-primary:hover { background-color: #1976D2; }
.btn-outline { border: 1px solid #ddd; background: white; color: #555; }
.container { display: flex; gap: 20px; align-items: flex-start; height: calc(100vh - 100px); }
.sidebar { width: 280px; background: var(--card-bg); padding: 15px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); display: flex; flex-direction: column; gap: 20px; height: 100%; overflow-y: auto; flex-shrink: 0; }
.panel-title { font-weight: 700; color: #444; border-bottom: 2px solid #eee; padding-bottom: 5px; margin-bottom: 10px; display:block;}
.custom-form { display: flex; flex-direction: column; gap: 10px; background: #e3f2fd; padding: 12px; border-radius: 8px; }
.custom-form input, .custom-form select { padding: 8px; border: 1px solid #bbdefb; border-radius: 4px; width: 100%; box-sizing: border-box; }
.form-row { display: flex; gap: 5px; }
.btn-add-custom { background: var(--primary); color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; width: 100%; font-weight: bold;}
.btn-add-template { background: white; color: var(--primary); border: 1px solid var(--primary); padding: 5px; border-radius: 4px; cursor: pointer; font-size: 0.8rem; margin-top: 5px; width: 100%;}
.btn-add-template:hover { background: #e3f2fd; }
.template-list { display: flex; flex-direction: column; gap: 8px; flex: 1; overflow-y: auto;}
.template-item { background: #f8f9fa; padding: 10px; border-radius: 6px; border: 1px solid #eee; cursor: grab; display: flex; justify-content: space-between; align-items: center; transition: background 0.2s;}
.template-item:hover { border-color: var(--primary); background: #e3f2fd; }
.board { flex: 1; display: flex; gap: 15px; overflow-x: auto; height: 100%; padding-bottom: 10px; }
.day-column { min-width: 320px; width: 320px; background: var(--card-bg); border-radius: 12px; display: flex; flex-direction: column; box-shadow: 0 2px 8px rgba(0,0,0,0.08); flex-shrink: 0; }
.day-header { padding: 15px; border-bottom: 1px solid #eee; background: #fafafa; border-radius: 12px 12px 0 0; }
.day-title-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
/* 這裡稍微改一下 CSS,讓舊的天數名字顯示完整 */
.day-name { font-weight: bold; font-size: 1.1rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; display: inline-block; vertical-align: middle;}
.day-body { flex: 1; padding: 10px; overflow-y: auto; background: #fdfdfd; min-height: 100px; }
.card { background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 12px; margin-bottom: 10px; cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,0.05); border-left: 4px solid var(--primary); position: relative;}
.card-time-range { font-size: 0.85rem; font-weight: 700; color: #1976D2; margin-bottom: 4px; display: block; }
.card-main { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.card-title { font-weight: 500; font-size: 1rem; flex: 1; outline: none; }
.card-duration { background: #eee; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-size: 0.85rem;}
.btn-card-del { color: #ff5252; background: none; border: none; cursor: pointer; font-size: 1.2rem; line-height: 1; }
#status { font-weight: bold; color: #f57c00; display: none; margin-right: 15px; }
</style>
</head>
<body>
<div class="header">
<h2>✈️ 旅遊規劃神器 V6</h2>
<div>
<span id="status">系統啟動中...</span>
<button class="btn btn-outline" onclick="addNewDay()">+ 新增一天</button>
<button class="btn btn-primary" onclick="saveData()">儲存所有變更</button>
</div>
</div>
<div class="container">
<div class="sidebar">
<div>
<span class="panel-title">✨ 新增行程</span>
<div class="custom-form">
<input type="text" id="new-card-name" placeholder="行程名稱 (例: 買伴手禮)">
<div class="form-row">
<input type="number" id="new-card-min" placeholder="分鐘" value="60" style="width:70px;">
<select id="new-card-type" style="flex:1;"><option value="景點">景點</option><option value="用餐">用餐</option><option value="交通">交通</option><option value="購物">購物</option></select>
</div>
<select id="target-day-select"></select>
<button class="btn-add-custom" onclick="addCustomCard()">加入行程</button>
<button class="btn-add-template" onclick="saveAsTemplate()">📥 另存為常用模板</button>
</div>
</div>
<div style="flex:1; display:flex; flex-direction:column; overflow:hidden;">
<span class="panel-title">📌 常用模板 (可拖曳)</span>
<div id="template-list" class="template-list"></div>
</div>
</div>
<div class="board" id="board"></div>
</div>
<script>
let itineraryData = [], templateData = [], settingsData = [];
let templateSortableInstance = null;
window.onload = function() {
const status = document.getElementById('status');
status.innerText = "讀取並修復資料中...";
status.style.display = 'inline';
google.script.run
.withSuccessHandler(initData)
.withFailureHandler(handleError)
.getAllData();
};
function handleError(error) {
document.getElementById('status').innerText = "❌ 錯誤";
alert("錯誤: " + error.message);
}
function initData(data) {
itineraryData = data.itinerary || [];
templateData = data.templates || [];
settingsData = data.settings || [];
renderTemplates();
renderBoard();
document.getElementById('status').style.display = 'none';
}
function renderTemplates() {
const container = document.getElementById('template-list');
if (templateSortableInstance) templateSortableInstance.destroy();
container.innerHTML = '';
templateData.forEach(tmpl => {
const el = document.createElement('div');
el.className = 'template-item';
el.innerHTML = `<span>${tmpl[0]}</span> <small>${tmpl[1]}分</small>`;
el.dataset.name = tmpl[0]; el.dataset.duration = tmpl[1]; el.dataset.type = tmpl[2];
container.appendChild(el);
});
templateSortableInstance = new Sortable(container, {
group: { name: 'shared', pull: 'clone', put: false },
sort: false, animation: 150
});
}
function saveAsTemplate() {
const name = document.getElementById('new-card-name').value;
const min = document.getElementById('new-card-min').value;
const type = document.getElementById('new-card-type').value;
if (!name) { alert('請輸入名稱'); return; }
templateData.push([name, min, type]);
renderTemplates();
const status = document.getElementById('status');
status.innerText = "儲存模板中..."; status.style.display = 'inline';
google.script.run.withSuccessHandler(res => {
status.innerText = "✅ " + res; setTimeout(() => status.style.display = 'none', 2000);
}).saveNewTemplate(name, min, type);
}
// --- Board Logic (修改了 Day Name 的顯示方式) ---
function renderBoard() {
const board = document.getElementById('board');
board.innerHTML = '';
settingsData.forEach((setting, index) => {
// setting[0] 是 DayID (可能為 "第1天" 或 "Day123")
// index+1 是純數字編號
createDayColumn(setting[0], setting[1], index + 1);
});
updateDaySelectOptions();
recalculateAll();
}
function createDayColumn(dayId, startTime, dayNumber) {
const board = document.getElementById('board');
const col = document.createElement('div');
col.className = 'day-column';
col.dataset.dayId = dayId;
// 如果 dayId 本身看起來就像中文 (e.g., "第1天"),我們就直接顯示它,不然就顯示 "第 N 天"
let displayName = `第 ${dayNumber} 天`;
// 這裡做個小判斷,如果 dayId 不是 Day 開頭的 ID,我們就把它顯示出來,讓使用者知道這是舊資料
if (!dayId.startsWith('Day')) {
displayName = dayId;
}
col.innerHTML = `
<div class="day-header">
<div class="day-title-row">
<span class="day-name" title="${displayName}">${displayName}</span>
<button class="btn-card-del" onclick="deleteDay(this)">×</button>
</div>
<div style="display:flex;align-items:center;gap:5px;font-size:0.9rem;color:#666;">
<span>開始:</span><input type="time" value="${startTime}" onchange="recalculateAll()" style="border:none;color:#1976D2;font-weight:bold;cursor:pointer;">
</div>
</div>
<div class="day-body" id="list-${dayId}" data-day-id="${dayId}"></div>
`;
board.appendChild(col);
const listEl = col.querySelector('.day-body');
// 篩選時強制轉 String 比對,避免型別問題
const dayCards = itineraryData.filter(row => String(row[0]) === String(dayId));
dayCards.forEach(item => listEl.appendChild(createCardEl(item[1], item[2], item[3])));
new Sortable(listEl, {
group: 'shared', animation: 150, onEnd: recalculateAll,
onAdd: function (evt) {
const item = evt.item;
if (item.classList.contains('template-item')) {
item.parentNode.replaceChild(createCardEl(item.dataset.name, item.dataset.duration, item.dataset.type), item);
recalculateAll();
}
}
});
}
function updateDaySelectOptions() {
const select = document.getElementById('target-day-select');
select.innerHTML = '';
document.querySelectorAll('.day-column').forEach(col => {
const option = document.createElement('option');
option.value = col.dataset.dayId;
option.innerText = `加入到:` + col.querySelector('.day-name').innerText;
select.appendChild(option);
});
}
function addNewDay() {
const newDayId = 'Day' + Date.now();
settingsData.push([newDayId, '09:00']);
renderBoard();
}
function deleteDay(btn) {
if(confirm('確定刪除?')) {
const removeId = btn.closest('.day-column').dataset.dayId;
settingsData = settingsData.filter(d => d[0] !== removeId);
renderBoard();
}
}
function createCardEl(name, duration, type) {
const div = document.createElement('div');
div.className = 'card';
div.dataset.duration = duration; div.dataset.type = type;
div.innerHTML = `
<span class="card-time-range">--:--</span>
<div class="card-main"><span class="card-title" contenteditable="true">${name}</span>
<div style="display:flex;align-items:center;gap:5px;">
<span class="card-duration" onclick="editDuration(this)">${duration}m</span><button class="btn-card-del" onclick="this.closest('.card').remove();recalculateAll()">×</button></div></div>
`;
return div;
}
function addCustomCard() {
const name = document.getElementById('new-card-name').value;
const min = document.getElementById('new-card-min').value;
const type = document.getElementById('new-card-type').value;
const targetDayId = document.getElementById('target-day-select').value;
if (!name || !targetDayId) return;
const targetList = document.querySelector(`.day-body[data-day-id="${targetDayId}"]`);
if (targetList) {
targetList.appendChild(createCardEl(name, min, type));
document.getElementById('new-card-name').value = '';
recalculateAll();
targetList.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
function editDuration(span) {
const newDur = prompt("修改時間:", span.innerText.replace('m',''));
if (newDur && !isNaN(newDur)) {
span.innerText = newDur + 'm';
span.closest('.card').dataset.duration = newDur;
recalculateAll();
}
}
function recalculateAll() {
document.querySelectorAll('.day-column').forEach(col => {
const timeVal = col.querySelector('input[type="time"]').value || "09:00";
let [h, m] = timeVal.split(':').map(Number);
let cur = h * 60 + m;
col.querySelectorAll('.day-body .card').forEach(card => {
let dur = parseInt(card.dataset.duration) || 0;
card.querySelector('.card-time-range').innerText = `${minToTime(cur)} - ${minToTime(cur+dur)}`;
cur += dur;
});
});
}
function minToTime(min) {
let h = Math.floor(min / 60) % 24, m = min % 60;
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
}
function saveData() {
const status = document.getElementById('status');
status.innerText = "儲存中..."; status.style.display = "inline";
const payload = { settings: [], itinerary: [] };
document.querySelectorAll('.day-column').forEach(col => {
const dayId = col.dataset.dayId;
payload.settings.push([dayId, col.querySelector('input[type="time"]').value]);
col.querySelectorAll('.day-body .card').forEach(card => {
payload.itinerary.push([dayId, card.querySelector('.card-title').innerText, card.dataset.duration, card.dataset.type, '']);
});
});
google.script.run.withSuccessHandler(res => {
status.innerText = "✅ " + res; setTimeout(() => status.style.display = 'none', 2000);
}).saveAllData(payload);
}
</script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案
6. 部署
部署成 Web App 後,就會拿到可分享網址。
- Apps Script 右上:部署 → 新部署
- 類型選:Web app
- 執行身分:你
- 存取權限:任何人
- 完成後複製網址
💬 評論區
老師們的交流與提問
登入 後即可發表評論