2026年8月12日 星期三

「Google Apps Script」DIY 批次更新 Chromebook 自訂欄位

批次更新 Chromebook 自訂欄位:資產 ID、使用者、位置、附註


檔案下載:https://docs.google.com/spreadsheets/d/1ZLp2KasfIP2B0WZkqMDwgYrarlTd0NyQC2UkAcx_4eM/copy


Code.gs

/**
 * 建立試算表自訂選單
 */
function onOpen() {
  var ui = SpreadsheetApp.getUi();
  ui.createMenu('🛠️ Chromebook 管理工具')
      .addItem('1. 匯出/重整設備清單', 'showOuDialog') // 改為呼叫自訂對話方塊
      .addItem('2. 執行批次更新', 'updateChromeDevices')
      .addToUi();
}

/**
 * 專門用來強制觸發授權的暫時函數
 */
function forceAuth() {
  // 直接呼叫 API,不使用 try...catch 保護
  AdminDirectory.Orgunits.list('my_customer', {type: 'all'});
}

/**
 * 顯示自訂的 OU 選擇對話方塊 (稍微加高視窗以容納多層選單)
 */
function showOuDialog() {
  var html = HtmlService.createHtmlOutputFromFile('OuDialog')
      .setWidth(450)
      .setHeight(400); // 增加高度
  SpreadsheetApp.getUi().showModalDialog(html, '匯出選項:請選擇組織單位 (OU)');
}

/**
 * 供 HTML 呼叫:取得網域內所有 OU 清單的原始結構
 */
function getOuList() {
  var customerId = 'my_customer';
  try {
    // 撈取所有 OU 原始資料,保留 parentOrgUnitPath 等階層屬性
    var response = AdminDirectory.Orgunits.list(customerId, {type: 'all'});
    return { success: true, data: response.organizationUnits || [] };
  } catch(e) {
    return { success: false, error: e.message };
  }
}

/**
 * 供 HTML 呼叫:執行實際的匯出作業
 */
function executeExport(targetOU) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var customerId = 'my_customer';
  var pageToken;
  var devices = [];
 
  // 設定標題列
  sheet.clear();
  var headers = ['Device ID (請勿更動)', '序號 (Serial Number)', '資產編號 (Asset ID)', '使用者 (User)', '位置 (Location)', '備註 (Notes)', '更新狀態'];
  sheet.appendRow(headers);
  sheet.getRange(1, 1, 1, headers.length).setFontWeight('bold').setBackground('#f3f3f3');

  // 設定 API 請求參數
  var queryOptions = { maxResults: 200 };
  if (targetOU !== '' && targetOU !== '/') {
    queryOptions.orgUnitPath = targetOU;
  }

  // 取得裝置清單
  do {
    queryOptions.pageToken = pageToken;
    var response = AdminDirectory.Chromeosdevices.list(customerId, queryOptions);
    var list = response.chromeosdevices;
   
    if (list) {
      for (var i = 0; i < list.length; i++) {
        var d = list[i];
        devices.push([
          d.deviceId,
          d.serialNumber,
          d.annotatedAssetId || '',
          d.annotatedUser || '',
          d.annotatedLocation || '',
          d.notes || '',
          ''
        ]);
      }
    }
    pageToken = response.nextPageToken;
  } while (pageToken);

  // 寫入資料
  if(devices.length > 0) {
     sheet.getRange(2, 1, devices.length, devices[0].length).setValues(devices);
     sheet.autoResizeColumns(1, 6);
     return '匯出完成!共找到 ' + devices.length + ' 筆裝置。';
  } else {
     return '找不到任何 ChromeOS 裝置,或該 OU 內無設備。';
  }
}

/**
 * 讀取試算表並批次更新 ChromeOS 裝置欄位 (保持不變)
 */
function updateChromeDevices() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var data = sheet.getDataRange().getValues();
  var customerId = 'my_customer';
  var updatedCount = 0;
 
  if (data.length <= 1) return;

  for (var i = 1; i < data.length; i++) {
    var deviceId = data[i][0];
    if (!deviceId) continue;
   
    var assetId = data[i][2];
    var user = data[i][3];
    var location = data[i][4];
    var notes = data[i][5];
   
    var resource = {
      annotatedAssetId: assetId,
      annotatedUser: user,
      annotatedLocation: location,
      notes: notes
    };
   
    try {
      AdminDirectory.Chromeosdevices.patch(resource, customerId, deviceId);
      sheet.getRange(i + 1, 7).setValue('✅ 成功').setFontColor('green');
      updatedCount++;
    } catch (e) {
      sheet.getRange(i + 1, 7).setValue('❌ 錯誤: ' + e.message).setFontColor('red');
    }
  }
  SpreadsheetApp.getUi().alert('更新完成!共更新 ' + updatedCount + ' 筆裝置。');
}


OuDialog.html

