0. 作品介紹

作者 吳俊成 東門國小

1.將雲端硬碟以類似檔案總管的方式顯示,左側(20%)為資料夾,中間(60%)部分為檔案區,右側(20%)為照片預覽區。 2.中間檔案區只會顯示圖片檔,非圖片檔不會顯示。 3.每一個檔案都有勾選功能,同時針對資料夾內的全部圖檔也有「全選」、「取消全選功能。 4.選取好範圍後,即可填入「檔名前綴」與選取「檔案編號數」,就可以為雲端硬碟中的圖檔進行「批次重新命名」的功能。


94 4 0
班級經營 辦公 非教學
示範影片

2. 資料表規則

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

第1分頁: config A 欄: 設定項目 B 欄: 內容

📄 解析結果(自動表格)

分頁:config
A 欄 B 欄
設定項目 內容

3. 開啟 Apps Script

從試算表開 Apps Script,才能直接用 SpreadsheetApp 讀寫資料。

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

4. 貼上後端(Code.gs)

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

Code.gs(後端)
/**
 * 東門國小校園活動照片系統 - 後端 V2.1
 */

function doGet() {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('東門國小校園活動照片 SerialRename 系統')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

// 取得試算表中的設定資料 (系統標題等)
function getAppData() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName('Config');
  
  // 如果找不到 Config 工作表,提供預設值
  if (!sheet) {
    return { title: "東門國小校園活動照片系統", welcome: "歡迎使用" };
  }
  
  const data = sheet.getDataRange().getValues();
  return {
    title: data[1] ? data[1][1] : "東門國小校園活動照片系統",
    welcome: data[2] ? data[2][1] : "歡迎使用"
  };
}

// 取得根目錄資料夾
function getRootFolders() {
  const root = DriveApp.getRootFolder();
  return getChildFolders(root.getId());
}

// 取得指定資料夾下的子資料夾 (樹狀結構用)
function getChildFolders(parentId) {
  try {
    const parent = DriveApp.getFolderById(parentId);
    const folders = parent.getFolders();
    let list = [];
    while (folders.hasNext()) {
      let f = folders.next();
      list.push({ name: f.getName(), id: f.getId() });
    }
    return list.sort((a, b) => a.name.localeCompare(b.name));
  } catch (e) {
    return [];
  }
}

// 取得圖片並轉換為 Base64 縮圖
function getFilesInFolder(folderId) {
  const folder = DriveApp.getFolderById(folderId);
  const files = folder.getFiles();
  let fileList = [];
  const allowedTypes = [MimeType.JPEG, MimeType.PNG, MimeType.GIF, MimeType.BMP];

  while (files.hasNext()) {
    let file = files.next();
    if (allowedTypes.indexOf(file.getMimeType()) !== -1) {
      // 優先使用縮圖,若無則抓取內容
      let thumb = file.getThumbnail();
      let bytes = thumb ? thumb.getBytes() : file.getBlob().getBytes();
      let b64 = Utilities.base64Encode(bytes);
      fileList.push({
        name: file.getName(),
        id: file.getId(),
        base64: b64,
        mimeType: file.getMimeType()
      });
    }
  }
  return fileList.sort((a, b) => a.name.localeCompare(b.name));
}

// 執行重新命名
function renameFiles(folderId, fileIds, prefix, digits) {
  fileIds.forEach((id, index) => {
    let file = DriveApp.getFileById(id);
    let nameParts = file.getName().split('.');
    let extension = nameParts.length > 1 ? nameParts.pop() : '';
    let sequence = (index + 1).toString().padStart(parseInt(digits), '0');
    let newName = prefix + sequence + (extension ? '.' + extension : '');
    file.setName(newName);
  });
  return "重新命名成功!共處理 " + fileIds.length + " 個檔案。";
}
貼到 Apps Script 的 Code.gs 檔案

5. 貼上前端(index.html)

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

