208 lines
9.1 KiB
HTML
208 lines
9.1 KiB
HTML
|
|
<!DOCTYPE html>
|
||
|
|
<html lang="zh-CN">
|
||
|
|
<head>
|
||
|
|
<meta charset="UTF-8">
|
||
|
|
<title>顶部控制栏</title>
|
||
|
|
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||
|
|
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
|
||
|
|
<link href="https://cdn.staticfile.org/bootstrap-icons/1.10.0/font/bootstrap-icons.min.css" rel="stylesheet">
|
||
|
|
<style>
|
||
|
|
[v-cloak] { display: none; }
|
||
|
|
|
||
|
|
body {
|
||
|
|
background: #fff;
|
||
|
|
margin: 0; padding: 0;
|
||
|
|
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
/* 容器布局 */
|
||
|
|
.top-bar {
|
||
|
|
height: 95px;
|
||
|
|
display: flex; align-items: center; padding: 0 20px;
|
||
|
|
background: #fff; border-bottom: 1px solid #e9ecef;
|
||
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.03);
|
||
|
|
box-sizing: border-box;
|
||
|
|
}
|
||
|
|
|
||
|
|
.divider { width: 1px; height: 50px; background: #f0f0f0; margin: 0 25px; }
|
||
|
|
|
||
|
|
/* 左侧状态 */
|
||
|
|
.status-group { display: flex; align-items: center; min-width: 160px; }
|
||
|
|
.status-dot { width: 12px; height: 12px; border-radius: 50%; margin-right: 12px; position: relative; }
|
||
|
|
|
||
|
|
.st-run { background: #198754; box-shadow: 0 0 0 4px rgba(25, 135, 84, 0.15); }
|
||
|
|
.st-stop { background: #dc3545; box-shadow: 0 0 0 4px rgba(220, 53, 69, 0.15); }
|
||
|
|
.st-wait { background: #ffc107; box-shadow: 0 0 0 4px rgba(255, 193, 7, 0.15); animation: pulse 1.5s infinite; }
|
||
|
|
|
||
|
|
.status-main { font-size: 1.1rem; font-weight: 700; color: #343a40; line-height: 1.2; }
|
||
|
|
.status-sub { font-size: 0.75rem; color: #adb5bd; margin-top: 2px; }
|
||
|
|
|
||
|
|
/* 信息与数据 */
|
||
|
|
.info-group { display: flex; flex-direction: column; justify-content: center; gap: 4px; font-size: 0.85rem; }
|
||
|
|
.info-row { display: flex; align-items: center; }
|
||
|
|
.info-label { color: #8898aa; width: 45px; font-weight: 500; }
|
||
|
|
.info-val { color: #495057; font-family: 'Segoe UI Semibold', sans-serif; }
|
||
|
|
|
||
|
|
.stat-group { display: flex; gap: 20px; }
|
||
|
|
.stat-item { text-align: center; min-width: 70px; }
|
||
|
|
.stat-label { font-size: 0.7rem; color: #adb5bd; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 2px; }
|
||
|
|
.stat-value { font-size: 1.1rem; color: #212529; font-weight: 600; font-family: Consolas, monospace; }
|
||
|
|
|
||
|
|
/* 按钮风格 */
|
||
|
|
.tool-btn {
|
||
|
|
background: linear-gradient(to bottom, #ffffff, #f1f3f5);
|
||
|
|
border: 1px solid #dee2e6; color: #495057; padding: 0;
|
||
|
|
font-size: 0.8rem; font-weight: 600; cursor: pointer;
|
||
|
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||
|
|
width: 68px; height: 68px; margin-left: 10px; border-radius: 6px; transition: all 0.1s;
|
||
|
|
}
|
||
|
|
.tool-btn:hover { background: linear-gradient(to bottom, #fff, #e2e6ea); border-color: #ced4da; color: #212529; }
|
||
|
|
.tool-btn:active { background: #e9ecef; box-shadow: inset 0 2px 5px rgba(0,0,0,0.05); transform: translateY(1px); }
|
||
|
|
.tool-btn i { font-size: 1.4rem; margin-bottom: 5px; color: #6c757d; }
|
||
|
|
|
||
|
|
.btn-stop-mode i { color: #dc3545; }
|
||
|
|
.btn-play-mode i { color: #198754; }
|
||
|
|
|
||
|
|
@keyframes pulse { 0% { opacity: 0.6; } 50% { opacity: 1; } 100% { opacity: 0.6; } }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<div id="app" v-cloak>
|
||
|
|
<div class="top-bar" v-if="cam">
|
||
|
|
|
||
|
|
<div class="status-group">
|
||
|
|
<div class="status-dot" :class="statusStyle.cls"></div>
|
||
|
|
<div>
|
||
|
|
<div class="status-main">{{ statusStyle.text }}</div>
|
||
|
|
<div class="status-sub">ID: {{ cam.id }}</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="divider"></div>
|
||
|
|
|
||
|
|
<div class="info-group">
|
||
|
|
<div class="info-row">
|
||
|
|
<span class="info-label">名称</span>
|
||
|
|
<span class="info-val" :title="cam.name">{{ truncate(cam.name) }}</span>
|
||
|
|
</div>
|
||
|
|
<div class="info-row">
|
||
|
|
<span class="info-label">地址</span>
|
||
|
|
<span class="info-val">{{ cam.ipAddress }}</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="divider"></div>
|
||
|
|
|
||
|
|
<div class="stat-group me-auto">
|
||
|
|
<div class="stat-item">
|
||
|
|
<div class="stat-label">分辨率</div>
|
||
|
|
<div class="stat-value">{{ cam.width }}<small class="text-muted" style="font-size:0.7em">x{{cam.height}}</small></div>
|
||
|
|
</div>
|
||
|
|
<div class="stat-item">
|
||
|
|
<div class="stat-label">实时帧率</div>
|
||
|
|
<div class="stat-value">{{ cam.realFps }} <small style="font-size:0.6em">FPS</small></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="d-flex">
|
||
|
|
<button class="tool-btn" :class="cam.status === 'Playing' ? 'btn-stop-mode' : 'btn-play-mode'" @click="togglePower" :disabled="loading">
|
||
|
|
<span v-if="loading" class="spinner-border spinner-border-sm mb-1 text-secondary"></span>
|
||
|
|
<i v-else :class="cam.status === 'Playing' ? 'bi-power' : 'bi-play-circle-fill'"></i>
|
||
|
|
<span>{{ btnText }}</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button class="tool-btn" @click="openEdit" title="修改IP、端口等基础信息">
|
||
|
|
<i class="bi-pencil-square"></i>
|
||
|
|
<span>编辑</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button class="tool-btn" @click="openControl" title="云台、校时、重启">
|
||
|
|
<i class="bi-dpad-fill"></i>
|
||
|
|
<span>控制</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button class="tool-btn" @click="openPre" title="图像预处理">
|
||
|
|
<i class="bi-sliders2"></i>
|
||
|
|
<span>预处理</span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button class="tool-btn" @click="openSub" title="流订阅分发">
|
||
|
|
<i class="bi-diagram-3"></i>
|
||
|
|
<span>订阅</span>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div v-else class="top-bar justify-content-center text-muted">
|
||
|
|
<i class="bi bi-hand-index-thumb me-2"></i> 请从左侧列表选择设备进行操作
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||
|
|
<script>
|
||
|
|
const { createApp, ref, computed, onMounted, onUnmounted } = Vue;
|
||
|
|
const API_BASE = "http://localhost:5000";
|
||
|
|
|
||
|
|
createApp({
|
||
|
|
setup() {
|
||
|
|
const cam = ref(null);
|
||
|
|
const loading = ref(false);
|
||
|
|
let pollTimer = null;
|
||
|
|
|
||
|
|
const statusStyle = computed(() => {
|
||
|
|
if(!cam.value) return {};
|
||
|
|
const s = cam.value.status;
|
||
|
|
if(s === 'Playing') return { text: '运行中', cls: 'st-run' };
|
||
|
|
if(s === 'Connecting') return { text: '启动中...', cls: 'st-wait' };
|
||
|
|
return { text: '已停止', cls: 'st-stop' };
|
||
|
|
});
|
||
|
|
|
||
|
|
const btnText = computed(() => {
|
||
|
|
if(loading.value) return '请稍候';
|
||
|
|
if(cam.value?.status === 'Playing') return '停止';
|
||
|
|
return '启动';
|
||
|
|
});
|
||
|
|
|
||
|
|
const truncate = (str) => (!str ? '-' : (str.length > 10 ? str.substring(0,10)+'..' : str));
|
||
|
|
|
||
|
|
// 状态轮询
|
||
|
|
const refreshStatus = async () => {
|
||
|
|
if (!cam.value) return;
|
||
|
|
try {
|
||
|
|
const res = await axios.get(`${API_BASE}/api/Monitor/${cam.value.id}`);
|
||
|
|
if (res.data) Object.assign(cam.value, res.data);
|
||
|
|
} catch (e) { console.warn("状态轮询失败", e); }
|
||
|
|
};
|
||
|
|
|
||
|
|
const togglePower = () => {
|
||
|
|
if (!cam.value || loading.value) return;
|
||
|
|
const isStart = cam.value.status !== 'Playing';
|
||
|
|
cam.value.status = isStart ? 'Connecting' : 'Stopped';
|
||
|
|
loading.value = true;
|
||
|
|
window.parent.postMessage({ type: 'DEVICE_CONTROL', action: isStart ? 'start' : 'stop', deviceId: cam.value.id }, '*');
|
||
|
|
setTimeout(() => { loading.value = false; }, 2000);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 消息发送
|
||
|
|
const openSub = () => window.parent.postMessage({ type: 'OPEN_SUBSCRIPTION', id: cam.value.id }, '*');
|
||
|
|
const openPre = () => window.parent.postMessage({ type: 'OPEN_PREPROCESS', id: cam.value.id }, '*');
|
||
|
|
|
||
|
|
// 两个不同的弹窗入口
|
||
|
|
const openEdit = () => window.parent.postMessage({ type: 'OPEN_CAMERA_EDIT', id: cam.value.id }, '*');
|
||
|
|
const openControl = () => window.parent.postMessage({ type: 'OPEN_CAMERA_CONTROL', id: cam.value.id }, '*');
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
window.addEventListener('message', (e) => {
|
||
|
|
if (e.data.type === 'UPDATE_TOP_INFO') cam.value = e.data.data;
|
||
|
|
});
|
||
|
|
pollTimer = setInterval(() => { if(cam.value) refreshStatus(); }, 2000);
|
||
|
|
});
|
||
|
|
|
||
|
|
onUnmounted(() => { if (pollTimer) clearInterval(pollTimer); });
|
||
|
|
|
||
|
|
return { cam, statusStyle, btnText, loading, truncate, togglePower, openSub, openPre, openEdit, openControl };
|
||
|
|
}
|
||
|
|
}).mount('#app');
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|