電子簽核系統

基於 Google Apps Script 開發的宿舍生活公約電子簽核系統,整合 Vue.js 3 + Vuetify 3 前端框架與 Canvas 手寫簽名功能,支援自動浮水印、PDF 生成與簽核紀錄管理。

系統特色

  • 響應式簽名板:Vue.js 3 + Canvas API 實現跨裝置簽名功能
  • 自動浮水印:簽名提交時自動添加浮水印
  • PDF 自動生成:整合 Google Slides 模板轉換為 PDF
  • 雲端儲存:Google Drive 自動分類儲存簽核文件
  • 簽核紀錄:Google Sheets 完整記錄所有簽核資訊
  • 無伺服器架構:完全基於 Google Workspace 生態系統

系統架構

前端 (Vue.js 3 + Vuetify 3)
     ↓
┌─────────────────┐
│ Canvas 簽名板   │ → 觸控/滑鼠事件處理
└─────────────────┘
     ↓
┌─────────────────┐
│ Google Apps     │ → 簽名處理與浮水印
│ Script          │
└─────────────────┘
     ↓
┌─────────────────┐    ┌─────────────────┐
│ Google Slides   │ →  │ PDF 轉換        │
│ 模板            │    │ 引擎            │
└─────────────────┘    └─────────────────┘
     ↓                        ↓
┌─────────────────┐    ┌─────────────────┐
│ Google Drive    │    │ Google Sheets   │
│ 文件儲存         │    │ 簽核紀錄        │
└─────────────────┘    └─────────────────┘

專案結構

e-sign/
├── Code.gs              # Google Apps Script 主邏輯
├── index.html           # 主要簽核頁面 (Vue.js 3)
├── confirmation.html    # 確認頁面 (Vue.js 2)
├── Google Sheets        # 簽核記錄
└── Google Slides 模板   # PDF 生成模板

核心技術實作

1.響應式簽名板

// Vue.js 3 Composition API 實作
const { createApp, ref, onMounted, nextTick, watch } = Vue;

createApp({
    setup() {
        const canvas = ref(null);
        const ctx = ref(null);
        const drawing = ref(false);
        const oldPos = ref({ x: 0, y: 0 });
        const isCanvasDrawn = ref(false);
        const isBackgroundCleared = ref(false);

        // 裝置類型偵測
        const detectDeviceType = () => {
            if (/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)) {
                return 'mobile';
            } else {
                return 'desktop';
            }
        };

        // 初始化 Canvas 事件監聽
        watch(formVisible, (newVal) => {
            if (newVal) {
                nextTick(() => {
                    canvas.value = document.getElementById('canvas');
                    ctx.value = canvas.value.getContext('2d');

                    const deviceType = detectDeviceType();
                    if (deviceType === 'desktop') {
                        canvas.value.addEventListener('mousedown', startDraw);
                        canvas.value.addEventListener('mousemove', draw);
                        window.addEventListener('mouseup', endDraw);
                    } else {
                        canvas.value.addEventListener('touchstart', startDraw);
                        canvas.value.addEventListener('touchmove', draw);
                        canvas.value.addEventListener('touchend', endDraw);
                    }

                    drawBackgroundText();
                });
            }
        });

        // 開始繪製
        const startDraw = (e) => {
            e.preventDefault();
            drawing.value = true;
            oldPos.value = getMousePos(canvas.value, e);

            if (!isBackgroundCleared.value) {
                ctx.value.clearRect(0, 0, canvas.value.width, canvas.value.height);
                isBackgroundCleared.value = true;
            }
        };

        // 繪製過程
        const draw = (e) => {
            e.preventDefault();
            if (drawing.value) {
                const newPos = getMousePos(canvas.value, e);
                drawLine(oldPos.value, newPos);
                oldPos.value = newPos;
                isCanvasDrawn.value = true;
            }
        };

        // 結束繪製
        const endDraw = (e) => {
            e.preventDefault();
            drawing.value = false;
        };

        // 取得滑鼠/觸控位置
        const getMousePos = (canvasDom, mouseEvent) => {
            const rect = canvasDom.getBoundingClientRect();
            if (mouseEvent.touches && mouseEvent.touches[0]) {
                return {
                    x: mouseEvent.touches[0].clientX - rect.left,
                    y: mouseEvent.touches[0].clientY - rect.top
                };
            } else {
                return {
                    x: mouseEvent.clientX - rect.left,
                    y: mouseEvent.clientY - rect.top
                };
            }
        };

        // 繪製線條
        const drawLine = (p1, p2) => {
            ctx.value.beginPath();
            ctx.value.strokeStyle = 'black';
            ctx.value.lineWidth = 2;
            ctx.value.moveTo(p1.x, p1.y);
            ctx.value.lineTo(p2.x, p2.y);
            ctx.value.stroke();
            ctx.value.closePath();
        };

        return {
            canvas,
            drawing,
            isCanvasDrawn,
            startDraw,
            draw,
            endDraw
        };
    }
}).use(vuetify).mount('#app');

2. 自動浮水印處理

