0. 作品介紹

我想要做旅行規劃地圖


58 1 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完整程式碼

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 讀寫資料。

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

4. 貼上後端(Code.gs)

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

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。

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 後,就會拿到可分享網址。

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

💬 評論區

老師們的交流與提問

載入評論中...