2026-04-12 17:47:54 +08:00

469 lines
18 KiB
HTML

<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KL630 Event Monitor</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #10121a; color: #dde1f0; min-height: 100vh; }
/* ── Header ── */
header {
display: flex; align-items: center; gap: 12px;
padding: 12px 20px;
background: #181b27; border-bottom: 1px solid #252836;
}
.dot-live { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; flex-shrink: 0; }
header h1 { font-size: 1rem; font-weight: 600; color: #fff; }
#utc-clock { margin-left: auto; font-size: 0.78rem; color: #555d7a; font-family: monospace; }
/* ── Layout ── */
.layout { display: grid; grid-template-columns: 1fr 1fr; height: calc(100vh - 49px); }
/* ── Panel ── */
.panel { display: flex; flex-direction: column; border-right: 1px solid #1e2130; overflow: hidden; }
.panel:last-child { border-right: none; }
.panel-head {
padding: 10px 16px; background: #181b27;
border-bottom: 1px solid #1e2130;
display: flex; align-items: center; gap: 8px; flex-shrink: 0;
}
.ch-badge {
font-size: 0.65rem; font-weight: 800; padding: 2px 7px;
border-radius: 10px; letter-spacing: 0.06em;
}
.ch-a { background: #1a3254; color: #60a5fa; }
.ch-b { background: #14312a; color: #4ade80; }
.panel-head h2 { font-size: 0.78rem; font-weight: 600; color: #8891b0; text-transform: uppercase; letter-spacing: 0.05em; }
/* ── Current event card ── */
#evt-card {
margin: 12px; padding: 16px;
background: #181b27; border: 1px solid #252836; border-radius: 10px;
flex-shrink: 0;
}
#evt-type { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: 10px; }
#evt-type.none { color: #3a3f55; }
/* Level timeline */
.level-track { display: flex; align-items: center; gap: 0; margin-bottom: 12px; }
.lv-step {
display: flex; flex-direction: column; align-items: center; flex: 1;
position: relative;
}
.lv-step + .lv-step::before {
content: ''; position: absolute; top: 13px; left: -50%; width: 100%;
height: 2px; background: #252836;
}
.lv-step + .lv-step.done::before { background: #4ade80; }
.lv-step + .lv-step.active::before { background: #60a5fa; }
.lv-circle {
width: 26px; height: 26px; border-radius: 50%;
border: 2px solid #252836; background: #10121a;
display: flex; align-items: center; justify-content: center;
font-size: 0.7rem; font-weight: 700; color: #3a3f55;
position: relative; z-index: 1;
transition: all 0.3s;
}
.lv-label { font-size: 0.62rem; color: #3a3f55; margin-top: 4px; }
.lv-step.active .lv-circle { border-color: #60a5fa; background: #1a3254; color: #60a5fa; }
.lv-step.done .lv-circle { border-color: #4ade80; background: #14312a; color: #4ade80; }
.lv-step.warn1 .lv-circle { border-color: #facc15; background: #2a2400; color: #facc15; }
.lv-step.warn2 .lv-circle { border-color: #fb923c; background: #2a1200; color: #fb923c; }
.lv-step.warn3 .lv-circle { border-color: #f87171; background: #2a0a0a; color: #f87171; }
.lv-step.single .lv-circle { border-color: #a78bfa; background: #1e1040; color: #a78bfa; }
.lv-step.active .lv-label,
.lv-step.done .lv-label,
.lv-step.warn1 .lv-label,
.lv-step.warn2 .lv-label,
.lv-step.warn3 .lv-label,
.lv-step.single .lv-label { color: #8891b0; }
#evt-meta { font-size: 0.73rem; color: #555d7a; font-family: monospace; }
#evt-meta span { color: #4a7ab0; }
/* ── Event log ── */
.log-scroll { flex: 1; overflow-y: auto; padding: 0 12px 12px; }
.log-scroll h3 {
font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.06em;
color: #3a3f55; padding: 10px 0 8px; position: sticky; top: 0;
background: #10121a;
}
.log-row {
display: flex; align-items: baseline; gap: 10px;
padding: 7px 10px; margin-bottom: 3px;
background: #181b27; border-radius: 6px;
border-left: 3px solid #252836;
font-size: 0.78rem;
}
.log-row.lv0 { border-color: #4ade80; }
.log-row.lv1 { border-color: #facc15; }
.log-row.lv2 { border-color: #fb923c; }
.log-row.lv3 { border-color: #f87171; }
.log-row.single { border-color: #a78bfa; }
.log-time { color: #555d7a; font-family: monospace; white-space: nowrap; flex-shrink: 0; }
.log-type { font-weight: 600; color: #dde1f0; }
.log-sub { color: #555d7a; }
/* ── Test bar ── */
.test-bar {
border-top: 1px solid #1e2130; padding: 8px 12px;
background: #0e1018; flex-shrink: 0;
}
details summary {
font-size: 0.68rem; color: #3a3f55; cursor: pointer; user-select: none;
text-transform: uppercase; letter-spacing: 0.05em;
list-style: none; display: flex; align-items: center; gap: 6px;
}
details summary::before { content: '▶'; font-size: 0.5rem; transition: transform 0.2s; }
details[open] summary::before { transform: rotate(90deg); }
.btn-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.btn {
padding: 4px 10px; border: none; border-radius: 5px;
font-size: 0.72rem; font-weight: 600; cursor: pointer; opacity: 0.85;
}
.btn:hover { opacity: 1; }
.b-l1 { background:#facc15;color:#000; }
.b-l2 { background:#fb923c;color:#000; }
.b-l3 { background:#f87171;color:#fff; }
.b-l0 { background:#4ade80;color:#000; }
.b-hz { background:#60a5fa;color:#000; }
.b-pe { background:#a78bfa;color:#fff; }
.b-up { background:#1e2130;color:#60a5fa;border:1px solid #2a3050; }
/* ── File list (Channel B) ── */
.file-scroll { flex: 1; overflow-y: auto; padding: 12px; }
.file-count { font-size: 0.68rem; color: #3a3f55; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 10px; }
.file-card {
background: #181b27; border: 1px solid #252836; border-radius: 8px;
padding: 11px 13px; margin-bottom: 7px;
display: flex; align-items: center; gap: 10px;
}
.file-icon { font-size: 1.3rem; flex-shrink: 0; }
.file-info { flex: 1; min-width: 0; }
.file-name {
font-size: 0.78rem; font-weight: 600; color: #b0c4e0;
font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.file-meta { font-size: 0.68rem; color: #3a3f55; margin-top: 3px; }
.file-dl {
padding: 4px 11px; background: #14312a; color: #4ade80;
border-radius: 5px; font-size: 0.72rem; font-weight: 600;
text-decoration: none; flex-shrink: 0;
}
.file-dl:hover { background: #1a4035; }
.empty { color: #2a2f45; font-size: 0.82rem; text-align: center; padding: 40px 0; }
/* ── Archive viewer ── */
.archive-contents {
display: none; margin-top: 8px; padding: 10px;
background: #10121a; border-radius: 6px; border: 1px solid #1e2130;
}
.archive-contents.open { display: block; }
.arc-json { font-size: 0.72rem; color: #8891b0; font-family: monospace; white-space: pre-wrap; word-break: break-all; margin-bottom: 8px; }
.img-grid { display: flex; flex-wrap: wrap; gap: 6px; }
.img-thumb {
width: 110px; height: 72px; object-fit: cover;
border-radius: 4px; border: 1px solid #252836; cursor: pointer;
background: #1e2130;
}
.img-label { font-size: 0.62rem; color: #555d7a; text-align: center; margin-top: 2px; font-family: monospace; }
/* ── Lightbox ── */
#lightbox {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.88); z-index: 999;
align-items: center; justify-content: center; flex-direction: column; gap: 12px;
}
#lightbox.open { display: flex; }
#lb-img { max-width: 92vw; max-height: 85vh; border-radius: 6px; }
#lb-name { font-size: 0.78rem; color: #8891b0; font-family: monospace; }
#lb-close {
position: absolute; top: 16px; right: 20px;
background: none; border: none; color: #8891b0; font-size: 1.4rem;
cursor: pointer; line-height: 1;
}
#lb-close:hover { color: #fff; }
</style>
</head>
<body>
<header>
<div class="dot-live"></div>
<h1>KL630 Golf Event Monitor</h1>
<div id="utc-clock"></div>
</header>
<div class="layout">
<!-- ── Channel A ── -->
<div class="panel">
<div class="panel-head">
<span class="ch-badge ch-a">CH A</span>
<h2>即時事件 → iPad / BLE</h2>
</div>
<div id="evt-card">
<div id="evt-type" class="none">— 無事件 —</div>
<div class="level-track">
<div class="lv-step" id="s1">
<div class="lv-circle">1</div>
<div class="lv-label">T+0s</div>
</div>
<div class="lv-step" id="s2">
<div class="lv-circle">2</div>
<div class="lv-label">T+6s</div>
</div>
<div class="lv-step" id="s3">
<div class="lv-circle">3</div>
<div class="lv-label">T+10s</div>
</div>
<div class="lv-step" id="s0">
<div class="lv-circle"></div>
<div class="lv-label">解除</div>
</div>
</div>
<div id="evt-meta">id: <span id="m-id"></span> &nbsp;|&nbsp; <span id="m-ts"></span></div>
</div>
<div class="log-scroll">
<h3>事件紀錄</h3>
<div id="evt-log"></div>
</div>
<div class="test-bar">
<details>
<summary>測試送出</summary>
<div class="btn-row">
<button class="btn b-l1" onclick="sendEvt('grass',1)">草地 L1</button>
<button class="btn b-l2" onclick="sendEvt('grass',2)">草地 L2</button>
<button class="btn b-l3" onclick="sendEvt('grass',3)">草地 L3</button>
<button class="btn b-l0" onclick="sendEvt('grass',0)">解除</button>
<button class="btn b-hz" onclick="sendEvt('bunker',1)">沙坑</button>
<button class="btn b-hz" onclick="sendEvt('pond',1)">水池</button>
<button class="btn b-hz" onclick="sendEvt('tree',1)">樹木</button>
<button class="btn b-pe" onclick="sendEvt('person',1)">行人</button>
</div>
</details>
</div>
</div>
<!-- ── Channel B ── -->
<div class="panel">
<div class="panel-head">
<span class="ch-badge ch-b">CH B</span>
<h2>壓縮檔上傳 → 奧創雲</h2>
</div>
<div class="file-scroll">
<div class="file-count" id="file-count">尚未收到上傳</div>
<div id="file-list"></div>
</div>
<div class="test-bar">
<details>
<summary>測試送出</summary>
<div class="btn-row">
<button class="btn b-up" onclick="sendFake()">產生假 tar.gz 並上傳</button>
</div>
</details>
</div>
</div>
</div>
<!-- Lightbox -->
<div id="lightbox" onclick="closeLB()">
<button id="lb-close" onclick="closeLB()"></button>
<img id="lb-img" src="" alt="">
<div id="lb-name"></div>
</div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
const TYPE_LABEL = { grass:'草地違規', bunker:'沙坑', pond:'水池', tree:'樹木', person:'行人偵測' };
const SINGLE = ['bunker','pond','tree','person'];
let knownCount = 0, knownFiles = [], eidCounter = Date.now();
// ── Polling ──────────────────────────────────────────────────────────────
async function poll() {
try {
const evts = await fetch('/api/events').then(r=>r.json());
if (evts.length !== knownCount) { knownCount = evts.length; render(evts); }
} catch(e){}
try {
const files = await fetch('/api/files').then(r=>r.json());
const names = files.map(f=>f.name).join();
if (names !== knownFiles.map(f=>f.name).join()) { knownFiles=files; await renderFiles(files); }
} catch(e){}
}
async function pollTime() {
try {
const t = await fetch('/api/time').then(r=>r.json());
document.getElementById('utc-clock').textContent = '台灣時間 ' + t.iso.replace('T',' ').replace('+08:00','');
} catch(e){}
}
// ── Render Channel A ────────────────────────────────────────────────────
function render(evts) {
// update status card from latest event
if (evts.length > 0) updateCard(evts[evts.length-1]);
const log = document.getElementById('evt-log');
log.innerHTML = [...evts].reverse().map(ev => {
const c = ev.content || {};
const ts = (ev.server_time||'').slice(11,19);
const lbl= TYPE_LABEL[c.type] || c.type || '?';
const isSingle = SINGLE.includes(c.type);
const cls = isSingle ? 'single' : `lv${c.level ?? ''}`;
const sub = isSingle ? '單次紀錄' : `Level ${c.level}`;
return `<div class="log-row ${cls}">
<span class="log-time">${ts}</span>
<span class="log-type">${lbl}</span>
<span class="log-sub">— ${sub}</span>
</div>`;
}).join('');
}
function updateCard(ev) {
const c = ev.content || {};
const lbl = TYPE_LABEL[c.type] || c.type || '—';
const isSingle = SINGLE.includes(c.type);
const el = document.getElementById('evt-type');
el.textContent = lbl;
el.className = '';
// reset all steps
['s1','s2','s3','s0'].forEach(id => document.getElementById(id).className = 'lv-step');
if (isSingle) {
document.getElementById('s1').classList.add('single');
} else {
const lv = c.level ?? -1;
if (lv >= 1) document.getElementById('s1').classList.add('warn1');
if (lv >= 2) document.getElementById('s2').classList.add('warn2');
if (lv >= 3) document.getElementById('s3').classList.add('warn3');
if (lv === 0) {
document.getElementById('s0').classList.add('done');
}
}
document.getElementById('m-id').textContent = c.id || '—';
document.getElementById('m-ts').textContent = (c.date||'').replace('T',' ').replace('Z','');
}
// ── Render Channel B ────────────────────────────────────────────────────
let _prevNewest = '';
async function renderFiles(files) {
const cnt = document.getElementById('file-count');
cnt.textContent = files.length ? `${files.length} 個封存檔案` : '尚未收到上傳';
document.getElementById('file-list').innerHTML = files.map(f => `
<div class="file-card" style="flex-direction:column;align-items:stretch">
<div style="display:flex;align-items:center;gap:10px">
<div class="file-icon">📦</div>
<div class="file-info">
<div class="file-name">${f.name}</div>
<div class="file-meta">${f.mtime} &nbsp;·&nbsp; ${(f.size/1024).toFixed(1)} KB</div>
</div>
<button class="file-dl" style="background:#1a3254;color:#60a5fa;border:none;cursor:pointer"
id="btn-${f.name.replace(/[^a-z0-9]/gi,'_')}"
onclick="toggleContents('${f.name}',this)">檢視</button>
<a class="file-dl" href="${f.url}" download>下載</a>
</div>
<div class="archive-contents" id="arc-${f.name.replace(/[^a-z0-9]/gi,'_')}"></div>
</div>`).join('');
/* Auto-expand newest file when a new upload arrives */
if (files.length > 0 && files[0].name !== _prevNewest) {
_prevNewest = files[0].name;
const id = files[0].name.replace(/[^a-z0-9]/gi,'_');
const btn = document.getElementById('btn-' + id);
if (btn) await toggleContents(files[0].name, btn);
}
}
async function toggleContents(name, btn) {
const id = name.replace(/[^a-z0-9]/gi,'_');
const el = document.getElementById('arc-' + id);
if (el.classList.contains('open')) {
el.classList.remove('open'); btn.textContent = '檢視'; return;
}
btn.textContent = '載入中…';
try {
const r = await fetch(`/api/contents/${encodeURIComponent(name)}`);
const d = await r.json();
if (!d.ok) { el.innerHTML = `<div style="color:#f87171;font-size:0.75rem">${d.error}</div>`; }
else {
const jsonFile = d.files.find(f => f.name === 'event.json');
const imgs = d.files.filter(f => f.is_image);
let html = '';
if (jsonFile) {
const jt = await fetch(`/uploads/${encodeURIComponent(name)}/event.json`).then(r=>r.text());
html += `<div class="arc-json">${jt}</div>`;
}
if (imgs.length) {
html += `<div class="img-grid">` + imgs.map(im => `
<div>
<img class="img-thumb" src="/uploads/${encodeURIComponent(name)}/${im.name}"
onclick="openLB(this.src,'${im.name}')" alt="${im.name}">
<div class="img-label">${im.name}</div>
</div>`).join('') + `</div>`;
}
el.innerHTML = html || '<div style="color:#555d7a;font-size:0.75rem">封存檔內無內容</div>';
}
} catch(e) {
el.innerHTML = `<div style="color:#f87171;font-size:0.75rem">${e}</div>`;
}
el.classList.add('open'); btn.textContent = '收起';
}
function openLB(src, name) {
document.getElementById('lb-img').src = src;
document.getElementById('lb-name').textContent = name;
document.getElementById('lightbox').classList.add('open');
}
function closeLB() { document.getElementById('lightbox').classList.remove('open'); }
// ── Test helpers ──────────────────────────────────────────────────────
function nowISO() { return new Date().toISOString().replace(/\.\d+Z$/,'Z'); }
async function sendEvt(type, level) {
await fetch('/api/event', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ response_type:'violation',
content:{ id: String(++eidCounter), date: nowISO(), type, level }})
});
await poll();
}
async function sendFake() {
const id = String(Date.now());
const body = JSON.stringify({ id, date:nowISO(), type:'grass', max_level:3, duration_sec:15,
images:['level1.jpg','level2.jpg','level3.jpg'] }, null, 2);
const ts = nowISO().replace(/[:.TZ]/g,'').slice(0,15);
await fetch('/api/upload', {
method:'POST',
headers:{ 'Content-Type':'application/gzip',
'Content-Disposition':`attachment; filename="event_${id}_${ts}.tar.gz"` },
body: new Blob([body])
});
await poll();
}
// ── Boot ────────────────────────────────────────────────────────────
setInterval(poll, 1000);
setInterval(pollTime, 5000);
poll(); pollTime();
</script>
</body>
</html>