Files
online-attendance/site/index.html
2026-08-25 11:14:55 +09:00

235 lines
8.3 KiB
HTML

<!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>