0. 作品介紹

作者 皮卡賴 數位實驗高中

一個「活動/研習資訊」網頁:把試算表裡的活動場次,自動排版成分區塊、分色、日期時間分欄、斑馬紋表格,頂端還有可跳轉的快速連結。老師只要維護試算表,網頁即時更新,不必碰程式碼。 實際部屬demo連結: https://script.google.com/macros/s/AKfycbyWEEmaaTpd2_mz0dWYXXIakZUKE7hHpblQXjVx_56tqfTgOR2ARVkljY-nJyAHJJl1/exec


37 0 0
辦公 非教學
封面圖 封面

1. AI 提示詞

把這段指令貼給 AI,讓它幫你生成程式碼。

我想要建立一個 【研習活動網頁】
幫我用 Google 試算表建立 【活動資料庫】
我希望有些資料我可以直接在試算表更改,首頁可以直接呈現

需求說明 【

1. 首頁自動把「活動資料庫」裡的每一個場次,排成一張一張好看的表格。
2. 活動可以分成好幾個大區塊(例如不同主題的工作坊),每個區塊自動用不同顏色。
3. 表格欄位要有:場次、日期、時間、報名;其中「日期」和「時間」要分成兩個欄位。
4. 表格每一列的底色要一深一淺交錯(斑馬紋),看起來比較清楚。
5. 每一列最右邊有一個「報名」按鈕,點下去會打開那一場的報名網址。
6. 網頁最上方要有一排「快速連結」,點了可以直接跳到對應的區塊。
7. 同一主題若分好幾梯、或分上午/下午、線上/實體,要能放在同一個大區塊裡再分成小表。
8. 網頁的標題、小標題、日期範圍、最下面的備註,我想直接在試算表改,不用動程式。
9. 用手機打開也要能正常閱讀(表格會自動變成一張張卡片)。
10. 整體風格希望簡潔專業、配色柔和(米白底、圓角卡片、每個區塊一種主色)。



要把它變成程式碼我要發佈
請完整敘述操作步驟 越詳細越好
作業環境
Google 試算表
Google Apps Script
我看不懂程式碼,請給我後端 gs 完整程式碼、前端 index 完整程式碼

2. 資料表規則

請照下面規則建立分頁與欄位。

資料表規則圖
第1分頁: 頁面設定 A 欄: 項目 B 欄: 內容 第2分頁: 課程資料 A 欄: 區塊 B 欄: 區塊附註 C 欄: 子群組 D 欄: 場次 E 欄: 日期 F 欄: 時間 G 欄: 報名網址

📄 解析結果(自動表格)

分頁:頁面設定
A 欄 B 欄
項目 內容
分頁:課程資料
A 欄 B 欄 C 欄 D 欄 E 欄 F 欄 G 欄
區塊 區塊附註 子群組 場次 日期 時間 報名網址

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 — 後端:讀取試算表,把課程資料回傳給前端渲染

function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('課程表')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

function getScheduleData() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var settings = readSettings_(ss);
  var sections = readCourses_(ss);
  return { meta: settings.meta, notes: settings.notes, sections: sections };
}

function readSettings_(ss) {
  var meta = { eyebrow: '', title: '課程表', subtitle: '', pill: '', footer: '' };
  var notes = [];
  var sh = ss.getSheetByName('頁面設定');
  if (sh) {
    var last = sh.getLastRow();
    if (last >= 2) {
      var vals = sh.getRange(2, 1, last - 1, 2).getValues();
      vals.forEach(function (r) {
        var k = String(r[0]).trim();
        var v = String(r[1]).trim();
        if (!k) return;
        if (k === '小標籤') meta.eyebrow = v;
        else if (k === '標題') meta.title = v;
        else if (k === '副標') meta.subtitle = v;
        else if (k === '年度膠囊' || k === '日期範圍') meta.pill = v;
        else if (k === '頁尾') meta.footer = v;
        else if (k === '備註') { if (v) notes.push(v); }
      });
    }
  }
  return { meta: meta, notes: notes };
}