<!DOCTYPE html>
<html>
  <head>
    <base target="_top">
    <style>
      body { font-family: "Google Sans", Roboto, Arial, sans-serif; padding: 20px; color: #3c4043; }
      p { margin-top: 0; font-size: 14px; margin-bottom: 10px; }
     
      #cascading-container {
        display: flex;
        flex-direction: column;
        gap: 8px;
        max-height: 180px;
        overflow-y: auto;
        padding-right: 5px;
        margin-bottom: 15px;
      }
     
      select { width: 100%; padding: 8px; font-size: 14px; border: 1px solid #dadce0; border-radius: 4px; outline: none; }
      select:focus { border-color: #1a73e8; }
     
      #path-display {
        font-size: 12px;
        color: #1a73e8;
        background-color: #e8f0fe;
        padding: 8px 12px;
        border-radius: 4px;
        margin-bottom: 20px;
        word-break: break-all;
        font-weight: 500;
      }

      .btn-container { text-align: right; }
      button { padding: 8px 16px; font-size: 14px; font-weight: 500; border-radius: 4px; cursor: pointer; border: none; }
      .btn-cancel { background-color: transparent; color: #1a73e8; margin-right: 8px; }
      .btn-cancel:hover { background-color: #f1f3f4; }
      .btn-submit { background-color: #1a73e8; color: white; }
      .btn-submit:hover { background-color: #1557b0; }
      .btn-submit:disabled { background-color: #e8eaed; color: #9aa0a6; cursor: not-allowed; }
      #loading { font-size: 14px; color: #5f6368; text-align: center; margin-top: 50px;}
    </style>
  </head>
  <body>
    <div id="loading">🔄 正在載入並解析組織架構...</div>
   
    <div id="form-content" style="display:none;">
      <p>請依序選擇要匯出的階層:</p>
     
      <div id="cascading-container"></div>
     
      <div id="path-display">目標路徑:/ (預設範圍)</div>
     
      <div class="btn-container">
        <button class="btn-cancel" onclick="google.script.host.close()">取消</button>
        <button class="btn-submit" id="btn-export" onclick="submitExport()">確定匯出</button>
      </div>
    </div>

    <script>
      let ouChildrenMap = {};
      let currentTargetPath = '/';

      window.onload = function() {
        google.script.run
          .withSuccessHandler(buildTreeAndInit)
          .getOuList();
      };

      function buildTreeAndInit(response) {
        if (!response.success) {
          document.getElementById('loading').innerHTML = '❌ 載入失敗:<br>' + response.error;
          return;
        }

        const data = response.data;
        if (!data || data.length === 0) {
          document.getElementById('loading').innerHTML = '❌ 找不到任何組織單位。';
          return;
        }
       
        // 記錄所有抓到的 OU 路徑,用來判斷誰才是真正的「最高層」
        const ouPaths = new Set(data.map(ou => ou.orgUnitPath));
        const TOP_LEVEL_KEY = 'TOP_LEVEL';
        ouChildrenMap[TOP_LEVEL_KEY] = [];

        data.forEach(function(ou) {
          let parent = ou.parentOrgUnitPath;
         
          // 如果這個 OU 的父層不存在於我們抓到的資料中,
          // 就代表它是您權限範圍內的最高層級
          if (!parent || parent === '' || !ouPaths.has(parent)) {
            ouChildrenMap[TOP_LEVEL_KEY].push(ou);
          } else {
            if (!ouChildrenMap[parent]) ouChildrenMap[parent] = [];
            ouChildrenMap[parent].push(ou);
          }
        });

        document.getElementById('loading').style.display = 'none';
        document.getElementById('form-content').style.display = 'block';

        // 從動態偵測到的最高層開始繪製選單
        renderSelectLevel(TOP_LEVEL_KEY, 0);
      }

      function renderSelectLevel(parentPath, level) {
        const container = document.getElementById('cascading-container');

        // 清除更深層的選單
        const existingSelects = container.querySelectorAll('.ou-select');
        existingSelects.forEach(function(sel) {
          if (parseInt(sel.dataset.level) >= level) {
            sel.remove();
          }
        });

        // 更新當前路徑
        if (parentPath === 'TOP_LEVEL') {
          currentTargetPath = '/';
        } else {
          currentTargetPath = parentPath;
        }
        updatePathDisplay();

        const children = ouChildrenMap[parentPath];
        if (!children || children.length === 0) return;

        // 依名稱排序
        children.sort((a, b) => a.name.localeCompare(b.name));

        const select = document.createElement('select');
        select.className = 'ou-select';
        select.dataset.level = level;
       
        const defaultOpt = document.createElement('option');
        defaultOpt.value = parentPath;
        if (level === 0) {
          defaultOpt.text = '🌐 匯出全部權限範圍內的設備 (或選擇子層)';
        } else {
          defaultOpt.text = '📂 匯出此層級底下所有設備';
        }
        select.appendChild(defaultOpt);

        children.forEach(function(child) {
          const opt = document.createElement('option');
          opt.value = child.orgUnitPath;
          opt.text = '↳ ' + child.name;
          select.appendChild(opt);
        });

        select.addEventListener('change', function() {
          const selectedValue = this.value;
          if (selectedValue === parentPath) {
            const allSelects = container.querySelectorAll('.ou-select');
            allSelects.forEach(function(sel) {
              if (parseInt(sel.dataset.level) > level) sel.remove();
            });
            currentTargetPath = (parentPath === 'TOP_LEVEL') ? '/' : parentPath;
            updatePathDisplay();
          } else {
            renderSelectLevel(selectedValue, level + 1);
          }
        });

        container.appendChild(select);
        container.scrollTop = container.scrollHeight;
      }

      function updatePathDisplay() {
        const display = document.getElementById('path-display');
        display.innerText = '目標路徑:' + currentTargetPath;
      }

      function submitExport() {
        const btn = document.getElementById('btn-export');
        btn.disabled = true;
        btn.innerText = '匯出中,請稍候...';
       
        google.script.run
          .withSuccessHandler(function(resultMessage) {
            alert(resultMessage);
            google.script.host.close();
          })
          .withFailureHandler(function(error) {
            alert('❌ 發生錯誤:' + error.message);
            btn.disabled = false;
            btn.innerText = '確定匯出';
          })
          .executeExport(currentTargetPath);
      }
    </script>
  </body>
</html>

沒有留言:

張貼留言

歡迎大家一起留言討論!