first commit
This commit is contained in:
142
app/static/app.js
Normal file
142
app/static/app.js
Normal file
@@ -0,0 +1,142 @@
|
||||
const connection = document.querySelector("#connection");
|
||||
const detectionsEl = document.querySelector("#detections");
|
||||
const frameIdEl = document.querySelector("#frame-id");
|
||||
const fpsEl = document.querySelector("#fps");
|
||||
const errorEl = document.querySelector("#error");
|
||||
const sourceEl = document.querySelector("#source");
|
||||
const deviceSelect = document.querySelector("#device-select");
|
||||
const timingTokenEl = document.querySelector("#timing-token");
|
||||
const timingSignEl = document.querySelector("#timing-sign");
|
||||
const timingUrlEl = document.querySelector("#timing-url");
|
||||
const timingOpenEl = document.querySelector("#timing-open");
|
||||
const timingFrameEl = document.querySelector("#timing-frame");
|
||||
|
||||
let selectedDevice = "";
|
||||
let pendingDevice = "";
|
||||
let devicesSignature = "";
|
||||
|
||||
function setConnection(online, text) {
|
||||
connection.textContent = text;
|
||||
connection.classList.toggle("online", online);
|
||||
connection.classList.toggle("offline", !online);
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
if (value === undefined || value === null || value === 0) {
|
||||
return "-";
|
||||
}
|
||||
return `${Number(value).toFixed(2)} ms`;
|
||||
}
|
||||
|
||||
function renderDevices(devices, currentDeviceNum) {
|
||||
if (!devices.length) {
|
||||
deviceSelect.innerHTML = '<option value="">未配置摄像头</option>';
|
||||
deviceSelect.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const displayDevice = pendingDevice || currentDeviceNum;
|
||||
const nextSignature = `${displayDevice}|${devices.map((device) => device.device_num).join(",")}`;
|
||||
if (nextSignature === devicesSignature) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedDevice = displayDevice;
|
||||
devicesSignature = nextSignature;
|
||||
deviceSelect.innerHTML = devices
|
||||
.map((device) => {
|
||||
const selected = device.device_num === displayDevice ? "selected" : "";
|
||||
return `<option value="${device.device_num}" ${selected}>${device.name} · ${device.device_num}</option>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderTimings(timings) {
|
||||
timingTokenEl.textContent = timings?.token_cache ? "缓存" : formatMs(timings?.token_ms);
|
||||
timingSignEl.textContent = formatMs(timings?.sign_ms);
|
||||
timingUrlEl.textContent = formatMs(timings?.stream_url_ms);
|
||||
timingOpenEl.textContent = formatMs(timings?.open_ms);
|
||||
timingFrameEl.textContent = formatMs(timings?.first_frame_ms);
|
||||
}
|
||||
|
||||
function renderDetections(detections) {
|
||||
if (!detections.length) {
|
||||
detectionsEl.className = "detections empty";
|
||||
detectionsEl.textContent = "暂无目标";
|
||||
return;
|
||||
}
|
||||
|
||||
detectionsEl.className = "detections";
|
||||
detectionsEl.innerHTML = detections
|
||||
.map((det) => {
|
||||
const score = `${(det.score * 100).toFixed(1)}%`;
|
||||
const box = det.box.join(", ");
|
||||
return `
|
||||
<div class="det-item">
|
||||
<div class="det-title">
|
||||
<span>${det.label}</span>
|
||||
<span>${score}</span>
|
||||
</div>
|
||||
<div class="det-box">box: [${box}]</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function switchDevice(deviceNum) {
|
||||
pendingDevice = deviceNum;
|
||||
devicesSignature = "";
|
||||
setConnection(false, "切换中");
|
||||
const response = await fetch(`/devices/${encodeURIComponent(deviceNum)}`, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw new Error("切换摄像头失败");
|
||||
}
|
||||
const video = document.querySelector("#video");
|
||||
video.src = `/video?t=${Date.now()}`;
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/detections`);
|
||||
|
||||
ws.addEventListener("open", () => setConnection(true, "已连接"));
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
frameIdEl.textContent = data.frame_id ?? "-";
|
||||
fpsEl.textContent = data.fps ?? "-";
|
||||
errorEl.textContent = data.error || (data.connected ? "正常" : "未连接");
|
||||
sourceEl.textContent = data.source || "-";
|
||||
setConnection(Boolean(data.connected), data.connected ? "已连接" : "重连中");
|
||||
if (pendingDevice && data.current_device_num === pendingDevice) {
|
||||
pendingDevice = "";
|
||||
deviceSelect.disabled = false;
|
||||
}
|
||||
renderDevices(data.devices || [], data.current_device_num || "");
|
||||
renderTimings(data.timings || {});
|
||||
renderDetections(data.detections || []);
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
setConnection(false, "已断开");
|
||||
setTimeout(connectWebSocket, 1500);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
setConnection(false, "连接错误");
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
deviceSelect.addEventListener("change", (event) => {
|
||||
selectedDevice = event.target.value;
|
||||
switchDevice(event.target.value).catch(() => {
|
||||
pendingDevice = "";
|
||||
devicesSignature = "";
|
||||
setConnection(false, "切换失败");
|
||||
deviceSelect.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
connectWebSocket();
|
||||
209
app/static/style.css
Normal file
209
app/static/style.css
Normal file
@@ -0,0 +1,209 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0c1017;
|
||||
--panel: #151b26;
|
||||
--panel-2: #101722;
|
||||
--text: #eef4ff;
|
||||
--muted: #8f9db3;
|
||||
--line: #273246;
|
||||
--green: #2ee887;
|
||||
--yellow: #f7c948;
|
||||
--red: #ff6b6b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top left, #182235, var(--bg) 45%);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 22px 28px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(12, 16, 23, 0.82);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 14px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
min-width: 86px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
color: var(--yellow);
|
||||
text-align: center;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.badge.online {
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.badge.offline {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.video-card,
|
||||
.side-card {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
background: rgba(21, 27, 38, 0.9);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.video-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pipeline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.stage {
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.stage.active {
|
||||
border-color: rgba(46, 232, 135, 0.5);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.video-wrap {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 420px;
|
||||
background: #05070b;
|
||||
}
|
||||
|
||||
#video {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: calc(100vh - 190px);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.side-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.status-grid dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-grid dd {
|
||||
margin: 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.detections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detections.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.det-item {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
background: var(--panel-2);
|
||||
}
|
||||
|
||||
.det-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.det-box {
|
||||
color: var(--muted);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user