function readCourses_(ss) {
  var sh = ss.getSheetByName('課程資料');
  if (!sh) return [];
  var last = sh.getLastRow();
  if (last < 2) return [];

  var vals = sh.getRange(2, 1, last - 1, 7).getValues();
  var order = [];
  var map = {};

  vals.forEach(function (r) {
    var sec = String(r[0]).trim();
    if (!sec) return;

    if (!map[sec]) {
      map[sec] = { title: sec, note: '', groupsOrder: [], groups: {} };
      order.push(sec);
    }
    var s = map[sec];

    if (!s.note) {
      var nt = String(r[1]).trim();
      if (nt) s.note = nt;
    }

    var gt = String(r[2]).trim();
    if (!s.groups[gt]) {
      s.groups[gt] = { title: gt, rows: [] };
      s.groupsOrder.push(gt);
    }

    s.groups[gt].rows.push({
      sess: cell_(r[3]),
      date: cell_(r[4]),
      time: cell_(r[5]),
      url:  String(r[6]).trim()
    });
  });

  return order.map(function (sec, i) {
    var s = map[sec];
    return {
      id: 'sec-' + (i + 1),
      title: s.title,
      note: s.note,
      groups: s.groupsOrder.map(function (gt) { return s.groups[gt]; })
    };
  });
}