// 提交時自動添加浮水印
const submit = () => {
    loading.value = true;

    // 添加浮水印文字
    const lines = [
        "此簽名僅供入住宿舍",
        "使用,其他用途無效",
    ];
    const lineHeight = 42.5;
    ctx.value.font = 'bold 28.5px Arial';
    ctx.value.fillStyle = 'rgba(0, 0, 0, 0.3)';
    ctx.value.textAlign = 'center';
    ctx.value.textBaseline = 'middle';

    // 繪製每一行浮水印
    lines.forEach((line, index) => {
        ctx.value.fillText(line, canvas.value.width / 2, (canvas.value.height / 3) + (index * lineHeight));
    });

    // 轉換為 base64 格式
    const dataUrl = canvas.value.toDataURL();

    // 提交到 Google Apps Script
    google.script.run
        .withSuccessHandler((response) => {
            const { url, date, name, downloadUrl } = response;
            sessionStorage.setItem('date', date);
            sessionStorage.setItem('name', name);
            sessionStorage.setItem('downloadUrl', downloadUrl);
            window.location.href = url;
        })
        .submitForm(dataUrl, region.value, dormitory.value, name.value, date.value, employeeId.value);
};

3. Google Apps Script 後端處理

// 主要處理函數
function doGet(e) {
    if (e.parameter.page == 'confirmation') {
        return HtmlService.createHtmlOutputFromFile('confirmation').setTitle('Confirmation')
            .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
            .setSandboxMode(HtmlService.SandboxMode.IFRAME)
            .addMetaTag("viewport", "width=device-width, initial-scale=1.0, shrink-to-fit-no");
    } else {
        return HtmlService.createHtmlOutputFromFile('index').setTitle('Form')
            .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
            .setSandboxMode(HtmlService.SandboxMode.IFRAME)
            .addMetaTag("viewport", "width=device-width, initial-scale=1.0, shrink-to-fit-no");
    }
}

// 簽名儲存與處理
function saveSignature(dataUrl, region, dormitory, name, date, employeeId) {
    var contentType = "image/png";
    var fileName = name + ".png";
    var bytes = Utilities.base64Decode(dataUrl.split(',')[1]);
    var blob = Utilities.newBlob(bytes, contentType, fileName);
    
    // 建立或取得資料夾
    var folderName = region;
    var folder = findOrCreateFolder(folderName);
    var file = folder.createFile(blob);
    
    // 記錄到 Google Sheets
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
    var lastRow = sheet.getLastRow() + 1;
    sheet.getRange("A" + lastRow).setValue(region);
    sheet.getRange("B" + lastRow).setValue(dormitory);
    sheet.getRange("C" + lastRow).setValue(name);
    sheet.getRange("D" + lastRow).setValue(date);
    sheet.getRange("E" + lastRow).setValue(employeeId);
    
    // 插入簽名到 Slides 並轉換為 PDF
    var url = insertImageIntoSlide(dataUrl, folder, date, employeeId, name, file);
    sheet.getRange("F" + lastRow).setValue(url);

    return url;
}

// 資料夾管理
function findOrCreateFolder(folderName) {
    var root = DriveApp.getFolderById("1-pz9BdFGw903109I1Lsw0g0iD_1hXK7j");
    var folder;
    var folders = root.getFoldersByName(folderName);
    if (folders.hasNext()) {
        folder = folders.next();
    } else {
        folder = root.createFolder(folderName);
    }
    return folder;
}

4. PDF 自動生成系統

// 將簽名插入 Google Slides 模板並轉換為 PDF
function insertImageIntoSlide(dataUrl, folder, date, employeeId, name, signatureFile) {
    var presentationId = "1rdg5FE32Rlt0B1juH5vkY7_oDKwFoenfcj9xe8J5ZX8";
    var originalPresentation = DriveApp.getFileById(presentationId);
    
    // 建立副本
    var fileName = date + "_" + employeeId + "_" + name;
    var presentationCopy = originalPresentation.makeCopy(fileName, folder);
    var presentation = SlidesApp.openById(presentationCopy.getId());
    var slide = presentation.getSlides()[0];

    // 插入簽名圖片
    var imageBlob = Utilities.newBlob(Utilities.base64Decode(dataUrl.split(',')[1]), 'image/png', 'signature.png');
    var image = slide.insertImage(imageBlob);

    // 設定簽名位置與大小
    image.setWidth(200);
    image.setHeight(100);
    image.setLeft(50);
    image.setTop(450);

    presentation.saveAndClose();

    // 生成 PDF 下載連結
    var slideUrl = "https://docs.google.com/presentation/d/" + presentationCopy.getId() + "/export/pdf";

    // 清理暫存檔案
    signatureFile.setTrashed(true);

    return slideUrl;
}

// 表單提交處理
function submitForm(dataUrl, region, dormitory, name, date, employeeId) {
    var downloadUrl = saveSignature(dataUrl, region, dormitory, name, date, employeeId);
    return {
        url: "https://script.google.com/macros/s/AKfycbxZV5MZbLsINym8lkzG-nPqA38x2ry9F3gOlSghGgZtodFGyEzrAYbsEcCLZQaPQ1-B/exec?page=confirmation",
        date: date,
        name: name,
        downloadUrl: downloadUrl
    };
}

5. 確認頁面

// 確認頁面實作
new Vue({
    el: '#app',
    vuetify: new Vuetify(),
    data: {
        date: '',
        name: '',
        downloadUrl: ''
    },
    created() {
        // 從 sessionStorage 取得資料
        this.date = sessionStorage.getItem('date');
        this.name = sessionStorage.getItem('name');
        this.downloadUrl = sessionStorage.getItem('downloadUrl');
    },
    methods: {
        download() {
            // 開啟 PDF 下載連結
            window.open(this.downloadUrl, '_blank');
        }
    }
});

系統展示

簽核系統 Demo

簽核紀錄查看


相關資料

🔗 簽核系統

📊 完整原始碼 (Google Sheet)