171
Code.gs
171
Code.gs
@@ -1,171 +0,0 @@
|
||||
const SHEET_NAME = '출석부';
|
||||
const SIGNATURE_FOLDER_NAME = '출석_서명이미지';
|
||||
const HEADERS = ['날짜', '이름', '입실시각', '퇴실시각', '학습시간', '입실서명', '퇴실서명', '입실기기ID', '입실기기정보', '퇴실기기ID', '퇴실기기정보'];
|
||||
const COL = {
|
||||
DATE: 1,
|
||||
NAME: 2,
|
||||
IN_TIME: 3,
|
||||
OUT_TIME: 4,
|
||||
STUDY_TIME: 5,
|
||||
IN_SIGNATURE: 6,
|
||||
OUT_SIGNATURE: 7,
|
||||
IN_DEVICE_ID: 8,
|
||||
IN_DEVICE_INFO: 9,
|
||||
OUT_DEVICE_ID: 10,
|
||||
OUT_DEVICE_INFO: 11
|
||||
};
|
||||
const MAX_SIGNATURE_BASE64_LENGTH = 2000000; // 서명 이미지 base64 길이 상한(약 1.5MB), 남용 방지용
|
||||
|
||||
// 학생용 페이지는 이 Apps Script가 아니라 별도 정적 호스팅(예: GitHub Pages)에 둔다.
|
||||
// 이 웹앱은 그 페이지가 fetch()로 호출하는 JSON API 역할만 한다 (배포 URL을 직접 열람하지 않게 하기 위함).
|
||||
function doGet(e) {
|
||||
if (e && e.parameter && e.parameter.action === 'names') {
|
||||
return ContentService.createTextOutput(JSON.stringify({ ok: true, names: getKnownNames_() }))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
return ContentService.createTextOutput('출석부 API가 정상 동작 중입니다.');
|
||||
}
|
||||
|
||||
// 이름 자동완성용: 시트에 이미 기록된 이름을 중복 없이 반환한다.
|
||||
function getKnownNames_() {
|
||||
const sheet = getOrCreateSheet_();
|
||||
const lastRow = sheet.getLastRow();
|
||||
if (lastRow < 2) return [];
|
||||
const values = sheet.getRange(2, COL.NAME, lastRow - 1, 1).getValues();
|
||||
const seen = {};
|
||||
const names = [];
|
||||
values.forEach(function (row) {
|
||||
const n = row[0];
|
||||
if (n && !seen[n]) {
|
||||
seen[n] = true;
|
||||
names.push(n);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function doPost(e) {
|
||||
let result;
|
||||
try {
|
||||
const payload = JSON.parse(e.postData.contents);
|
||||
result = submitAttendance(payload);
|
||||
} catch (err) {
|
||||
result = { ok: false, error: err.message };
|
||||
}
|
||||
return ContentService.createTextOutput(JSON.stringify(result))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
|
||||
function getOrCreateSheet_() {
|
||||
const ss = SpreadsheetApp.getActiveSpreadsheet();
|
||||
let sheet = ss.getSheetByName(SHEET_NAME);
|
||||
if (!sheet) {
|
||||
sheet = ss.insertSheet(SHEET_NAME);
|
||||
}
|
||||
if (sheet.getLastRow() === 0) {
|
||||
sheet.appendRow(HEADERS);
|
||||
sheet.setFrozenRows(1);
|
||||
sheet.getRange('A2:A').setNumberFormat('yyyy-mm-dd');
|
||||
sheet.getRange('C2:D').setNumberFormat('hh:mm:ss');
|
||||
sheet.getRange('E2:E').setNumberFormat('[h]"시간" mm"분"');
|
||||
sheet.setColumnWidth(COL.IN_SIGNATURE, 160);
|
||||
sheet.setColumnWidth(COL.OUT_SIGNATURE, 160);
|
||||
sheet.hideColumns(COL.IN_DEVICE_ID, 4);
|
||||
}
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function getOrCreateFolder_() {
|
||||
const folders = DriveApp.getFoldersByName(SIGNATURE_FOLDER_NAME);
|
||||
if (folders.hasNext()) {
|
||||
return folders.next();
|
||||
}
|
||||
return DriveApp.createFolder(SIGNATURE_FOLDER_NAME);
|
||||
}
|
||||
|
||||
// 이름/기기정보가 "=", "+" 등으로 시작하면 시트에서 수식으로 실행될 수 있어(수식 삽입 취약점) 앞에 작은따옴표를 붙여 텍스트로 강제
|
||||
function sanitizeForSheet_(text) {
|
||||
const str = (text || '').toString();
|
||||
return /^[=+\-@\t\r]/.test(str) ? "'" + str : str;
|
||||
}
|
||||
|
||||
// 같은 이름 + 같은 날짜의 행을 찾아 반환하고, 없으면 새로 만들어서 반환한다.
|
||||
function findOrCreateRow_(sheet, dateStr, name) {
|
||||
const lastRow = sheet.getLastRow();
|
||||
const tz = Session.getScriptTimeZone();
|
||||
if (lastRow >= 2) {
|
||||
const values = sheet.getRange(2, COL.DATE, lastRow - 1, 2).getValues();
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const rowDate = values[i][0];
|
||||
const rowDateStr = rowDate instanceof Date ? Utilities.formatDate(rowDate, tz, 'yyyy-MM-dd') : '';
|
||||
if (rowDateStr === dateStr && values[i][1] === name) {
|
||||
return i + 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
const newRow = lastRow + 1;
|
||||
sheet.getRange(newRow, COL.DATE).setValue(new Date(dateStr));
|
||||
sheet.getRange(newRow, COL.NAME).setValue(sanitizeForSheet_(name));
|
||||
// 입실/퇴실시각이 둘 다 채워지면 자동으로 학습시간을 계산하는 수식 (둘 중 하나라도 없으면 빈 값)
|
||||
sheet.getRange(newRow, COL.STUDY_TIME).setFormula(
|
||||
'=IF(AND(C' + newRow + '<>"",D' + newRow + '<>""),D' + newRow + '-C' + newRow + ',"")'
|
||||
);
|
||||
return newRow;
|
||||
}
|
||||
|
||||
function submitAttendance(payload) {
|
||||
if (!payload || !payload.name || !payload.signature) {
|
||||
throw new Error('이름과 서명을 모두 입력해주세요.');
|
||||
}
|
||||
if (payload.type !== 'in' && payload.type !== 'out') {
|
||||
throw new Error('입실/퇴실 여부를 선택해주세요.');
|
||||
}
|
||||
|
||||
const name = payload.name.toString().trim().slice(0, 50);
|
||||
if (!name) {
|
||||
throw new Error('이름을 입력해주세요.');
|
||||
}
|
||||
|
||||
const base64 = payload.signature.split(',').pop();
|
||||
if (!base64 || base64.length > MAX_SIGNATURE_BASE64_LENGTH) {
|
||||
throw new Error('서명 데이터가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const tz = Session.getScriptTimeZone();
|
||||
const dateStr = Utilities.formatDate(now, tz, 'yyyy-MM-dd');
|
||||
|
||||
const sheet = getOrCreateSheet_();
|
||||
const row = findOrCreateRow_(sheet, dateStr, name);
|
||||
|
||||
const isIn = payload.type === 'in';
|
||||
const timeCol = isIn ? COL.IN_TIME : COL.OUT_TIME;
|
||||
const existing = sheet.getRange(row, timeCol).getValue();
|
||||
const shouldUpdate = !(existing instanceof Date) || (isIn ? now < existing : now > existing);
|
||||
|
||||
// 입실은 그날의 가장 빠른 시각, 퇴실은 가장 늦은 시각만 채택한다.
|
||||
// 채택되지 않는 제출은 시각/서명/기기정보를 아예 기록하지 않는다 (기록된 시각과 서명이 항상 같은 제출에서 나오도록 보장).
|
||||
if (!shouldUpdate) {
|
||||
return { ok: true, updated: false };
|
||||
}
|
||||
|
||||
const bytes = Utilities.base64Decode(base64);
|
||||
const blob = Utilities.newBlob(bytes, 'image/png', name + '_' + now.getTime() + '.png');
|
||||
const folder = getOrCreateFolder_();
|
||||
const file = folder.createFile(blob);
|
||||
file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
|
||||
const imageUrl = 'https://drive.google.com/thumbnail?id=' + file.getId() + '&sz=w1000';
|
||||
|
||||
const sigCol = isIn ? COL.IN_SIGNATURE : COL.OUT_SIGNATURE;
|
||||
const devIdCol = isIn ? COL.IN_DEVICE_ID : COL.OUT_DEVICE_ID;
|
||||
const devInfoCol = isIn ? COL.IN_DEVICE_INFO : COL.OUT_DEVICE_INFO;
|
||||
|
||||
sheet.getRange(row, timeCol).setValue(now);
|
||||
const cellImage = SpreadsheetApp.newCellImage().setSourceUrl(imageUrl).build();
|
||||
sheet.getRange(row, sigCol).setValue(cellImage);
|
||||
sheet.getRange(row, devIdCol).setValue(sanitizeForSheet_(payload.deviceId));
|
||||
sheet.getRange(row, devInfoCol).setValue(sanitizeForSheet_(payload.deviceInfo));
|
||||
sheet.setRowHeight(row, 60);
|
||||
|
||||
return { ok: true, updated: true };
|
||||
}
|
||||
87
README.md
87
README.md
@@ -1,87 +0,0 @@
|
||||
# 온라인 출석부 (QR 서명 방식)
|
||||
|
||||
## 구조
|
||||
|
||||
구글 계정이 여러 개 있는 폰에서 `script.google.com` 페이지를 직접 열면, 구글이 계정 컨텍스트를 정하는 과정에서 주소가 `.../u/1/exec`, `.../u/3/exec` 처럼 바뀌면서 접속이 안 되는 문제가 있습니다. 이 문제는 링크를 단축 URL로 감싸도, 최종적으로 그 페이지를 "열람"하는 순간 다시 발생합니다.
|
||||
|
||||
그래서 이 구성에서는 **학생이 여는 페이지는 구글 도메인이 아닌 곳에 두고**, Apps Script는 그 페이지가 백그라운드에서 `fetch()`로 호출하는 JSON API 역할만 하도록 나눴습니다. 브라우저가 `script.google.com`을 화면에 열람하는 게 아니라 JS가 조용히 API만 호출하기 때문에 계정 선택 문제가 아예 발생하지 않습니다.
|
||||
|
||||
- `Code.gs` → Google Apps Script에 붙여넣음. 서명 이미지 저장 + 시트 기록을 담당하는 JSON API (`doPost`)
|
||||
- `site/index.html` → **Apps Script가 아닌** 일반 정적 호스팅(GitHub Pages 등)에 올림. 학생이 QR로 여는 실제 페이지 (이름 입력 + 손글씨 서명 캔버스)
|
||||
|
||||
기록되는 열: 날짜, 이름, 입실시각, 퇴실시각, 학습시간(자동 계산), 입실서명(이미지), 퇴실서명(이미지), 입실기기ID·입실기기정보·퇴실기기ID·퇴실기기정보(기본적으로 숨김 처리됨)
|
||||
|
||||
- **학습시간**은 입실시각·퇴실시각이 둘 다 있을 때만 자동 계산되는 수식(`=D-C`)입니다. 시각이 바뀌면 자동으로 다시 계산됩니다.
|
||||
- **이름 입력창 자동완성**: 페이지를 열 때 시트에 이미 기록된 이름 목록을 불러와 입력창에 제안으로 띄워줍니다 (브라우저 기본 자동완성 UI 사용, 새 이름도 자유롭게 입력 가능).
|
||||
- 페이지 상단에 학교 로고 이미지가 표시됩니다.
|
||||
|
||||
시트는 **학생 1명 + 날짜 1개 = 1행**으로 관리됩니다. 같은 날 같은 학생이 여러 번 서명해도 행이 늘어나지 않고, 다음 규칙으로 같은 행이 갱신됩니다.
|
||||
- **입실**: 그날 기록된 시각보다 더 빠른 경우에만 갱신 (그날 첫 입실 시각 유지)
|
||||
- **퇴실**: 그날 기록된 시각보다 더 늦은 경우에만 갱신 (그날 마지막 퇴실 시각 유지)
|
||||
- 갱신되지 않는 제출은 서명 이미지도 기록되지 않습니다 (시트에 남는 서명은 항상 실제로 채택된 입실/퇴실 시각과 같은 순간에 찍힌 서명입니다).
|
||||
|
||||
이 구조라서 날짜별로 등교/하교 시간을 볼 때 피벗테이블이 단순해집니다 — 이미 한 줄에 그날의 입실/퇴실시각이 정리돼 있으므로, 피벗테이블에서 행에 `날짜`(또는 `이름`), 값에 `입실시각`/`퇴실시각`을 그대로 넣으면 됩니다 (별도 MIN/MAX 집계 설정 불필요).
|
||||
|
||||
> **열 구조가 바뀔 때마다 기존 `출석부` 시트는 그대로 재사용하면 안 됩니다** (헤더가 이미 있으면 코드가 헤더를 다시 쓰지 않기 때문에 새 열이 추가되지 않습니다). 학습시간 열을 추가하면서 다시 한 번 열 구조가 바뀌었으니, 기존 `출석부` 탭 이름을 `출석부_구버전` 등으로 바꿔 보관해두고 Apps Script를 재배포하면 새 구조의 `출석부` 시트가 새로 만들어집니다.
|
||||
|
||||
## 1단계 — Apps Script API 배포
|
||||
|
||||
1. [sheets.google.com](https://sheets.google.com)에서 새 스프레드시트 생성 (이름 예: "출석부")
|
||||
2. 상단 메뉴 **확장 프로그램 → Apps Script** 클릭
|
||||
3. 기본 생성된 `Code.gs` 내용을 전부 지우고, 이 폴더의 `Code.gs` 내용을 붙여넣기
|
||||
4. `Ctrl+S`로 저장
|
||||
5. 오른쪽 위 **배포 → 새 배포**
|
||||
- 유형: **웹 앱**
|
||||
- 실행 계정: **나**
|
||||
- 액세스 권한이 있는 사용자: **전체**
|
||||
- 배포 클릭
|
||||
6. 처음 배포 시 권한 승인 창이 뜨면 **고급 → (프로젝트명)으로 이동 → 허용** (Sheets/Drive 접근 권한 승인 — 본인 계정으로만 실행되므로 안전합니다)
|
||||
7. 발급된 웹 앱 URL(`https://script.google.com/macros/s/.../exec`)을 복사 — **이 URL은 학생에게 직접 주지 않습니다.** 2단계에서 페이지 안에만 넣습니다.
|
||||
|
||||
## 2단계 — 학생용 페이지 호스팅 (구글이 아닌 곳)
|
||||
|
||||
`site/index.html`을 열어 맨 위 스크립트의
|
||||
|
||||
```js
|
||||
const EXEC_URL = 'PASTE_YOUR_APPS_SCRIPT_WEB_APP_URL_HERE';
|
||||
```
|
||||
|
||||
부분을 1단계에서 복사한 실제 URL로 교체한 뒤, 정적 호스팅에 올립니다. 아무 계정 없이 가장 빨리 해보려면:
|
||||
|
||||
- **Netlify Drop**: [app.netlify.com/drop](https://app.netlify.com/drop)에 `site` 폴더를 그대로 드래그 앤 드롭 → 즉시 URL 발급 (계정 없이도 되지만, 계속 쓰려면 무료 계정 연결 권장)
|
||||
|
||||
계속 쓸 주소를 원하면:
|
||||
|
||||
- **GitHub Pages**: GitHub 저장소에 `site` 폴더 내용을 올리고 저장소 설정에서 Pages를 켜면 `https://<사용자명>.github.io/<저장소명>/` 형태의 고정 주소가 생김
|
||||
- **Cloudflare Pages / Vercel**: 같은 방식으로 `site` 폴더 하나만 배포하면 됨 (별도 빌드 설정 불필요, 정적 HTML 파일 1개)
|
||||
|
||||
## 3단계 — QR 코드 만들기
|
||||
|
||||
호스팅에서 받은 주소(구글 도메인이 아닌 주소)로 QR 코드를 생성해 인쇄 후 교실 입구에 부착하세요.
|
||||
- Chrome 주소창 오른쪽 **공유 아이콘 → "QR 코드 만들기"** 기능을 쓰면 별도 사이트 없이 바로 생성됩니다.
|
||||
- 원하는 QR 생성 사이트/앱을 사용해도 무방합니다.
|
||||
|
||||
이제 QR을 찍으면 곧바로 `site/index.html` 페이지가 열리고(구글 계정과 무관), 제출 시에만 뒤에서 조용히 Apps Script API를 호출합니다.
|
||||
|
||||
## 대리서명 확인 방법
|
||||
|
||||
- **입실기기ID/퇴실기기ID** 열(기본 숨김)이 같은데 **이름**이 다른 경우, 같은 기기(브라우저)에서 여러 사람 이름으로 서명이 이뤄진 것이므로 대리서명 의심 대상입니다. 확인할 때만 열을 펼쳐서(선택 후 우클릭 → 열 표시) 기기ID 기준으로 정렬하거나 조건부 서식으로 중복을 표시하면 됩니다.
|
||||
- **입실기기정보/퇴실기기정보** 열로 기종·브라우저가 같은 서명이 몰려 있는지 함께 확인하세요.
|
||||
- 기기ID는 브라우저의 localStorage에 저장되는 값이라, 시크릿 모드나 브라우저 데이터 삭제 시 매번 새로 생성됩니다. 완벽한 기기 식별은 아니고 보조 지표로만 활용하세요.
|
||||
|
||||
## 피벗테이블로 일자별 등교/하교 시간 보기
|
||||
|
||||
1. `출석부` 시트에서 데이터 범위를 선택하고 **삽입 → 피벗 테이블**
|
||||
2. 행: `날짜` (필요하면 `이름`도 추가)
|
||||
3. 값: `입실시각`, `퇴실시각` — 요약 방식은 "없음"(그대로 표시)이나 "최소"/"최대" 어느 걸 골라도 결과가 같습니다 (한 학생·한 날짜에 이미 값이 하나뿐이기 때문입니다).
|
||||
|
||||
## 코드 수정 시 주의
|
||||
|
||||
- `Code.gs`를 수정한 뒤에는 **배포 → 배포 관리 → 수정(연필 아이콘) → 버전: 새 버전 → 배포**를 해야 반영됩니다. (같은 URL이 유지되므로 `site/index.html`의 `EXEC_URL`은 그대로 둬도 됩니다.)
|
||||
- `site/index.html`을 수정한 뒤에는 호스팅에 다시 올려야 반영됩니다 (GitHub Pages는 push, Netlify Drop은 다시 드래그).
|
||||
|
||||
## 자동 생성되는 것들
|
||||
|
||||
첫 제출이 들어오면 자동으로:
|
||||
- 스프레드시트에 `출석부` 시트와 헤더가 생성됩니다.
|
||||
- 내 드라이브에 `출석_서명이미지` 폴더가 생성되고, 서명 PNG 파일들이 여기에 저장됩니다 (시트 셀에는 이 이미지가 바로 삽입되어 보입니다).
|
||||
234
site/index.html
234
site/index.html
@@ -1,234 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>자율학습 출석 서명</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
margin: 0; padding: 24px 16px;
|
||||
background: #f5f6f8;
|
||||
display: flex; justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #fff; width: 100%; max-width: 420px;
|
||||
border-radius: 16px; padding: 24px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
.logo { display: block; max-width: 140px; max-height: 70px; margin: 0 auto 16px; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; text-align: center; }
|
||||
p.sub { color: #666; font-size: 13px; margin: 0 0 20px; }
|
||||
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
||||
input[type=text] {
|
||||
width: 100%; padding: 12px; font-size: 16px;
|
||||
border: 1px solid #ddd; border-radius: 8px; margin-bottom: 20px;
|
||||
}
|
||||
canvas {
|
||||
width: 100%; height: 180px; border: 2px dashed #ccc; border-radius: 8px;
|
||||
touch-action: none; background: #fff; display: block;
|
||||
}
|
||||
.canvas-wrap { position: relative; margin-bottom: 10px; }
|
||||
.clear-btn {
|
||||
position: absolute; top: 8px; right: 8px;
|
||||
background: #eee; border: none; border-radius: 6px;
|
||||
padding: 6px 10px; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.hint { font-size: 12px; color: #999; margin-bottom: 20px; }
|
||||
.toggle-group { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.toggle-btn {
|
||||
flex: 1; padding: 14px; font-size: 16px; font-weight: 600;
|
||||
border: 2px solid #ddd; border-radius: 10px; background: #fff; color: #444;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle-btn.active { border-color: #2563eb; background: #2563eb; color: #fff; }
|
||||
button.submit {
|
||||
width: 100%; padding: 14px; font-size: 16px; font-weight: 600;
|
||||
background: #2563eb; color: #fff; border: none; border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.submit:disabled { background: #93b4ec; }
|
||||
.msg { margin-top: 14px; font-size: 14px; text-align: center; min-height: 18px; }
|
||||
.msg.error { color: #dc2626; }
|
||||
.msg.success { color: #16a34a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img class="logo" src="https://dukyoung-h.goeyi.kr/upload/dukyoung-h/logo/img_c93b11b3-352b-4a30-8334-bc48086c7ea21757555128430.png" alt="학교 로고">
|
||||
<h1>자율학습 출석 서명</h1>
|
||||
|
||||
<label>구분</label>
|
||||
<div class="toggle-group">
|
||||
<button type="button" class="toggle-btn" id="btnIn" onclick="selectType('in')">등교</button>
|
||||
<button type="button" class="toggle-btn" id="btnOut" onclick="selectType('out')">하교</button>
|
||||
</div>
|
||||
|
||||
<label for="name">이름</label>
|
||||
<input type="text" id="name" placeholder="예) 홍길동" autocomplete="off" list="nameList">
|
||||
<datalist id="nameList"></datalist>
|
||||
|
||||
<label>서명</label>
|
||||
<div class="canvas-wrap">
|
||||
<canvas id="pad"></canvas>
|
||||
<button type="button" class="clear-btn" onclick="clearPad()">지우기</button>
|
||||
</div>
|
||||
<div class="hint">손가락 또는 마우스로 직접 서명해주세요.</div>
|
||||
|
||||
<button class="submit" id="submitBtn" onclick="submitForm()">제출</button>
|
||||
<div id="msg" class="msg"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const EXEC_URL = 'https://script.google.com/macros/s/AKfycbzryIIvlnzoyf2W8Fq2n0LE70w8wXF46HgQWnHehZoz_-RcHpdBXQW6MyBsq91f_suG/exec';
|
||||
|
||||
const canvas = document.getElementById('pad');
|
||||
const ctx = canvas.getContext('2d');
|
||||
let drawing = false, hasDrawn = false;
|
||||
let selectedType = null;
|
||||
|
||||
function selectType(type) {
|
||||
selectedType = type;
|
||||
document.getElementById('btnIn').classList.toggle('active', type === 'in');
|
||||
document.getElementById('btnOut').classList.toggle('active', type === 'out');
|
||||
}
|
||||
|
||||
// 기존에 시트에 기록된 이름을 불러와 자동완성 목록에 채워둔다 (실패해도 이름 직접 입력엔 문제 없음).
|
||||
fetch(EXEC_URL + '?action=names')
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok || !Array.isArray(data.names)) return;
|
||||
const dl = document.getElementById('nameList');
|
||||
data.names.forEach(function (n) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
|
||||
function resizeCanvas() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const ratio = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * ratio;
|
||||
canvas.height = rect.height * ratio;
|
||||
ctx.scale(ratio, ratio);
|
||||
ctx.lineWidth = 2.2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeStyle = '#111';
|
||||
}
|
||||
resizeCanvas();
|
||||
window.addEventListener('resize', function () {
|
||||
resizeCanvas();
|
||||
clearPad();
|
||||
});
|
||||
|
||||
function pos(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const t = e.touches ? e.touches[0] : e;
|
||||
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
|
||||
}
|
||||
function start(e) {
|
||||
e.preventDefault();
|
||||
drawing = true;
|
||||
hasDrawn = true;
|
||||
const p = pos(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(p.x, p.y);
|
||||
}
|
||||
function move(e) {
|
||||
if (!drawing) return;
|
||||
e.preventDefault();
|
||||
const p = pos(e);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
function end() { drawing = false; }
|
||||
|
||||
canvas.addEventListener('mousedown', start);
|
||||
canvas.addEventListener('mousemove', move);
|
||||
window.addEventListener('mouseup', end);
|
||||
canvas.addEventListener('touchstart', start, { passive: false });
|
||||
canvas.addEventListener('touchmove', move, { passive: false });
|
||||
canvas.addEventListener('touchend', end);
|
||||
|
||||
function clearPad() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
hasDrawn = false;
|
||||
}
|
||||
|
||||
function getDeviceId() {
|
||||
try {
|
||||
let id = localStorage.getItem('attendanceDeviceId');
|
||||
if (!id) {
|
||||
id = 'dev-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
|
||||
localStorage.setItem('attendanceDeviceId', id);
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
function getDeviceInfo() {
|
||||
return [
|
||||
navigator.userAgent,
|
||||
'해상도:' + screen.width + 'x' + screen.height,
|
||||
'언어:' + navigator.language,
|
||||
'시간대:' + Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
].join(' | ');
|
||||
}
|
||||
|
||||
function setMsg(text, type) {
|
||||
const el = document.getElementById('msg');
|
||||
el.textContent = text;
|
||||
el.className = 'msg' + (type ? ' ' + type : '');
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
const name = document.getElementById('name').value.trim();
|
||||
if (!selectedType) { setMsg('입실/퇴실을 선택해주세요.', 'error'); return; }
|
||||
if (!name) { setMsg('이름을 입력해주세요.', 'error'); return; }
|
||||
if (!hasDrawn) { setMsg('서명을 해주세요.', 'error'); return; }
|
||||
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
setMsg('제출 중...', '');
|
||||
|
||||
const payload = {
|
||||
type: selectedType,
|
||||
name: name,
|
||||
signature: canvas.toDataURL('image/png'),
|
||||
deviceId: getDeviceId(),
|
||||
deviceInfo: getDeviceInfo()
|
||||
};
|
||||
|
||||
// Content-Type을 text/plain으로 보내야 브라우저가 사전확인(preflight) 요청을 보내지 않는다.
|
||||
// Apps Script 웹앱은 OPTIONS 프리플라이트에 응답하지 못해, application/json으로 보내면 실패한다.
|
||||
fetch(EXEC_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'text/plain;charset=utf-8' },
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
.then(function (res) { return res.json(); })
|
||||
.then(function (data) {
|
||||
if (data && data.ok) {
|
||||
setMsg(data.updated ? '출석이 완료되었습니다.' : '이미 더 앞선(또는 더 늦은) 기록이 있어 갱신되지 않았습니다.', 'success');
|
||||
document.getElementById('name').value = '';
|
||||
clearPad();
|
||||
selectType(null);
|
||||
} else {
|
||||
setMsg('오류: ' + (data && data.error ? data.error : '알 수 없는 오류'), 'error');
|
||||
}
|
||||
btn.disabled = false;
|
||||
})
|
||||
.catch(function (err) {
|
||||
setMsg('네트워크 오류: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user