index.html(前端)
<!DOCTYPE html>
<html>
<head>
  <base target="_top">
  <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
  <style>
    :root {
      --primary: #2c3e50; /* 深藍學術風 */
      --accent: #e67e22; /* 青春橘 */
      --sidebar-bg: #ffffff;
      --main-bg: #f0f3f6;
    }
    body { font-family: "Microsoft JhengHei", "PingFang TC", sans-serif; margin: 0; display: flex; flex-direction: column; height: 100vh; background: var(--main-bg); color: #333; }
    
    header { background: linear-gradient(135deg, var(--primary), #34495e); color: white; padding: 1rem 2rem; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 10; }
    header h1 { margin: 0; font-size: 1.4rem; display: flex; align-items: center; gap: 12px; }

    .main { display: flex; flex: 1; overflow: hidden; }

    /* 左側:樹狀目錄 (20%) */
    #sidebar { width: 20%; background: var(--sidebar-bg); border-right: 1px solid #d1d8e0; overflow-y: auto; padding: 15px; }
    #sidebar h3 { font-size: 1rem; color: var(--primary); border-bottom: 2px solid var(--accent); padding-bottom: 5px; margin-top: 0; }
    .tree-node { margin-left: 12px; margin-top: 4px; }
    .folder-label { display: flex; align-items: center; padding: 8px; border-radius: 6px; transition: 0.2s; cursor: pointer; font-size: 0.95rem; }
    .folder-label:hover { background: #f1f2f6; }
    .folder-label i { margin-right: 10px; color: #f1c40f; font-size: 1.1rem; }
    .active-folder { background: #e3f2fd !important; font-weight: bold; color: var(--primary); box-shadow: inset 3px 0 0 var(--accent); }
    .loading-text { font-size: 12px; color: #888; margin-left: 25px; font-style: italic; }

    /* 中間:檔案列表 (60%) */
    #content { width: 60%; padding: 20px; overflow-y: auto; position: relative; }
    .toolbar { background: white; padding: 12px; border-radius: 10px; margin-bottom: 20px; display: flex; gap: 10px; align-items: center; position: sticky; top: 0; z-index: 5; box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
    .photo-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 18px; }
    .photo-card { background: white; border: 1px solid #e1e8ed; padding: 10px; border-radius: 10px; text-align: center; transition: 0.3s; cursor: pointer; position: relative; }
    .photo-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0,0,0,0.1); border-color: var(--accent); }
    .photo-card input { position: absolute; top: 8px; left: 8px; transform: scale(1.4); cursor: pointer; z-index: 2; }
    .img-container { height: 100px; display: flex; align-items: center; justify-content: center; overflow: hidden; margin-bottom: 8px; background: #f8f9fa; border-radius: 6px; }
    .img-container img { max-width: 100%; max-height: 100%; object-fit: contain; }
    .photo-card p { font-size: 12px; margin: 0; color: #444; word-break: break-all; height: 2.8em; overflow: hidden; line-height: 1.4; }

    /* 右側:預覽 (20%) */
    #preview-panel { width: 20%; background: white; border-left: 1px solid #d1d8e0; padding: 20px; display: flex; flex-direction: column; align-items: center; }
    .preview-box { width: 100%; border: 2px dashed #cbd5e0; border-radius: 12px; min-height: 250px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #fcfcfc; }
    #full-preview { max-width: 100%; max-height: 450px; display: none; border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
    #p-hint { color: #a0aec0; text-align: center; padding: 20px; font-size: 0.9rem; }

    /* 底部:控制列 */
    footer { background: white; border-top: 4px solid var(--accent); padding: 15px 30px; display: flex; justify-content: center; gap: 25px; align-items: center; box-shadow: 0 -2px 10px rgba(0,0,0,0.05); }
    .input-group { display: flex; align-items: center; gap: 10px; font-weight: bold; color: var(--primary); }
    input[type="text"], select { padding: 10px; border: 1px solid #cbd5e0; border-radius: 6px; outline: none; transition: 0.2s; }
    input[type="text"]:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(230, 126, 34, 0.2); }
    .btn-main { background: var(--accent); color: white; border: none; padding: 10px 30px; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 1rem; transition: 0.2s; display: flex; align-items: center; gap: 8px; }
    .btn-main:hover { background: #d35400; transform: scale(1.05); }
    
    /* Loading 遮罩 */
    #mask { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(255,255,255,0.8); z-index:1000; justify-content:center; align-items:center; flex-direction:column; }
    .spinner { color: var(--accent); font-size: 3rem; margin-bottom: 15px; animation: spin 1s infinite linear; }
    @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
  </style>
</head>
<body>

<div id="mask">
  <i class="fas fa-circle-notch spinner"></i>
  <p style="font-weight:bold; color:var(--primary)">系統處理中,請稍候...</p>
</div>

<header>
  <h1 id="app-title"><i class="fas fa-graduation-cap"></i> 載入中...</h1>
</header>

<div class="main">
  <div id="sidebar">
    <h3><i class="fas fa-folder-tree"></i> 校園雲端目錄</h3>
    <div id="tree-root">正在連線至雲端硬碟...</div>
  </div>

  <div id="content">
    <div class="toolbar">
      <button onclick="checkAll(true)" style="background:#2ecc71; color:white; border:none; padding:8px 15px; border-radius:6px; cursor:pointer;"><i class="fas fa-check-square"></i> 全選照片</button>
      <button onclick="checkAll(false)" style="background:#95a5a6; color:white; border:none; padding:8px 15px; border-radius:6px; cursor:pointer;"><i class="fas fa-minus-square"></i> 取消選取</button>
      <span id="status-msg" style="margin-left:auto; font-weight:bold; color:var(--accent)"></span>
    </div>
    <div id="photo-list" class="photo-grid">請從左側點選活動資料夾...</div>
  </div>

  <div id="preview-panel">
    <h3 style="color:var(--primary)"><i class="fas fa-eye"></i> 畫面預覽</h3>
    <div class="preview-box">
      <span id="p-hint"><i class="fas fa-mouse-pointer"></i><br>點擊左側照片<br>可在此查看大圖</span>
      <img id="full-preview" src="">
    </div>
    <p id="p-filename" style="margin-top:20px; text-align:center; font-weight:bold; color:var(--primary)"></p>
  </div>
</div>

<footer>
  <div class="input-group">
    <label><i class="fas fa-tag"></i> 新檔名前綴:</label>
    <input type="text" id="prefix" placeholder="例如:2024校慶_">
  </div>
  <div class="input-group">
    <label><i class="fas fa-list-ol"></i> 序號位數:</label>
    <select id="digits">
      <option value="1">1 (1, 2...)</option>
      <option value="2" selected>01 (01, 02...)</option>
      <option value="3">001 (001, 002...)</option>
    </select>
  </div>
  <button class="btn-main" onclick="runRename()"><i class="fas fa-sync-alt"></i> 執行批次改名</button>
</footer>

<script>
  let currentFolderId = '';
  let allFiles = [];

  window.onload = () => {
    // 1. 初始化讀取試算表標題
    google.script.run.withSuccessHandler(data => {
      document.getElementById('app-title').innerHTML = `<i class="fas fa-university"></i> ${data.title}`;
    }).getAppData();

    // 2. 載入第一層資料夾
    initRoot();
  };

  function initRoot() {
    const rootContainer = document.getElementById('tree-root');
    google.script.run.withSuccessHandler(folders => {
      renderTree(folders, rootContainer);
    }).getRootFolders();
  }

  // 渲染樹狀結構元件
  function renderTree(folders, container) {
    if (folders.length === 0) {
      container.innerHTML = '<div class="loading-text">無子資料夾</div>';
      return;
    }
    container.innerHTML = '';
    folders.forEach(f => {
      const node = document.createElement('div');
      node.className = 'tree-node';
      node.innerHTML = `
        <div class="folder-label" id="label-${f.id}" onclick="handleFolderClick('${f.id}', '${f.name}')">
          <i class="fas fa-folder"></i> ${f.name}
        </div>
        <div id="child-${f.id}" style="display:none"></div>
      `;
      container.appendChild(node);
    });
  }

  // 點擊資料夾處理
  function handleFolderClick(id, name) {
    // UI 反饋:切換選取顏色
    document.querySelectorAll('.folder-label').forEach(el => el.classList.remove('active-folder'));
    document.getElementById(`label-${id}`).classList.add('active-folder');

    const childDiv = document.getElementById(`child-${id}`);
    
    // 如果是第一次點擊(內容為空),則顯示讀取中並抓取子目錄
    if (childDiv.innerHTML === '') {
      childDiv.style.display = 'block';
      childDiv.innerHTML = '<div class="loading-text">讀取中...</div>';
      
      google.script.run.withSuccessHandler(subFolders => {
        // 完成讀取後,renderTree 會覆寫 innerHTML,原本的「讀取中...」會自動消失
        renderTree(subFolders, childDiv);
      }).getChildFolders(id);
    } else {
      // 若已有內容,則切換顯示/隱藏
      childDiv.style.display = (childDiv.style.display === 'none') ? 'block' : 'none';
    }

    // 抓取該資料夾的照片
    currentFolderId = id;
    loadFiles(id);
  }

  // 載入照片列表
  function loadFiles(id) {
    showLoading(true);
    google.script.run.withSuccessHandler(files => {
      allFiles = files;
      const list = document.getElementById('photo-list');
      list.innerHTML = '';
      if (files.length === 0) {
        list.innerHTML = '<div style="grid-column: 1/-1; text-align:center; padding:50px; color:#95a5a6;">此資料夾內沒有照片檔案。</div>';
      } else {
        files.forEach(f => {
          const card = document.createElement('div');
          card.className = 'photo-card';
          // 點擊卡片任何地方啟動預覽(除了勾選框以外)
          card.onclick = (e) => {
            if(e.target.tagName !== 'INPUT') showPreview(f.id);
          };
          card.innerHTML = `
            <input type="checkbox" class="file-chk" value="${f.id}">
            <div class="img-container">
               <img src="data:${f.mimeType};base64,${f.base64}">
            </div>
            <p title="${f.name}">${f.name}</p>
          `;
          list.appendChild(card);
        });
      }
      showLoading(false);
    }).getFilesInFolder(id);
  }

  // 預覽大圖
  function showPreview(id) {
    const file = allFiles.find(f => f.id === id);
    if (!file) return;
    const img = document.getElementById('full-preview');
    const hint = document.getElementById('p-hint');
    img.src = `data:${file.mimeType};base64,${file.base64}`;
    img.style.display = 'block';
    hint.style.display = 'none';
    document.getElementById('p-filename').innerText = file.name;
  }

  // 全選/取消
  function checkAll(bool) {
    document.querySelectorAll('.file-chk').forEach(c => c.checked = bool);
  }

  // 執行重新命名
  function runRename() {
    const prefixInput = document.getElementById('prefix');
    const prefix = prefixInput.value;
    const digits = document.getElementById('digits').value;
    const selectedIds = Array.from(document.querySelectorAll('.file-chk:checked')).map(c => c.value);

    if (!prefix) return alert('請輸入新檔名的前綴字!');
    if (selectedIds.length === 0) return alert('請先勾選想要更改檔名的照片!');

    if (!confirm(`確定要將這 ${selectedIds.length} 張照片重新命名嗎?`)) return;

    showLoading(true);
    google.script.run.withSuccessHandler(msg => {
      alert(msg);
      // 成功後清空前綴輸入框
      prefixInput.value = '';
      // 重新整理目前的檔案列表
      loadFiles(currentFolderId);
    }).renameFiles(currentFolderId, selectedIds, prefix, digits);
  }

  function showLoading(bool) {
    document.getElementById('mask').style.display = bool ? 'flex' : 'none';
  }
</script>

</body>
</html>
貼到 Apps Script 的 index.html 檔案

6. 部署

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

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

💬 評論區

老師們的交流與提問

載入評論中...