電子簽核系統
基於 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
簽核紀錄查看
相關資料
🔗 簽核系統