250 lines
9.2 KiB
HTML
250 lines
9.2 KiB
HTML
<!doctype html>
|
||||
|
|
<html lang="zh-Hant">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="utf-8">
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|||
|
|
<title>動態相片管理</title>
|
|||
|
|
<style>
|
|||
|
|
:root { color-scheme: light dark; }
|
|||
|
|
* { box-sizing: border-box; }
|
|||
|
|
body {
|
|||
|
|
margin: 0; padding: 24px; font-family: system-ui, -apple-system, "Noto Sans TC", sans-serif;
|
|||
|
|
background: #f2f4f8; color: #1c2333;
|
|||
|
|
}
|
|||
|
|
@media (prefers-color-scheme: dark) {
|
|||
|
|
body { background: #0f1219; color: #e8ecf5; }
|
|||
|
|
.card { background: #1a1f2b !important; }
|
|||
|
|
th { color: #9aa4bd !important; }
|
|||
|
|
input[type="text"] { background: #0f1219; color: #e8ecf5; border-color: #333c50 !important; }
|
|||
|
|
}
|
|||
|
|
h1 { font-size: 22px; margin: 0 0 4px; }
|
|||
|
|
.sub { color: #6b7590; font-size: 14px; margin: 0 0 24px; }
|
|||
|
|
.card {
|
|||
|
|
background: #fff; border-radius: 12px; padding: 20px; margin-bottom: 20px;
|
|||
|
|
box-shadow: 0 1px 3px rgba(0,0,0,.08); max-width: 860px;
|
|||
|
|
}
|
|||
|
|
.card h2 { font-size: 16px; margin: 0 0 14px; }
|
|||
|
|
form { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; }
|
|||
|
|
label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: #6b7590; }
|
|||
|
|
input[type="text"] { padding: 8px 10px; border: 1px solid #d4dae6; border-radius: 8px; font-size: 14px; width: 180px; }
|
|||
|
|
input[type="file"] { font-size: 13px; max-width: 230px; }
|
|||
|
|
button {
|
|||
|
|
padding: 9px 20px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600;
|
|||
|
|
cursor: pointer; background: #2b6cf5; color: #fff;
|
|||
|
|
}
|
|||
|
|
button:disabled { opacity: .5; cursor: default; }
|
|||
|
|
button.danger { background: transparent; color: #d33; padding: 4px 8px; font-weight: 400; }
|
|||
|
|
button.big { padding: 12px 28px; }
|
|||
|
|
table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
|||
|
|
th { text-align: left; font-size: 12px; color: #6b7590; font-weight: 500; padding: 6px 8px; }
|
|||
|
|
td { padding: 8px; border-top: 1px solid rgba(128,128,128,.15); vertical-align: middle; }
|
|||
|
|
td img { width: 72px; height: 54px; object-fit: cover; border-radius: 6px; display: block; }
|
|||
|
|
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
|||
|
|
.badge.warn { background: #fff3d6; color: #8a6100; }
|
|||
|
|
.badge.ok { background: #d9f2e2; color: #12703c; }
|
|||
|
|
@media (prefers-color-scheme: dark) {
|
|||
|
|
.badge.warn { background: #443a14; color: #ffd45e; }
|
|||
|
|
.badge.ok { background: #10331f; color: #5fd68f; }
|
|||
|
|
}
|
|||
|
|
#msg { font-size: 14px; margin-top: 10px; min-height: 1.4em; }
|
|||
|
|
#msg.err { color: #d33; }
|
|||
|
|
.muted { color: #6b7590; font-size: 13px; }
|
|||
|
|
progress { width: 240px; }
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<h1>動態相片管理</h1>
|
|||
|
|
<p class="sub">照片=key、影片=value。新增或刪除配對後,記得按「重新編譯」產生辨識特徵檔。掃描頁:<a id="viewerLink" href="#">載入中…</a></p>
|
|||
|
|
|
|||
|
|
<div class="card">
|
|||
|
|
<h2>新增配對</h2>
|
|||
|
|
<form id="addForm">
|
|||
|
|
<label>照片(jpg / png)<input type="file" name="photo" accept=".jpg,.jpeg,.png" required></label>
|
|||
|
|
<label>影片(mp4 建議 H.264)<input type="file" name="video" accept=".mp4,.m4v,.mov,.webm" required></label>
|
|||
|
|
<button type="submit" id="addBtn">上傳</button>
|
|||
|
|
</form>
|
|||
|
|
<div id="msg"></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card">
|
|||
|
|
<h2>辨識特徵檔 <span id="compileBadge"></span></h2>
|
|||
|
|
<p class="muted">編譯在你目前的瀏覽器執行(建議用桌機,每張照片約 10~30 秒),完成後自動上傳到伺服器。</p>
|
|||
|
|
<button id="compileBtn" class="big">重新編譯全部照片</button>
|
|||
|
|
<span id="compileStatus" class="muted" style="margin-left:12px"></span>
|
|||
|
|
<div><progress id="compileProgress" max="100" value="0" hidden></progress></div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="card">
|
|||
|
|
<h2>已登錄配對(<span id="count">0</span>)</h2>
|
|||
|
|
<table>
|
|||
|
|
<thead><tr><th>照片</th><th>名稱</th><th>影片</th><th>建立時間</th><th></th></tr></thead>
|
|||
|
|
<tbody id="rows"></tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
let state = null;
|
|||
|
|
|
|||
|
|
async function refresh() {
|
|||
|
|
state = await (await fetch('/api/state')).json();
|
|||
|
|
document.getElementById('count').textContent = state.pairs.length;
|
|||
|
|
|
|||
|
|
const viewerUrl = `https://${location.hostname}:${state.ports.viewer}/`;
|
|||
|
|
const viewerLink = document.getElementById('viewerLink');
|
|||
|
|
viewerLink.href = viewerUrl;
|
|||
|
|
viewerLink.textContent = viewerUrl;
|
|||
|
|
|
|||
|
|
const badge = document.getElementById('compileBadge');
|
|||
|
|
if (state.needsCompile) {
|
|||
|
|
badge.className = 'badge warn';
|
|||
|
|
badge.textContent = '有變更尚未編譯';
|
|||
|
|
} else if (state.targets && state.targets.targets.length) {
|
|||
|
|
badge.className = 'badge ok';
|
|||
|
|
badge.textContent = '已是最新(' + new Date(state.targets.compiledAt).toLocaleString() + ')';
|
|||
|
|
} else {
|
|||
|
|
badge.className = 'badge warn';
|
|||
|
|
badge.textContent = '尚未編譯';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const tbody = document.getElementById('rows');
|
|||
|
|
tbody.textContent = '';
|
|||
|
|
for (const p of state.pairs) {
|
|||
|
|
const tr = document.createElement('tr');
|
|||
|
|
|
|||
|
|
const tdImg = document.createElement('td');
|
|||
|
|
const img = document.createElement('img');
|
|||
|
|
img.src = '/data/photos/' + encodeURIComponent(p.photo);
|
|||
|
|
tdImg.appendChild(img);
|
|||
|
|
|
|||
|
|
const tdName = document.createElement('td');
|
|||
|
|
tdName.textContent = p.name;
|
|||
|
|
|
|||
|
|
const tdVideo = document.createElement('td');
|
|||
|
|
const a = document.createElement('a');
|
|||
|
|
a.href = '/data/videos/' + encodeURIComponent(p.video);
|
|||
|
|
a.target = '_blank';
|
|||
|
|
a.textContent = p.video;
|
|||
|
|
tdVideo.appendChild(a);
|
|||
|
|
|
|||
|
|
const tdTime = document.createElement('td');
|
|||
|
|
tdTime.className = 'muted';
|
|||
|
|
tdTime.textContent = new Date(p.createdAt).toLocaleString();
|
|||
|
|
|
|||
|
|
const tdDel = document.createElement('td');
|
|||
|
|
const del = document.createElement('button');
|
|||
|
|
del.className = 'danger';
|
|||
|
|
del.textContent = '刪除';
|
|||
|
|
del.onclick = async () => {
|
|||
|
|
if (!confirm('確定刪除「' + p.name + '」?照片與影片檔案將一併移除。')) return;
|
|||
|
|
await fetch('/api/pairs/' + p.id, { method: 'DELETE' });
|
|||
|
|
refresh();
|
|||
|
|
};
|
|||
|
|
tdDel.appendChild(del);
|
|||
|
|
|
|||
|
|
tr.append(tdImg, tdName, tdVideo, tdTime, tdDel);
|
|||
|
|
tbody.appendChild(tr);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('addForm').addEventListener('submit', async (e) => {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const btn = document.getElementById('addBtn');
|
|||
|
|
const msg = document.getElementById('msg');
|
|||
|
|
btn.disabled = true;
|
|||
|
|
msg.className = '';
|
|||
|
|
msg.textContent = '上傳中…';
|
|||
|
|
try {
|
|||
|
|
const res = await fetch('/api/pairs', { method: 'POST', body: new FormData(e.target) });
|
|||
|
|
const json = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(json.error || res.statusText);
|
|||
|
|
msg.textContent = '已新增,請記得重新編譯。';
|
|||
|
|
e.target.reset();
|
|||
|
|
refresh();
|
|||
|
|
} catch (err) {
|
|||
|
|
msg.className = 'err';
|
|||
|
|
msg.textContent = '上傳失敗:' + err.message;
|
|||
|
|
} finally {
|
|||
|
|
btn.disabled = false;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function loadImage(url) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const img = new Image();
|
|||
|
|
img.onload = () => resolve(img);
|
|||
|
|
img.onerror = () => reject(new Error('讀取照片失敗:' + url));
|
|||
|
|
img.src = url;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
let compilerLoaded = false;
|
|||
|
|
function loadCompilerScript() {
|
|||
|
|
if (compilerLoaded) return Promise.resolve();
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
const s = document.createElement('script');
|
|||
|
|
s.type = 'module'; // mind-ar 編譯器是 ES module,會自行掛上 window.MINDAR
|
|||
|
|
s.src = '/vendor/mindar-image.prod.js';
|
|||
|
|
s.onload = () => { compilerLoaded = true; resolve(); };
|
|||
|
|
s.onerror = () => reject(new Error('無法載入 MindAR 編譯器'));
|
|||
|
|
document.head.appendChild(s);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
document.getElementById('compileBtn').addEventListener('click', async () => {
|
|||
|
|
const btn = document.getElementById('compileBtn');
|
|||
|
|
const status = document.getElementById('compileStatus');
|
|||
|
|
const bar = document.getElementById('compileProgress');
|
|||
|
|
if (!state || !state.pairs.length) {
|
|||
|
|
status.textContent = '沒有任何配對可編譯。';
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
btn.disabled = true;
|
|||
|
|
bar.hidden = false;
|
|||
|
|
bar.value = 0;
|
|||
|
|
try {
|
|||
|
|
status.textContent = '載入編譯器…';
|
|||
|
|
await loadCompilerScript();
|
|||
|
|
|
|||
|
|
status.textContent = '載入照片…';
|
|||
|
|
const pairs = state.pairs;
|
|||
|
|
const images = await Promise.all(
|
|||
|
|
pairs.map((p) => loadImage('/data/photos/' + encodeURIComponent(p.photo)))
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
status.textContent = '編譯特徵中(勿關閉頁面)…';
|
|||
|
|
const compiler = new window.MINDAR.IMAGE.Compiler();
|
|||
|
|
await compiler.compileImageTargets(images, (progress) => {
|
|||
|
|
bar.value = progress;
|
|||
|
|
status.textContent = '編譯特徵中 ' + progress.toFixed(0) + '%(勿關閉頁面)…';
|
|||
|
|
});
|
|||
|
|
const buffer = await compiler.exportData();
|
|||
|
|
|
|||
|
|
status.textContent = '上傳特徵檔…';
|
|||
|
|
const mapping = {
|
|||
|
|
targets: pairs.map((p, i) => ({
|
|||
|
|
pairId: p.id,
|
|||
|
|
aspect: images[i].naturalHeight / images[i].naturalWidth,
|
|||
|
|
})),
|
|||
|
|
};
|
|||
|
|
const fd = new FormData();
|
|||
|
|
fd.append('mapping', JSON.stringify(mapping));
|
|||
|
|
fd.append('mind', new Blob([buffer]), 'targets.mind');
|
|||
|
|
const res = await fetch('/api/targets', { method: 'POST', body: fd });
|
|||
|
|
const json = await res.json();
|
|||
|
|
if (!res.ok) throw new Error(json.error || res.statusText);
|
|||
|
|
|
|||
|
|
status.textContent = '編譯完成!';
|
|||
|
|
refresh();
|
|||
|
|
} catch (err) {
|
|||
|
|
status.textContent = '編譯失敗:' + err.message;
|
|||
|
|
} finally {
|
|||
|
|
btn.disabled = false;
|
|||
|
|
bar.hidden = true;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
refresh();
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|