function cell_(v) {
  if (v instanceof Date) {
    var tz = Session.getScriptTimeZone();
    var y = v.getFullYear();
    if (y < 1900) return Utilities.formatDate(v, tz, 'HH:mm');
    return Utilities.formatDate(v, tz, 'M月d日');
  }
  return String(v).trim();
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

把前端程式貼到 Apps Script 的 index.html。

index.html(前端)
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<base target="_top">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
  /* ===== 樣式鎖定:請勿修改本 <style> 內的任何規則 =====
     版面固定:置中單欄 920px、米白底 #faf8f3、字型 Noto Sans TC。
     配色為六色循環 s1..s6(綠 / 紫 / 橘 / 藍 / 玫瑰 / 青),每個主區塊用一組色;
     由下方 render() 依「區塊出現順序」自動指派,超過 6 個循環回 s1。
     表格欄位固定為「場次|日期|時間|報名」,日期與時間必為兩欄;
     資料列淺 / 深交錯(斑馬紋);報名為連外按鈕;≤720px 自動轉為卡片式。 */
  :root{
    --ink:#1f2430;
    --muted:#6b7280;
    --line:#e7e3da;
    --paper:#faf8f3;
    --card:#ffffff;
  }
  *{box-sizing:border-box;}
  html{scroll-behavior:smooth;}
  body{
    margin:0;
    background:var(--paper);
    color:var(--ink);
    font-family:"Noto Sans TC","PingFang TC","Microsoft JhengHei",system-ui,sans-serif;
    line-height:1.6;
    -webkit-font-smoothing:antialiased;
  }
  .wrap{max-width:920px;margin:0 auto;padding:40px 20px 64px;}
  header{margin-bottom:28px;}
  .eyebrow{font-size:13px;letter-spacing:.18em;color:var(--muted);text-transform:uppercase;margin-bottom:10px;}
  h1{font-size:30px;line-height:1.3;margin:0 0 8px;font-weight:700;letter-spacing:.01em;}
  .sub{color:var(--muted);font-size:15px;margin:0;}
  .year-pill{display:inline-block;margin-top:14px;padding:4px 12px;border-radius:999px;background:#fff;border:1px solid var(--line);font-size:13px;color:var(--muted);}

  .quicknav{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px;padding-top:22px;border-top:1px dashed var(--line);}
  .quicknav .label{width:100%;font-size:12px;letter-spacing:.1em;color:var(--muted);margin-bottom:2px;}
  .tag{display:inline-flex;align-items:center;gap:8px;text-decoration:none;padding:9px 16px;border-radius:999px;font-size:14px;font-weight:600;border:1.5px solid;transition:background .15s ease,color .15s ease,transform .08s ease;}
  .tag:hover{transform:translateY(-1px);}
  .tag .n{width:8px;height:8px;border-radius:50%;}
  .tag.s1{color:#0a5a48;border-color:#0f8a6e;background:#e3f4ef;}
  .tag.s1 .n{background:#0f8a6e;} .tag.s1:hover{background:#0f8a6e;color:#fff;} .tag.s1:hover .n{background:#fff;}
  .tag.s2{color:#473690;border-color:#6d54c7;background:#eeeafb;}
  .tag.s2 .n{background:#6d54c7;} .tag.s2:hover{background:#6d54c7;color:#fff;} .tag.s2:hover .n{background:#fff;}
  .tag.s3{color:#8a4712;border-color:#c2681f;background:#fbeee0;}
  .tag.s3 .n{background:#c2681f;} .tag.s3:hover{background:#c2681f;color:#fff;} .tag.s3:hover .n{background:#fff;}
  .tag.s4{color:#1f4d80;border-color:#2f6fb0;background:#e6eef7;}
  .tag.s4 .n{background:#2f6fb0;} .tag.s4:hover{background:#2f6fb0;color:#fff;} .tag.s4:hover .n{background:#fff;}
  .tag.s5{color:#8a2f4d;border-color:#c0466e;background:#fbe7ee;}
  .tag.s5 .n{background:#c0466e;} .tag.s5:hover{background:#c0466e;color:#fff;} .tag.s5:hover .n{background:#fff;}
  .tag.s6{color:#0a5a5a;border-color:#128a8a;background:#e0f4f4;}
  .tag.s6 .n{background:#128a8a;} .tag.s6:hover{background:#128a8a;color:#fff;} .tag.s6:hover .n{background:#fff;}

  .batch{margin-top:44px;scroll-margin-top:20px;}
  .batch.s1{--acc:#0f8a6e;--acc-soft:#e3f4ef;--acc-zebra:#cfeae2;--acc-ink:#0a5a48;}
  .batch.s2{--acc:#6d54c7;--acc-soft:#eeeafb;--acc-zebra:#ded6f5;--acc-ink:#473690;}
  .batch.s3{--acc:#c2681f;--acc-soft:#fbeee0;--acc-zebra:#f6dfc6;--acc-ink:#8a4712;}
  .batch.s4{--acc:#2f6fb0;--acc-soft:#e6eef7;--acc-zebra:#cfe0f0;--acc-ink:#1f4d80;}
  .batch.s5{--acc:#c0466e;--acc-soft:#fbe7ee;--acc-zebra:#f6d0dd;--acc-ink:#8a2f4d;}
  .batch.s6{--acc:#128a8a;--acc-soft:#e0f4f4;--acc-zebra:#c7ebeb;--acc-ink:#0a5a5a;}

  .batch-head{display:flex;align-items:baseline;gap:10px;margin-bottom:6px;}
  .dot{width:11px;height:11px;border-radius:50%;background:var(--acc);align-self:center;}
  .batch-title{font-size:19px;font-weight:700;margin:0;}
  .batch-note{color:var(--muted);font-size:14px;margin:0 0 16px 21px;}

  .subhead{font-size:14px;font-weight:700;color:var(--acc-ink);margin:22px 0 10px;display:flex;align-items:center;gap:8px;}
  .subhead::before{content:"";width:6px;height:16px;border-radius:3px;background:var(--acc);}

  table{width:100%;border-collapse:separate;border-spacing:0;background:var(--card);border:1px solid var(--line);border-radius:14px;overflow:hidden;margin-bottom:4px;}
  thead th{text-align:left;font-size:12px;letter-spacing:.06em;color:#fff;font-weight:600;padding:13px 16px;background:var(--acc);}
  tbody td{padding:14px 16px;font-size:15px;vertical-align:middle;}
  tbody tr:nth-child(odd) td{background:var(--acc-soft);}
  tbody tr:nth-child(even) td{background:var(--acc-zebra);}

  .sess{display:inline-flex;align-items:center;justify-content:center;min-width:64px;padding:5px 10px;border-radius:8px;background:#fff;font-weight:700;font-size:14px;color:var(--acc-ink);}
  .col-sess{width:96px;}
  .date{font-weight:600;white-space:nowrap;color:var(--acc-ink);}
  .time{color:var(--muted);font-size:14px;white-space:nowrap;}
  .col-date,.col-time{width:130px;}

  .btn{display:inline-flex;align-items:center;gap:6px;text-decoration:none;padding:8px 14px;border-radius:9px;font-size:14px;font-weight:600;white-space:nowrap;background:var(--acc);color:#fff;transition:transform .08s ease,filter .15s ease;}
  .btn:hover{filter:brightness(1.08);transform:translateY(-1px);}
  .btn .arrow{font-size:13px;}
  .col-reg{width:120px;text-align:right;}

  .notes{margin-top:44px;background:#fff;border:1px solid var(--line);border-radius:14px;padding:20px 22px;}
  .notes h3{margin:0 0 10px;font-size:15px;font-weight:700;}
  .notes ul{margin:0;padding-left:18px;color:var(--muted);font-size:14px;}
  .notes li{margin-bottom:6px;}

  footer{margin-top:32px;text-align:center;color:var(--muted);font-size:13px;}
  .state{padding:60px 0;text-align:center;color:var(--muted);font-size:15px;}

  @media (max-width:720px){
    h1{font-size:24px;}
    thead{display:none;}
    table,tbody,tr,td{display:block;width:100%;}
    table{border:none;background:transparent;padding:0;margin-bottom:0;}
    tbody tr{border-radius:14px;overflow:hidden;margin-bottom:12px;border:1px solid var(--line);}
    tbody td{border:none;padding:9px 16px;}
    .col-sess{padding-top:14px;}
    .col-reg{text-align:left;padding-bottom:14px;}
    .time{display:inline;}
  }
</style>
</head>
<body>
  <div class="wrap">
    <header>
      <div class="eyebrow" id="eyebrow"></div>
      <h1 id="title">課程表</h1>
      <p class="sub" id="sub"></p>
      <span class="year-pill" id="pill" style="display:none"></span>
      <nav class="quicknav" id="quicknav" style="display:none"></nav>
    </header>

    <div id="state" class="state">載入中…</div>
    <div id="sections"></div>
    <div id="notes"></div>

    <footer id="footer">點選各場次「報名」按鈕即可前往報名頁面</footer>
  </div>

  <script>
    function esc(s){
      return String(s==null?'':s).replace(/[&<>"]/g,function(c){
        return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];
      });
    }

    window.onload = function(){
      google.script.run
        .withSuccessHandler(render)
        .withFailureHandler(showError)
        .getScheduleData();
    };

    function showError(err){
      document.getElementById('state').textContent = '讀取資料時發生錯誤:' + (err && err.message ? err.message : err);
    }

    function render(data){
      document.getElementById('state').style.display = 'none';

      var m = data.meta || {};
      document.getElementById('eyebrow').textContent = m.eyebrow || '';
      document.getElementById('title').textContent = m.title || '課程表';
      document.title = m.title || '課程表';
      document.getElementById('sub').textContent = m.subtitle || '';
      if(m.pill){ var p=document.getElementById('pill'); p.textContent=m.pill; p.style.display='inline-block'; }
      if(m.footer){ document.getElementById('footer').textContent = m.footer; }

      var sections = data.sections || [];

      if(sections.length){
        var nav = document.getElementById('quicknav');
        var tags = '<span class="label">快速連結</span>';
        sections.forEach(function(sec,i){
          // 依區塊出現順序指派顏色,超過 6 色循環回 s1
          var s = 's' + ((i % 6) + 1);
          tags += '<a class="tag '+s+'" href="#'+esc(sec.id)+'"><span class="n"></span>'+esc(sec.title)+'</a>';
        });
        nav.innerHTML = tags;
        nav.style.display = 'flex';
      }

      var html = '';
      sections.forEach(function(sec,i){
        var s = 's' + ((i % 6) + 1);
        html += '<section class="batch '+s+'" id="'+esc(sec.id)+'">';
        html += '<div class="batch-head"><span class="dot"></span><h2 class="batch-title">'+esc(sec.title)+'</h2></div>';
        if(sec.note){ html += '<p class="batch-note">'+esc(sec.note)+'</p>'; }
        (sec.groups||[]).forEach(function(g){
          if(g.title){ html += '<div class="subhead">'+esc(g.title)+'</div>'; }
          // 欄位固定:場次|日期|時間|報名(日期與時間分兩欄)
          html += '<table><thead><tr>'
                + '<th class="col-sess">場次</th><th class="col-date">日期</th>'
                + '<th class="col-time">時間</th><th class="col-reg">報名</th>'
                + '</tr></thead><tbody>';
          (g.rows||[]).forEach(function(r){
            html += '<tr>'
                  + '<td><span class="sess">'+esc(r.sess)+'</span></td>'
                  + '<td><span class="date">'+esc(r.date)+'</span></td>'
                  + '<td><span class="time">'+esc(r.time)+'</span></td>'
                  + '<td class="col-reg">'
                  + (r.url ? '<a class="btn" href="'+esc(r.url)+'" target="_blank" rel="noopener">報名 <span class="arrow">↗</span></a>' : '')
                  + '</td></tr>';
          });
          html += '</tbody></table>';
        });
        html += '</section>';
      });
      document.getElementById('sections').innerHTML = html;

      var notes = data.notes || [];
      if(notes.length){
        var n = '<div class="notes"><h3>備註</h3><ul>';
        notes.forEach(function(x){ n += '<li>'+esc(x)+'</li>'; });
        n += '</ul></div>';
        document.getElementById('notes').innerHTML = n;
      }
    }
  </script>
</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

部署成 Web App 後,就會拿到可分享網址。

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

💬 評論區

老師們的交流與提問

載入評論中...