출석부 QR 서명 시스템 초기 커밋
This commit is contained in:
234
site/index.html
Normal file
234
site/index.html
Normal file
@@ -0,0 +1,234 @@
|
||||
<!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