支持通过网页增加、删除、修改摄像头配置信息
支持摄像头配置信息中句柄的设置,并实测有效
This commit is contained in:
147
SHH.CameraSdk/Htmls/CameraControl.html
Normal file
147
SHH.CameraSdk/Htmls/CameraControl.html
Normal file
@@ -0,0 +1,147 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>设备控制台</title>
|
||||
<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", sans-serif; overflow: hidden; display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.modal-header-custom { padding: 12px 20px; background: #f8f9fa; border-bottom: 1px solid #dee2e6; display: flex; justify-content: space-between; align-items: center; }
|
||||
.content-body { flex: 1; padding: 20px; overflow-y: auto; display: flex; gap: 20px; }
|
||||
|
||||
/* 左侧:云台控制区 */
|
||||
.ptz-section { flex: 0 0 240px; display: flex; flex-direction: column; align-items: center; border-right: 1px solid #eee; padding-right: 20px; }
|
||||
|
||||
.d-pad { position: relative; width: 180px; height: 180px; background: #f1f3f5; border-radius: 50%; margin-bottom: 20px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.05); }
|
||||
.d-btn { position: absolute; width: 50px; height: 50px; border: none; background: #fff; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); color: #495057; transition: 0.1s; display: flex; align-items: center; justify-content: center; cursor: pointer; }
|
||||
.d-btn:active { background: #e9ecef; transform: scale(0.95); box-shadow: inset 0 2px 4px rgba(0,0,0,0.1); color: #0d6efd; }
|
||||
.d-btn i { font-size: 1.5rem; }
|
||||
|
||||
.btn-up { top: 10px; left: 65px; }
|
||||
.btn-down { bottom: 10px; left: 65px; }
|
||||
.btn-left { top: 65px; left: 10px; }
|
||||
.btn-right { top: 65px; right: 10px; }
|
||||
.btn-center { top: 65px; left: 65px; border-radius: 50%; background: #e7f1ff; color: #0d6efd; font-weight: bold; font-size: 0.8rem; }
|
||||
|
||||
.zoom-ctrl { display: flex; width: 100%; gap: 10px; justify-content: center; }
|
||||
.zoom-btn { flex: 1; padding: 8px; border: 1px solid #dee2e6; background: #fff; border-radius: 6px; font-size: 0.9rem; font-weight: 600; color: #555; }
|
||||
.zoom-btn:active { background: #f8f9fa; border-color: #adb5bd; }
|
||||
|
||||
/* 右侧:系统维护区 */
|
||||
.sys-section { flex: 1; display: flex; flex-direction: column; gap: 15px; }
|
||||
.func-card { border: 1px solid #e9ecef; border-radius: 8px; padding: 15px; background: #fff; transition: 0.2s; }
|
||||
.func-card:hover { border-color: #dee2e6; box-shadow: 0 2px 8px rgba(0,0,0,0.02); }
|
||||
.func-title { font-size: 0.9rem; font-weight: 700; margin-bottom: 10px; color: #343a40; display: flex; align-items: center; }
|
||||
.time-box { background: #f8f9fa; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 1.1rem; letter-spacing: 1px; color: #0d6efd; text-align: center; border: 1px solid #e9ecef; margin-bottom: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<div class="modal-header-custom">
|
||||
<h6 class="m-0 fw-bold"><i class="bi bi-joystick me-2 text-primary"></i>设备控制台</h6>
|
||||
<div class="text-muted small">ID: {{ deviceId }}</div>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
<div class="ptz-section">
|
||||
<h6 class="text-muted small mb-3 fw-bold">云台控制 (PTZ)</h6>
|
||||
<div class="d-pad">
|
||||
<button class="d-btn btn-up" @mousedown="ptz('up')" @mouseup="ptzStop"><i class="bi bi-caret-up-fill"></i></button>
|
||||
<button class="d-btn btn-left" @mousedown="ptz('left')" @mouseup="ptzStop"><i class="bi bi-caret-left-fill"></i></button>
|
||||
<button class="d-btn btn-center" @click="ptz('home')" title="回原点">HOME</button>
|
||||
<button class="d-btn btn-right" @mousedown="ptz('right')" @mouseup="ptzStop"><i class="bi bi-caret-right-fill"></i></button>
|
||||
<button class="d-btn btn-down" @mousedown="ptz('down')" @mouseup="ptzStop"><i class="bi bi-caret-down-fill"></i></button>
|
||||
</div>
|
||||
<div class="zoom-ctrl">
|
||||
<button class="zoom-btn" @mousedown="ptz('zoomIn')" @mouseup="ptzStop"><i class="bi bi-zoom-in me-1"></i>放大</button>
|
||||
<button class="zoom-btn" @mousedown="ptz('zoomOut')" @mouseup="ptzStop"><i class="bi bi-zoom-out me-1"></i>缩小</button>
|
||||
</div>
|
||||
<div class="mt-2 text-muted small" style="font-size: 0.75rem;"><i class="bi bi-info-circle me-1"></i>长按移动,松开停止</div>
|
||||
</div>
|
||||
|
||||
<div class="sys-section">
|
||||
<div class="func-card">
|
||||
<div class="func-title"><i class="bi bi-clock-history me-2 text-success"></i>时间同步</div>
|
||||
<div class="text-muted small mb-2">设备当前时间:</div>
|
||||
<div class="time-box">{{ deviceTime || '--:--:--' }}</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary flex-fill" @click="getDeviceTime"><i class="bi bi-arrow-clockwise me-1"></i>刷新</button>
|
||||
<button class="btn btn-sm btn-success flex-fill" @click="syncTime" :disabled="syncing">
|
||||
<span v-if="syncing" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-check2-circle me-1"></i>同步本机
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="func-card">
|
||||
<div class="func-title"><i class="bi bi-tools me-2 text-warning"></i>系统维护</div>
|
||||
<button class="btn btn-sm btn-light border w-100 text-start" @click="reboot"><i class="bi bi-bootstrap-reboot me-2"></i>重启设备</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, onMounted } = Vue;
|
||||
let API_BASE = "";
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const deviceId = ref(0);
|
||||
const deviceTime = ref("");
|
||||
const syncing = ref(false);
|
||||
|
||||
// PTZ控制
|
||||
const ptz = async (action) => {
|
||||
log(`PTZ: ${action}`);
|
||||
try { await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/ptz?action=${action}&speed=5`); } catch(e) {}
|
||||
};
|
||||
const ptzStop = async () => {
|
||||
try { await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/ptz?action=stop`); } catch(e) {}
|
||||
};
|
||||
|
||||
// 校时逻辑
|
||||
const getDeviceTime = async () => {
|
||||
const now = new Date(); now.setMinutes(now.getMinutes() - 5); // 模拟
|
||||
deviceTime.value = now.toLocaleTimeString();
|
||||
};
|
||||
const syncTime = async () => {
|
||||
syncing.value = true;
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/sync-time`, { time: new Date().toISOString() });
|
||||
alert("指令已下发");
|
||||
deviceTime.value = new Date().toLocaleTimeString();
|
||||
} catch(e) { alert("失败: " + e.message); }
|
||||
finally { syncing.value = false; }
|
||||
};
|
||||
|
||||
const reboot = async () => {
|
||||
if(confirm("确定要重启设备吗?")) {
|
||||
try { await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/reboot`); alert("重启中..."); } catch(e){}
|
||||
}
|
||||
};
|
||||
|
||||
const log = (msg) => window.parent.postMessage({ type: 'API_LOG', log: { method: 'PTZ', url: msg, status: 200, msg: 'Sent' } }, '*');
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'LOAD_CTRL_DATA') {
|
||||
if(e.data.apiBase) API_BASE = e.data.apiBase;
|
||||
deviceId.value = e.data.deviceId;
|
||||
getDeviceTime();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { deviceId, deviceTime, syncing, ptz, ptzStop, getDeviceTime, syncTime, reboot };
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
422
SHH.CameraSdk/Htmls/CameraEdit.html
Normal file
422
SHH.CameraSdk/Htmls/CameraEdit.html
Normal file
@@ -0,0 +1,422 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>设备配置</title>
|
||||
<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; }
|
||||
|
||||
html, body {
|
||||
height: 100%; margin: 0; padding: 0;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden; background: #fff;
|
||||
}
|
||||
|
||||
#app { height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
/* 头部 */
|
||||
.page-header {
|
||||
padding: 15px 30px; border-bottom: 1px solid #e9ecef; background: #fff;
|
||||
display: flex; justify-content: space-between; align-items: center; flex-shrink: 0;
|
||||
}
|
||||
.page-title { font-size: 1.2rem; font-weight: 700; color: #343a40; display: flex; align-items: center; }
|
||||
|
||||
/* 内容区 */
|
||||
.content-body {
|
||||
flex: 1; overflow-y: auto; padding: 30px 40px;
|
||||
max-width: 1200px; margin: 0 auto; width: 100%;
|
||||
}
|
||||
.content-body::-webkit-scrollbar { width: 8px; }
|
||||
.content-body::-webkit-scrollbar-track { background: #f8f9fa; }
|
||||
.content-body::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 4px; }
|
||||
|
||||
/* 表单控件 */
|
||||
.form-label { font-weight: 600; color: #495057; margin-bottom: 6px; font-size: 0.85rem; }
|
||||
.form-control, .form-select { padding: 9px 12px; border-radius: 4px; border-color: #dee2e6; font-size: 0.9rem; }
|
||||
.form-control:focus { border-color: #86b7fe; box-shadow: 0 0 0 3px rgba(13,110,253,0.1); }
|
||||
.form-control[readonly] { background-color: #f8f9fa; color: #6c757d; cursor: not-allowed; }
|
||||
|
||||
/* 亮色标题 */
|
||||
.section-header {
|
||||
display: flex; align-items: center; margin-top: 25px; margin-bottom: 15px;
|
||||
padding-bottom: 8px; border-bottom: 2px solid #e7f1ff;
|
||||
}
|
||||
.section-header h6 { margin: 0; font-weight: 800; color: #0d6efd; font-size: 0.95rem; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
/* 板卡区域 */
|
||||
.board-card {
|
||||
background-color: #fcfcfc; border: 1px solid #e9ecef;
|
||||
border-left: 4px solid #0d6efd; border-radius: 6px;
|
||||
padding: 15px 20px; margin-top: 10px;
|
||||
}
|
||||
|
||||
/* 底部按钮栏 - 修改为两端对齐 */
|
||||
.footer-bar {
|
||||
padding: 15px 40px; border-top: 1px solid #e9ecef; background: #fff;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<i class="bi me-2 text-primary" :class="mode === 'add' ? 'bi-plus-circle-fill' : 'bi-pencil-square'"></i>
|
||||
{{ pageTitle }}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-light border" @click="closeMode" title="关闭"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
<div v-if="loadingData" class="text-center py-5 text-muted">
|
||||
<span class="spinner-border text-primary"></span>
|
||||
<p class="mt-3">正在读取配置...</p>
|
||||
</div>
|
||||
|
||||
<form v-else class="row g-3">
|
||||
|
||||
<div class="col-12">
|
||||
<div class="section-header" style="margin-top:0;"><h6><i class="bi bi-person-badge me-2"></i>基础信息</h6></div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">设备名称 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" v-model="form.name" placeholder="例如:北门入口监控" required>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">品牌/协议</label>
|
||||
<select class="form-select" v-model.number="form.brand" @change="onBrandChange">
|
||||
<option value="1">海康威视 (Hikvision)</option>
|
||||
<option value="2">大华 (Dahua)</option>
|
||||
<option value="4">标准 RTSP 流</option>
|
||||
<option value="7">ONVIF</option>
|
||||
<option value="3">USB 摄像头</option>
|
||||
<option value="0">未知</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">默认码流</label>
|
||||
<select class="form-select" v-model.number="form.streamType">
|
||||
<option value="0">主码流 (Main)</option>
|
||||
<option value="1">子码流 (Sub)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">设备 ID <span class="text-danger">*</span></label>
|
||||
<input type="number" class="form-control fw-bold font-monospace"
|
||||
v-model.number="form.id"
|
||||
:readonly="mode === 'edit'"
|
||||
min="1" placeholder="正整数">
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<label class="form-label">安装位置</label>
|
||||
<input type="text" class="form-control" v-model="form.location" placeholder="例如:一号厂房东门">
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="section-header"><h6><i class="bi bi-hdd-network me-2"></i>网络与连接 (Network)</h6></div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 row g-3 m-0 p-0" v-if="form.brand !== 4">
|
||||
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">IP 地址 <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control font-monospace" v-model="form.ipAddress" @input="tryAutoGenerateRtsp">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">端口</label>
|
||||
<input type="number" class="form-control font-monospace" v-model.number="form.port">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">传输协议</label>
|
||||
<select class="form-select font-monospace" v-model="form.transport">
|
||||
<option value="Tcp">TCP (推荐)</option>
|
||||
<option value="Udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">超时 (ms)</label>
|
||||
<input type="number" class="form-control font-monospace" v-model.number="form.connectionTimeoutMs" step="1000">
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">登录用户名</label>
|
||||
<input type="text" class="form-control" v-model="form.username" placeholder="admin" @input="tryAutoGenerateRtsp">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">登录密码</label>
|
||||
<input type="password" class="form-control" v-model="form.password" placeholder="••••••" @input="tryAutoGenerateRtsp">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">通道号</label>
|
||||
<input type="number" class="form-control" v-model.number="form.channelIndex" min="1" @input="tryAutoGenerateRtsp">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">渲染句柄</label>
|
||||
<input type="number" class="form-control font-monospace text-muted"
|
||||
v-model.number="form.renderHandle"
|
||||
:disabled="!isSdkBrand"
|
||||
placeholder="0 (自动)">
|
||||
</div>
|
||||
|
||||
<div class="col-12" v-if="isSdkBrand">
|
||||
<div class="board-card">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="enableBoard" v-model="enableBoard">
|
||||
<label class="form-check-label fw-bold text-dark" for="enableBoard">
|
||||
关联主板 (Mainboard Linkage)
|
||||
</label>
|
||||
</div>
|
||||
<div class="row g-3" v-if="enableBoard">
|
||||
<div class="col-md-8">
|
||||
<label class="form-label text-muted small">板卡 IP 地址</label>
|
||||
<input type="text" class="form-control form-control-sm font-monospace"
|
||||
v-model="form.mainboardIp" placeholder="例如:192.168.1.200">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label text-muted small">板卡端口</label>
|
||||
<input type="number" class="form-control form-control-sm font-monospace"
|
||||
v-model.number="form.mainboardPort" placeholder="80">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="section-header mt-4">
|
||||
<h6><i class="bi bi-broadcast me-2"></i>RTSP 流配置</h6>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label">RTSP 地址 (RtspPath)</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light font-monospace small">URL</span>
|
||||
<input type="text" class="form-control font-monospace text-primary" v-model="form.rtspPath"
|
||||
placeholder="rtsp://admin:123456@192.168.1.64:554/...">
|
||||
|
||||
<button class="btn btn-outline-secondary" type="button"
|
||||
v-if="isSdkBrand" @click="forceGenerateRtsp"
|
||||
title="强制重新生成">
|
||||
<i class="bi bi-magic"></i> 自动生成
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-text text-muted small mt-1" v-if="form.brand !== 4">
|
||||
<i class="bi bi-info-circle me-1"></i>SDK 模式下此为备用地址。如果字段为空,系统将自动生成标准路径。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 40px;"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<div>
|
||||
<button v-if="mode === 'edit'" class="btn btn-outline-danger" @click="removeDevice" :disabled="submitting">
|
||||
<i class="bi bi-trash3 me-1"></i> 删除设备
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-light border px-4" @click="closeMode">取消</button>
|
||||
<button class="btn btn-primary px-4" @click="save" :disabled="submitting">
|
||||
<span v-if="submitting" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi me-1" :class="mode === 'add' ? 'bi-check-lg' : 'bi-floppy2-fill'"></i>
|
||||
{{ mode === 'add' ? '确认添加' : '保存更改' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||
let API_BASE = "";
|
||||
|
||||
const BrandMap = {
|
||||
'HikVision': 1, 'Hikvision': 1, 'Dahua': 2, 'Usb': 3,
|
||||
'RtspGeneral': 4, 'Rtsp': 4, 'OnvifGeneral': 7, 'Onvif': 7, 'Unknown': 0
|
||||
};
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const mode = ref('add'); // add | edit
|
||||
const deviceId = ref(0);
|
||||
const loadingData = ref(false);
|
||||
const submitting = ref(false);
|
||||
const enableBoard = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
id: null, name: '', brand: 1, location: '',
|
||||
ipAddress: '', port: 8000, username: '', password: '',
|
||||
channelIndex: 1, rtspPath: '', streamType: 0,
|
||||
renderHandle: 0, transport: 'Tcp', connectionTimeoutMs: 5000,
|
||||
mainboardIp: '', mainboardPort: 80
|
||||
});
|
||||
|
||||
const pageTitle = computed(() => mode.value === 'add' ? '添加新设备' : '编辑设备配置');
|
||||
const isSdkBrand = computed(() => form.brand === 1 || form.brand === 2);
|
||||
|
||||
const logToParent = (method, url, status, msg) => window.parent.postMessage({ type: 'API_LOG', log: { method, url, status, msg } }, '*');
|
||||
|
||||
const generateRtspUrl = () => {
|
||||
if (!isSdkBrand.value) return "";
|
||||
const ip = form.ipAddress || "0.0.0.0";
|
||||
const user = form.username || "admin";
|
||||
const pass = form.password || "123456";
|
||||
const ch = form.channelIndex || 1;
|
||||
if (form.brand === 1) {
|
||||
const stream = form.streamType === 0 ? "01" : "02";
|
||||
return `rtsp://${user}:${pass}@${ip}:554/Streaming/Channels/${ch}${stream}`;
|
||||
} else if (form.brand === 2) {
|
||||
const subtype = form.streamType;
|
||||
return `rtsp://${user}:${pass}@${ip}:554/cam/realmonitor?channel=${ch}&subtype=${subtype}`;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const tryAutoGenerateRtsp = () => {
|
||||
if (isSdkBrand.value && !form.rtspPath) form.rtspPath = generateRtspUrl();
|
||||
};
|
||||
const forceGenerateRtsp = () => { form.rtspPath = generateRtspUrl(); };
|
||||
|
||||
const onBrandChange = () => {
|
||||
if (!isSdkBrand.value) enableBoard.value = false;
|
||||
if (form.brand === 1) form.port = 8000;
|
||||
else if (form.brand === 2) form.port = 37777;
|
||||
else if (form.brand === 7) form.port = 80;
|
||||
};
|
||||
|
||||
const initAdd = () => {
|
||||
mode.value = 'add';
|
||||
loadingData.value = false;
|
||||
Object.assign(form, {
|
||||
id: Math.floor(Math.random() * 9000) + 1000,
|
||||
name: "NewCamera", brand: 1, location: '',
|
||||
ipAddress: '192.168.1.64', port: 8000,
|
||||
username: 'admin', password: '',
|
||||
channelIndex: 1, rtspPath: '', streamType: 0,
|
||||
renderHandle: 0, transport: 'Tcp', connectionTimeoutMs: 5000,
|
||||
mainboardIp: '', mainboardPort: 80
|
||||
});
|
||||
enableBoard.value = false;
|
||||
};
|
||||
|
||||
const loadData = async (id) => {
|
||||
mode.value = 'edit';
|
||||
deviceId.value = id;
|
||||
loadingData.value = true;
|
||||
const url = `${API_BASE}/api/Cameras/${id}`;
|
||||
try {
|
||||
logToParent('GET', url, 'PENDING', 'Reading config...');
|
||||
const res = await axios.get(url);
|
||||
if (res.data) {
|
||||
const d = res.data;
|
||||
Object.assign(form, {
|
||||
id: d.id, name: d.name || '',
|
||||
brand: (typeof d.brand === 'string') ? (BrandMap[d.brand] || 0) : d.brand,
|
||||
location: d.location || '', ipAddress: d.ipAddress, port: d.port,
|
||||
username: d.username || d.account || '', password: d.password || '',
|
||||
channelIndex: d.channelIndex || 1, rtspPath: d.rtspPath || d.rtspUrl || '',
|
||||
streamType: d.streamType, renderHandle: d.renderHandle || 0,
|
||||
transport: d.transport || 'Tcp', connectionTimeoutMs: d.connectionTimeoutMs || 5000,
|
||||
mainboardIp: d.mainboardIp || '', mainboardPort: d.mainboardPort || 80
|
||||
});
|
||||
enableBoard.value = !!form.mainboardIp;
|
||||
logToParent('GET', url, 200, 'OK');
|
||||
}
|
||||
} catch (e) { alert("加载失败: " + e.message); }
|
||||
finally { loadingData.value = false; }
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
submitting.value = true;
|
||||
if (form.id <= 0) { alert("ID 必须为正整数"); submitting.value = false; return; }
|
||||
if (!enableBoard.value) { form.mainboardIp = ""; form.mainboardPort = 80; }
|
||||
if (form.brand === 4 && !form.ipAddress) { form.ipAddress = "0.0.0.0"; }
|
||||
|
||||
if (isSdkBrand.value) {
|
||||
const autoGen = generateRtspUrl();
|
||||
if (form.rtspPath === autoGen) form.rtspPath = "";
|
||||
}
|
||||
|
||||
try {
|
||||
if (mode.value === 'add') {
|
||||
const url = `${API_BASE}/api/Cameras`;
|
||||
logToParent('POST', url, 'PENDING', 'Adding camera...');
|
||||
await axios.post(url, form);
|
||||
logToParent('POST', url, 200, 'Created');
|
||||
alert("添加成功");
|
||||
window.parent.postMessage({ type: 'CLOSE_ADD_MODE', needRefresh: true }, '*');
|
||||
} else {
|
||||
const url = `${API_BASE}/api/Cameras/${deviceId.value}`;
|
||||
logToParent('PUT', url, 'PENDING', 'Updating camera...');
|
||||
await axios.put(url, form);
|
||||
logToParent('PUT', url, 200, 'Updated');
|
||||
alert("保存成功");
|
||||
window.parent.postMessage({ type: 'CLOSE_EDIT_MODE', needRefresh: true }, '*');
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.message || e.message;
|
||||
let detail = msg;
|
||||
if (e.response?.data?.errors) detail += "\n" + JSON.stringify(e.response.data.errors);
|
||||
alert("操作失败: " + detail);
|
||||
logToParent(mode.value === 'add' ? 'POST' : 'PUT', 'API', 'ERROR', msg);
|
||||
} finally { submitting.value = false; }
|
||||
};
|
||||
|
||||
// 【新增】删除设备
|
||||
const removeDevice = async () => {
|
||||
if(!confirm(`确定要删除设备 [${form.name}] (ID:${deviceId.value}) 吗?\n此操作不可恢复!`)) return;
|
||||
|
||||
submitting.value = true;
|
||||
const url = `${API_BASE}/api/Cameras/${deviceId.value}`;
|
||||
try {
|
||||
logToParent('DELETE', url, 'PENDING', 'Deleting camera...');
|
||||
await axios.delete(url);
|
||||
logToParent('DELETE', url, 200, 'Deleted');
|
||||
alert("删除成功");
|
||||
// 刷新列表并退出编辑模式
|
||||
window.parent.postMessage({ type: 'CLOSE_EDIT_MODE', needRefresh: true }, '*');
|
||||
} catch(e) {
|
||||
alert("删除失败: " + e.message);
|
||||
logToParent('DELETE', url, 'ERROR', e.message);
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeMode = () => {
|
||||
const msgType = mode.value === 'add' ? 'CLOSE_ADD_MODE' : 'CLOSE_EDIT_MODE';
|
||||
window.parent.postMessage({ type: msgType, needRefresh: false }, '*');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'LOAD_EDIT_DATA') {
|
||||
if (e.data.apiBase) API_BASE = e.data.apiBase;
|
||||
loadData(e.data.deviceId);
|
||||
} else if (e.data.type === 'INIT_ADD_PAGE') {
|
||||
if (e.data.apiBase) API_BASE = e.data.apiBase;
|
||||
initAdd();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
mode, deviceId, pageTitle, form, loadingData, submitting, enableBoard, isSdkBrand,
|
||||
save, removeDevice, closeMode, onBrandChange, tryAutoGenerateRtsp, forceGenerateRtsp
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
133
SHH.CameraSdk/Htmls/Diagnostic.html
Normal file
133
SHH.CameraSdk/Htmls/Diagnostic.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
body { background: #1e1e1e; color: #d4d4d4; margin: 0; font-family: 'Consolas', monospace; font-size: 11px; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* 头部样式 */
|
||||
.header { background: #2d2d2d; padding: 5px 15px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; border-bottom: 2px solid #444; height: 40px; flex-shrink: 0; }
|
||||
.search-box { background: #3c3c3c; border: 1px solid #555; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; width: 220px; }
|
||||
|
||||
/* 日志区域 */
|
||||
.body { flex: 1; overflow-y: auto; padding: 5px 10px; background: #1e1e1e; }
|
||||
.log-row { display: flex; gap: 12px; border-bottom: 1px solid #2a2a2a; padding: 4px 0; align-items: center; white-space: nowrap; }
|
||||
.log-row:hover { background: #2a2a2a; }
|
||||
|
||||
/* 文字颜色定义 */
|
||||
.t-time { color: #888; min-width: 75px; }
|
||||
.t-method { font-weight: bold; min-width: 50px; color: #569cd6; }
|
||||
.t-url { color: #9cdcfe; flex: 1; overflow: hidden; text-overflow: ellipsis; cursor: help; }
|
||||
.status-ok { color: #4ec9b0; }
|
||||
.status-err { color: #f44747; }
|
||||
|
||||
/* 复制按钮样式 */
|
||||
.btn-copy {
|
||||
color: #6a9955; cursor: pointer; padding: 0 5px;
|
||||
border: 1px solid transparent; border-radius: 3px;
|
||||
transition: 0.2s; visibility: hidden; /* 默认隐藏,悬停显示 */
|
||||
}
|
||||
.log-row:hover .btn-copy { visibility: visible; }
|
||||
.btn-copy:hover { border-color: #6a9955; background: rgba(106, 153, 85, 0.1); }
|
||||
|
||||
/* 滚动条 */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-thumb { background: #444; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="display: contents;">
|
||||
<div class="header" @click="toggle">
|
||||
<div>
|
||||
<i class="bi bi-terminal-split me-2 text-warning"></i>
|
||||
全路径诊断中控 ({{ filteredLogs.length }} 条)
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<input type="text" class="form-control form-control-sm search-box me-3"
|
||||
v-model="searchText" @click.stop placeholder="检索 URL / 关键字...">
|
||||
<span style="color:#ffca28; font-weight:bold; cursor:pointer" class="me-3">
|
||||
{{ isExpanded ? '▼ 收起' : '▲ 展开' }}
|
||||
</span>
|
||||
<button class="btn btn-outline-danger btn-sm py-0 px-2" @click.stop="logs=[]">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="body" id="log-container">
|
||||
<div v-for="log in filteredLogs" :key="log.id" class="log-row">
|
||||
<span class="t-time">[{{ log.time }}]</span>
|
||||
<span class="t-method">{{ log.method }}</span>
|
||||
<span class="t-url" :title="log.url">{{ log.url }}</span>
|
||||
<span :class="log.ok ? 'status-ok' : 'status-err'">{{ log.status }}</span>
|
||||
|
||||
<span class="btn-copy" @click.stop="copyLog(log.url)" title="复制完整URL">
|
||||
<i class="bi bi-clipboard-plus"></i> 复制
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="filteredLogs.length === 0" class="text-center text-muted mt-5">
|
||||
<i class="bi bi-activity d-block fs-3 mb-2"></i>
|
||||
等待数据请求...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, computed, nextTick } = Vue;
|
||||
createApp({
|
||||
setup() {
|
||||
const logs = ref([]);
|
||||
const isExpanded = ref(false);
|
||||
const searchText = ref("");
|
||||
|
||||
// 监听母座转发的日志
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'PUSH_LOG') {
|
||||
logs.value.push({
|
||||
id: Date.now() + Math.random(),
|
||||
time: new Date().toLocaleTimeString(),
|
||||
...e.data.log,
|
||||
ok: e.data.log.status === 200 || e.data.log.status === 'OK' || e.data.log.status === 'SEND'
|
||||
});
|
||||
|
||||
// 自动清理,防止内存溢出
|
||||
if (logs.value.length > 200) logs.value.shift();
|
||||
|
||||
// 如果没在搜索,自动滚动到底部
|
||||
if(!searchText.value) {
|
||||
nextTick(() => {
|
||||
const c = document.getElementById('log-container');
|
||||
c.scrollTop = c.scrollHeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 复制到剪贴板功能
|
||||
const copyLog = (text) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// 可以在这里加个简单的 Toast 提示,或者改变图标颜色
|
||||
console.log('Copied:', text);
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = () => {
|
||||
isExpanded.value = !isExpanded.value;
|
||||
window.parent.postMessage({ type: 'UI_RESIZE_DIAG', expanded: isExpanded.value }, '*');
|
||||
};
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const kw = searchText.value.toLowerCase();
|
||||
return logs.value.filter(l =>
|
||||
l.url.toLowerCase().includes(kw) ||
|
||||
(l.msg && l.msg.toLowerCase().includes(kw))
|
||||
);
|
||||
});
|
||||
|
||||
return { logs, isExpanded, searchText, filteredLogs, toggle, copyLog };
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
99
SHH.CameraSdk/Htmls/Editor.html
Normal file
99
SHH.CameraSdk/Htmls/Editor.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link href="https://cdn.staticfile.org/twitter-bootstrap/5.3.0/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
[v-cloak] { display: none; }
|
||||
body { background: #f4f6f9; padding: 20px; font-size: 0.85rem; }
|
||||
.card { border: none; border-radius: 10px; box-shadow: 0 4px 12px rgba(0,0,0,0.08); background: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<div v-if="!deviceId" class="text-center mt-5 text-muted">
|
||||
<i class="bi bi-cpu display-1 opacity-25"></i>
|
||||
<p class="mt-3">请选择设备以载入配置</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="card p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="m-0 fw-bold">AI 配置 (ID: {{ deviceId }})</h5>
|
||||
<button class="btn btn-primary btn-sm" @click="save">下发配置</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 border-end">
|
||||
<label class="fw-bold mb-2">缩放控制</label>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" v-model="proc.enableShrink">
|
||||
<label>启用缩小</label>
|
||||
</div>
|
||||
<div class="input-group input-group-sm mb-3">
|
||||
<input type="number" class="form-control" v-model.number="proc.targetWidth">
|
||||
<span class="input-group-text">x</span>
|
||||
<input type="number" class="form-control" v-model.number="proc.targetHeight">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 ps-4">
|
||||
<label class="fw-bold mb-2">图像增强</label>
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input class="form-check-input" type="checkbox" v-model="proc.enableEnhance">
|
||||
<label>开启亮度补偿</label>
|
||||
</div>
|
||||
<input type="range" class="form-range" v-model.number="proc.brightnessLevel" :disabled="!proc.enableEnhance">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted } = Vue;
|
||||
const API_BASE = "http://localhost:5000";
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const deviceId = ref(null);
|
||||
const proc = reactive({ enableShrink: true, targetWidth: 640, targetHeight: 360, enableEnhance: false, brightnessLevel: 30 });
|
||||
|
||||
// 【修复点】路径对齐到 GET /api/Monitor/{id}
|
||||
const loadDetail = async (id) => {
|
||||
const fullUrl = `${API_BASE}/api/Monitor/${id}`;
|
||||
try {
|
||||
const res = await axios.get(fullUrl);
|
||||
// 假设返回的 JSON 包含 processingOptions 字段
|
||||
if (res.data && res.data.processingOptions) {
|
||||
Object.assign(proc, res.data.processingOptions);
|
||||
}
|
||||
window.parent.postMessage({ type: 'API_LOG', log: { method: 'GET', url: fullUrl, status: 200, msg: '配置载入成功' }}, '*');
|
||||
} catch (e) {
|
||||
window.parent.postMessage({ type: 'API_LOG', log: { method: 'GET', url: fullUrl, status: 'FAIL', msg: e.message }}, '*');
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
// 对齐到 POST /api/Monitor/update-processing
|
||||
const fullUrl = `${API_BASE}/api/Monitor/update-processing`;
|
||||
try {
|
||||
await axios.post(fullUrl, { deviceId: deviceId.value, ...proc });
|
||||
alert("配置已更新");
|
||||
} catch (e) { alert("更新失败"); }
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data.type === 'LOAD_DEVICE') {
|
||||
deviceId.value = event.data.deviceId;
|
||||
loadDetail(event.data.deviceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { deviceId, proc, save };
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
208
SHH.CameraSdk/Htmls/EditorTop.html
Normal file
208
SHH.CameraSdk/Htmls/EditorTop.html
Normal file
@@ -0,0 +1,208 @@
|
||||
<!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>
|
||||
175
SHH.CameraSdk/Htmls/List.html
Normal file
175
SHH.CameraSdk/Htmls/List.html
Normal file
@@ -0,0 +1,175 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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 !important; }
|
||||
body { background-color: #fff; font-size: 0.85rem; height: 100vh; overflow: hidden; display: flex; flex-direction: column; font-family: "Segoe UI", sans-serif; }
|
||||
|
||||
.sidebar-header { padding: 12px; border-bottom: 1px solid #eee; background: #f8f9fa; flex-shrink: 0; }
|
||||
.sidebar-list { flex: 1; overflow-y: auto; }
|
||||
|
||||
/* 经典卡片样式 */
|
||||
.camera-card { padding: 10px 15px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: 0.2s; border-left: 4px solid transparent; }
|
||||
.camera-card:hover { background-color: #f8f9fa; }
|
||||
.camera-card.active { background-color: #e3f2fd; border-left-color: #0d6efd !important; }
|
||||
|
||||
/* 状态边框颜色 */
|
||||
.border-run { border-left-color: #198754; }
|
||||
.border-wait { border-left-color: #ffc107; }
|
||||
.border-err { border-left-color: #dc3545; }
|
||||
|
||||
/* 细节样式 */
|
||||
.ping-dot { font-size: 10px; margin-right: 6px; }
|
||||
.ping-online { color: #198754; text-shadow: 0 0 5px rgba(25, 135, 84, 0.5); }
|
||||
.ping-offline { color: #adb5bd; }
|
||||
|
||||
.device-id { font-weight: bold; color: #333; }
|
||||
.info-row { display: flex; justify-content: space-between; align-items: center; margin-top: 4px; }
|
||||
|
||||
.res-tag {
|
||||
font-size: 0.7rem; color: #666; background: #f0f0f0;
|
||||
padding: 1px 5px; border-radius: 3px; font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app" v-cloak class="d-flex flex-column h-100">
|
||||
<div class="sidebar-header">
|
||||
<div class="input-group input-group-sm mb-2">
|
||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search"></i></span>
|
||||
<input type="text" class="form-control border-start-0" v-model="searchText" placeholder="搜索 ID / IP / 名称...">
|
||||
|
||||
<button class="btn btn-primary" @click="openAddPage" title="添加新设备">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center px-1">
|
||||
<small class="text-muted">运行中: <span class="text-success fw-bold">{{ playingCount }}</span> / {{ list.length }}</small>
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input" type="checkbox" v-model="autoRefresh" id="ar" style="transform: scale(0.8);">
|
||||
<label class="form-check-label small" for="ar">自动刷新</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-list">
|
||||
<div v-for="cam in filteredList" :key="cam.id"
|
||||
class="camera-card"
|
||||
:class="[{'active': sid === cam.id}, getStatusBorder(cam.status)]"
|
||||
@click="onSelect(cam)">
|
||||
|
||||
<div class="info-row mb-1">
|
||||
<span class="device-id text-truncate" style="max-width: 160px;" :title="cam.name">
|
||||
<i class="bi bi-circle-fill ping-dot" :class="cam.isPhysicalOnline ? 'ping-online' : 'ping-offline'"></i>
|
||||
#{{ cam.id }} {{ cam.name || '未命名相机' }}
|
||||
</span>
|
||||
|
||||
<span v-if="isRun(cam.status)" class="badge bg-success" style="font-size: 0.6rem; padding: 2px 5px;">LIVE</span>
|
||||
<span v-else-if="isWait(cam.status)" class="badge bg-warning text-dark" style="font-size: 0.6rem;">BUSY</span>
|
||||
<span v-else-if="cam.status === 'Faulted'" class="badge bg-danger" style="font-size: 0.6rem;">ERR</span>
|
||||
<span v-else class="badge bg-light text-muted border" style="font-size: 0.6rem;">STOP</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row text-muted small">
|
||||
<span><i class="bi bi-link-45deg"></i> {{ cam.ipAddress }}</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span v-if="cam.resolution" class="res-tag" title="当前分辨率">
|
||||
<i class="bi bi-aspect-ratio me-1"></i>{{ cam.resolution }}
|
||||
</span>
|
||||
<span v-if="isRun(cam.status)" class="text-primary fw-bold" style="min-width: 45px; text-align: right;">
|
||||
{{ cam.realFps?.toFixed(1) }} FPS
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredList.length === 0" class="text-center mt-5 text-muted">
|
||||
<i class="bi bi-inbox fs-2 d-block mb-2 opacity-25"></i>
|
||||
未找到设备
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted } = Vue;
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const API_BASE = "http://localhost:5000";
|
||||
const list = ref([]);
|
||||
const sid = ref(null);
|
||||
const searchText = ref("");
|
||||
const autoRefresh = ref(true);
|
||||
|
||||
// 状态判断
|
||||
const isRun = (s) => s === 'Playing' || s === 'Streaming';
|
||||
const isWait = (s) => ['Connecting', 'Authorizing', 'Reconnecting'].includes(s);
|
||||
const getStatusBorder = (s) => {
|
||||
if (isRun(s)) return 'border-run';
|
||||
if (isWait(s)) return 'border-wait';
|
||||
if (s === 'Faulted') return 'border-err';
|
||||
return '';
|
||||
};
|
||||
|
||||
const fetchList = async () => {
|
||||
try {
|
||||
const res = await axios.get(API_BASE + "/api/Monitor/all");
|
||||
const data = res.data || [];
|
||||
list.value = data.map(item => ({
|
||||
...item,
|
||||
// 兼容大小写
|
||||
resolution: item.resolution || item.Resolution || (item.width ? `${item.width}x${item.height}` : null)
|
||||
}));
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const onSelect = (cam) => {
|
||||
sid.value = cam.id;
|
||||
if(window.parent) window.parent.postMessage({ type: 'DEVICE_SELECTED', data: JSON.parse(JSON.stringify(cam)) }, '*');
|
||||
};
|
||||
|
||||
// 【触发】打开右侧添加页
|
||||
const openAddPage = () => {
|
||||
if(window.parent) window.parent.postMessage({ type: 'OPEN_CAMERA_ADD' }, '*');
|
||||
};
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if(e.data.type === 'REFRESH_LIST') fetchList();
|
||||
});
|
||||
|
||||
const filteredList = computed(() => {
|
||||
const kw = searchText.value.toLowerCase().trim();
|
||||
if (!kw) return list.value;
|
||||
return list.value.filter(i =>
|
||||
String(i.id).includes(kw) ||
|
||||
(i.name && i.name.toLowerCase().includes(kw)) ||
|
||||
(i.ipAddress && i.ipAddress.includes(kw))
|
||||
);
|
||||
});
|
||||
|
||||
const playingCount = computed(() => list.value.filter(i => isRun(i.status)).length);
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
setInterval(() => { if (autoRefresh.value) fetchList(); }, 3000);
|
||||
});
|
||||
|
||||
return {
|
||||
list, sid, searchText, autoRefresh,
|
||||
filteredList, playingCount,
|
||||
onSelect, isRun, isWait, getStatusBorder,
|
||||
openAddPage
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
148
SHH.CameraSdk/Htmls/Main.html
Normal file
148
SHH.CameraSdk/Htmls/Main.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SHH 视频网关 - 控制底座</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>
|
||||
body, html { margin: 0; padding: 0; height: 100vh; overflow: hidden; background: #f4f6f9; }
|
||||
.app-shell { display: flex; height: 100vh; }
|
||||
.sidebar-container { width: 320px; border-right: 1px solid #ddd; background: #fff; flex-shrink: 0; }
|
||||
.main-workarea { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.top-container { height: 110px; padding: 15px 15px 0 15px; flex-shrink: 0; }
|
||||
.editor-container { flex: 1; padding: 15px; overflow: hidden; position: relative; }
|
||||
.log-panel { height: 45px; border-top: 1px solid #333; transition: height 0.3s; background: #1e1e1e; }
|
||||
.modal-body iframe { border: none; width: 100%; }
|
||||
#frame-editor { width: 100%; height: 100%; border: none; background: #fff; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.05); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="app-shell">
|
||||
<div class="sidebar-container">
|
||||
<iframe id="frame-list" src="List.html" style="width:100%; height:100%; border:none;" name="list"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="main-workarea">
|
||||
<div class="top-container">
|
||||
<iframe id="frame-top" src="EditorTop.html" style="width:100%; height:100%; border:none;" name="top"></iframe>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<iframe id="frame-editor" src="Editor.html" name="editor"></iframe>
|
||||
</div>
|
||||
|
||||
<div id="diag-wrapper" class="log-panel">
|
||||
<iframe id="frame-diag" src="Diagnostic.html" style="width:100%; height:100%; border:none;" name="diag"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="subModal" tabindex="-1"><div class="modal-dialog modal-lg modal-dialog-centered"><div class="modal-content shadow-lg"><div class="modal-body p-0"><iframe id="frame-sub" src="Subscription.html" style="height: 650px;" name="sub"></iframe></div></div></div></div>
|
||||
<div class="modal fade" id="preModal" tabindex="-1"><div class="modal-dialog modal-md modal-dialog-centered"><div class="modal-content shadow-lg"><div class="modal-body p-0"><iframe id="frame-pre" src="Preprocessing.html" style="height: 580px;" name="pre"></iframe></div></div></div></div>
|
||||
<div class="modal fade" id="ctrlModal" tabindex="-1"><div class="modal-dialog modal-dialog-centered"><div class="modal-content shadow-lg"><div class="modal-body p-0"><iframe id="frame-ctrl" src="CameraControl.html" style="height: 480px;" name="ctrl"></iframe></div></div></div></div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/bootstrap/5.3.0/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
const API_BASE = "http://localhost:5000";
|
||||
let currentDeviceId = 0;
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
const frames = {
|
||||
list: document.getElementById('frame-list').contentWindow,
|
||||
top: document.getElementById('frame-top').contentWindow,
|
||||
editor: document.getElementById('frame-editor').contentWindow,
|
||||
diag: document.getElementById('frame-diag').contentWindow,
|
||||
sub: document.getElementById('frame-sub').contentWindow,
|
||||
pre: document.getElementById('frame-pre').contentWindow,
|
||||
ctrl: document.getElementById('frame-ctrl').contentWindow
|
||||
};
|
||||
const editorIframe = document.getElementById('frame-editor');
|
||||
|
||||
const switchToDetail = () => {
|
||||
editorIframe.src = "Editor.html";
|
||||
editorIframe.onload = () => {
|
||||
setTimeout(() => {
|
||||
if(currentDeviceId) editorIframe.contentWindow.postMessage({ type: 'LOAD_DEVICE', deviceId: currentDeviceId }, '*');
|
||||
}, 100);
|
||||
};
|
||||
};
|
||||
|
||||
switch(msg.type) {
|
||||
case 'DEVICE_SELECTED':
|
||||
currentDeviceId = msg.data.id;
|
||||
// 如果当前不是Editor页面,切回
|
||||
if (!editorIframe.src.includes('Editor.html')) {
|
||||
switchToDetail();
|
||||
} else {
|
||||
if(frames.top) frames.top.postMessage({ type: 'UPDATE_TOP_INFO', data: msg.data }, '*');
|
||||
if(frames.editor) frames.editor.postMessage({ type: 'LOAD_DEVICE', deviceId: msg.data.id }, '*');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'DEVICE_CONTROL':
|
||||
const controlUrl = `${API_BASE}/api/Cameras/${msg.deviceId}/power?enabled=${msg.action === 'start'}`;
|
||||
axios.post(controlUrl).then(() => {
|
||||
frames.list.postMessage({ type: 'REFRESH_LIST' }, '*');
|
||||
frames.diag.postMessage({ type: 'PUSH_LOG', log: { method: 'POST', url: controlUrl, status: 200, msg: `Device ${msg.action}` } }, '*');
|
||||
}).catch(e => console.error(e));
|
||||
break;
|
||||
|
||||
case 'API_LOG':
|
||||
if(frames.diag) frames.diag.postMessage({ type: 'PUSH_LOG', log: msg.log }, '*');
|
||||
break;
|
||||
|
||||
case 'UI_RESIZE_DIAG':
|
||||
const diagEl = document.getElementById('diag-wrapper');
|
||||
if(diagEl) diagEl.style.height = msg.expanded ? '350px' : '45px';
|
||||
break;
|
||||
|
||||
// 弹窗逻辑
|
||||
case 'OPEN_SUBSCRIPTION':
|
||||
new bootstrap.Modal(document.getElementById('subModal')).show();
|
||||
setTimeout(() => frames.sub.postMessage({ type: 'LOAD_SUBS_DATA', deviceId: msg.id }, '*'), 400);
|
||||
break;
|
||||
case 'OPEN_PREPROCESS':
|
||||
editorIframe.src = "Preprocessing.html";
|
||||
editorIframe.onload = () => {
|
||||
editorIframe.contentWindow.postMessage({ type: 'LOAD_PREPROCESS_DATA', deviceId: msg.id, apiBase: API_BASE }, '*');
|
||||
};
|
||||
break;
|
||||
case 'OPEN_CAMERA_CONTROL':
|
||||
new bootstrap.Modal(document.getElementById('ctrlModal')).show();
|
||||
setTimeout(() => frames.ctrl.postMessage({ type: 'LOAD_CTRL_DATA', deviceId: msg.id, apiBase: API_BASE }, '*'), 400);
|
||||
break;
|
||||
|
||||
// --- 统一使用 CameraEdit.html ---
|
||||
|
||||
case 'OPEN_CAMERA_EDIT':
|
||||
editorIframe.src = "CameraEdit.html";
|
||||
editorIframe.onload = () => {
|
||||
editorIframe.contentWindow.postMessage({ type: 'LOAD_EDIT_DATA', deviceId: msg.id, apiBase: API_BASE }, '*');
|
||||
};
|
||||
break;
|
||||
|
||||
case 'OPEN_CAMERA_ADD': // 新增也指向同一个文件
|
||||
editorIframe.src = "CameraEdit.html";
|
||||
editorIframe.onload = () => {
|
||||
editorIframe.contentWindow.postMessage({ type: 'INIT_ADD_PAGE', apiBase: API_BASE }, '*');
|
||||
};
|
||||
break;
|
||||
|
||||
// 统一关闭逻辑
|
||||
case 'CLOSE_EDIT_MODE':
|
||||
case 'CLOSE_PREPROCESS_MODE':
|
||||
case 'CLOSE_ADD_MODE':
|
||||
switchToDetail();
|
||||
if(msg.needRefresh) frames.list.postMessage({ type: 'REFRESH_LIST' }, '*');
|
||||
break;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
375
SHH.CameraSdk/Htmls/Preprocessing.html
Normal file
375
SHH.CameraSdk/Htmls/Preprocessing.html
Normal file
@@ -0,0 +1,375 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>图像预处理配置</title>
|
||||
<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; }
|
||||
|
||||
/* 1. 基础布局:全屏、Flex */
|
||||
html, body {
|
||||
height: 100%; margin: 0; padding: 0;
|
||||
background: #fff; font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%; display: flex; flex-direction: column;
|
||||
}
|
||||
|
||||
/* 2. 头部样式 */
|
||||
.page-header {
|
||||
padding: 15px 30px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
background: #fff;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.page-title { font-size: 1.2rem; font-weight: 700; color: #343a40; display: flex; align-items: center; }
|
||||
|
||||
/* 3. 中间内容区 */
|
||||
.content-body {
|
||||
flex: 1; overflow-y: auto; padding: 30px 40px;
|
||||
max-width: 1200px; margin: 0 auto; width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
.content-body::-webkit-scrollbar { width: 8px; }
|
||||
.content-body::-webkit-scrollbar-track { background: #f8f9fa; }
|
||||
.content-body::-webkit-scrollbar-thumb { background: #ced4da; border-radius: 4px; }
|
||||
|
||||
/* 4. 分区标题:亮蓝色风格 */
|
||||
.section-header {
|
||||
display: flex; align-items: center; margin-top: 10px; margin-bottom: 25px;
|
||||
padding-bottom: 10px; border-bottom: 2px solid #e7f1ff;
|
||||
}
|
||||
.section-header h6 {
|
||||
margin: 0; font-weight: 800; color: #0d6efd;
|
||||
font-size: 1rem; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* 5. 组件样式 */
|
||||
.form-label { font-weight: 600; color: #495057; margin-bottom: 8px; font-size: 0.9rem; }
|
||||
.form-control { padding: 10px 15px; border-radius: 6px; border-color: #dee2e6; }
|
||||
.form-control:focus { border-color: #86b7fe; box-shadow: 0 0 0 3px rgba(13,110,253,0.1); }
|
||||
|
||||
.preset-badge {
|
||||
cursor: pointer; user-select: none; border: 1px solid #dee2e6;
|
||||
color: #555; background: #fff; transition: all 0.2s;
|
||||
padding: 8px 16px; border-radius: 6px; font-size: 0.85rem; margin-right: 8px; margin-bottom: 8px;
|
||||
display: inline-block;
|
||||
}
|
||||
.preset-badge:hover { background-color: #f8f9fa; border-color: #adb5bd; }
|
||||
.preset-badge.active { background-color: #e7f1ff; color: #0d6efd; border-color: #0d6efd; font-weight: 600; }
|
||||
|
||||
.source-info-bar {
|
||||
background-color: #f8f9fa; border: 1px dashed #ced4da; border-radius: 8px;
|
||||
padding: 12px 20px; margin-bottom: 25px; display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
|
||||
/* 6. 底部按钮栏 */
|
||||
.footer-bar {
|
||||
padding: 15px 40px; border-top: 1px solid #e9ecef; background: #fff;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
|
||||
.success-msg { color: #198754; font-weight: 500; display: flex; align-items: center; }
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
.is-invalid { border-color: #dc3545 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="app" v-cloak>
|
||||
<div class="page-header">
|
||||
<div class="page-title">
|
||||
<i class="bi bi-sliders2 me-2 text-primary"></i>
|
||||
图像预处理配置 <span class="text-muted ms-2 fw-normal fs-6">(ID: {{ deviceId }})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
|
||||
<div class="section-header">
|
||||
<h6><i class="bi bi-aspect-ratio me-2"></i>分辨率控制 (Resolution)</h6>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div>
|
||||
<span class="text-muted me-2"><i class="bi bi-camera-video"></i> 源画面尺寸:</span>
|
||||
<strong class="font-monospace fs-5 text-dark">{{ baseRes.w }} x {{ baseRes.h }}</strong>
|
||||
<span class="badge bg-light text-secondary border me-2" v-if="currentScale !== '1.00'">缩放比: {{ currentScale }}x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch p-2 ps-5 border rounded bg-white">
|
||||
<input class="form-check-input" type="checkbox" v-model="config.allowShrink" style="margin-left: -2.5em;">
|
||||
<label class="form-check-label fw-bold">允许缩小 (Shrink)</label>
|
||||
<div class="text-muted small mt-1">当源分辨率大于目标时生效,通常建议开启以节省带宽。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch p-2 ps-5 border rounded bg-white">
|
||||
<input class="form-check-input" type="checkbox" v-model="config.allowEnlarge" @change="validateInputLimit" style="margin-left: -2.5em;">
|
||||
<label class="form-check-label fw-bold">允许放大 (Expand)</label>
|
||||
<div class="text-muted small mt-1">允许输出比源画面更大的分辨率(可能会增加系统负载)。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<label class="form-label">目标分辨率 (Target Size)</label>
|
||||
<div class="form-check form-check-inline m-0">
|
||||
<input class="form-check-input" type="checkbox" id="lockRatio" v-model="lockRatio">
|
||||
<label class="form-check-label small text-muted" for="lockRatio"><i class="bi bi-link-45deg"></i> 锁定比例</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-5">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light text-muted">W</span>
|
||||
<input type="number" class="form-control font-monospace" v-model.number="config.width"
|
||||
@input="onWidthChange" @blur="validateInputLimit" :class="{'is-invalid': inputError.w}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto text-muted"><i class="bi bi-x-lg"></i></div>
|
||||
<div class="col-5">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-light text-muted">H</span>
|
||||
<input type="number" class="form-control font-monospace" v-model.number="config.height"
|
||||
@input="onHeightChange" @blur="validateInputLimit" :class="{'is-invalid': inputError.h}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="inputError.msg" class="text-danger small mt-2"><i class="bi bi-exclamation-circle me-1"></i>{{ inputError.msg }}</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<label class="form-label d-block mb-3">常用预设 (Presets)</label>
|
||||
<span v-for="p in presets" :key="p.label" class="preset-badge"
|
||||
:class="{ 'active': config.width === p.w && config.height === p.h }" @click="applyPreset(p)">
|
||||
{{ p.label }} <span class="opacity-50 ms-1" v-if="p.w > 0">{{ p.w }}x{{ p.h }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5">
|
||||
<label class="form-label d-flex justify-content-between">
|
||||
<span>快速缩放</span>
|
||||
<span class="text-primary font-monospace">{{ sliderScale }}x</span>
|
||||
</label>
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<span class="small text-muted">0.1x</span>
|
||||
<input type="range" class="form-range" min="0.1" max="2.0" step="0.1"
|
||||
v-model.number="sliderScale" @input="onSliderChange">
|
||||
<span class="small text-muted">2.0x</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-header">
|
||||
<h6><i class="bi bi-magic me-2"></i>图像增强 (Enhancement)</h6>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch mb-3 ps-5">
|
||||
<input class="form-check-input" type="checkbox" v-model="config.enableGain" style="margin-left: -2.5em; transform: scale(1.2);">
|
||||
<label class="form-check-label fw-bold">启用亮度/增益调节 (Brightness)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 transition-box" v-show="config.enableGain">
|
||||
<div class="p-3 bg-light border rounded">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<span class="small fw-bold text-muted">强度 (Intensity)</span>
|
||||
<span class="fw-bold text-primary">{{ config.brightness }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="bi bi-sun text-muted"></i>
|
||||
<input type="range" class="form-range" min="0" max="255" step="1" v-model.number="config.brightness">
|
||||
<i class="bi bi-sun-fill text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="height: 40px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<div class="me-auto">
|
||||
<transition name="fade">
|
||||
<div v-if="saveSuccess" class="success-msg">
|
||||
<i class="bi bi-check-circle-fill me-2 fs-5"></i>
|
||||
<span>配置已生效 (Applied)</span>
|
||||
</div>
|
||||
</transition>
|
||||
<span v-if="errorMsg" class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>{{ errorMsg }}</span>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-light border px-4" @click="closeMode">取消</button>
|
||||
<button class="btn btn-primary px-4 shadow-sm" @click="saveConfig" :disabled="loading || !!inputError.msg">
|
||||
<span v-if="loading" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-floppy2-fill me-1"></i>
|
||||
保存应用
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, computed, onMounted } = Vue;
|
||||
let API_BASE = "";
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const deviceId = ref(0);
|
||||
const loading = ref(false);
|
||||
const saveSuccess = ref(false);
|
||||
const errorMsg = ref("");
|
||||
const lockRatio = ref(true);
|
||||
const sliderScale = ref(1.0);
|
||||
|
||||
const baseRes = reactive({ w: 0, h: 0 });
|
||||
const config = reactive({ width: 1280, height: 720, allowShrink: true, allowEnlarge: false, enableGain: false, brightness: 128 });
|
||||
const inputError = reactive({ w: false, h: false, msg: '' });
|
||||
|
||||
const presets = [
|
||||
{ label: 'Original', w: 0, h: 0 },
|
||||
{ label: '2K', w: 2560, h: 1440 },
|
||||
{ label: '1080P', w: 1920, h: 1080 },
|
||||
{ label: '720P', w: 1280, h: 720 },
|
||||
{ label: 'D1', w: 704, h: 576 },
|
||||
{ label: 'Yolo', w: 640, h: 640 }
|
||||
];
|
||||
|
||||
const currentScale = computed(() => (!baseRes.w ? "1.00" : (config.width / baseRes.w).toFixed(2)));
|
||||
|
||||
const logToParent = (status, msg, url, method = 'POST') => {
|
||||
if (window.parent) window.parent.postMessage({ type: 'API_LOG', log: { method, url, status, msg } }, '*');
|
||||
};
|
||||
|
||||
const loadDeviceInfo = async (id) => {
|
||||
deviceId.value = id;
|
||||
loading.value = true;
|
||||
const urlStatus = `${API_BASE}/api/Monitor/${id}`;
|
||||
const urlConfig = `${API_BASE}/api/cameras/${id}/processing`;
|
||||
|
||||
try {
|
||||
const [resStatus, resConfig] = await Promise.allSettled([ axios.get(urlStatus), axios.get(urlConfig) ]);
|
||||
|
||||
if (resStatus.status === 'fulfilled' && resStatus.value.data) {
|
||||
baseRes.w = resStatus.value.data.width || 2560;
|
||||
baseRes.h = resStatus.value.data.height || 1440;
|
||||
} else { baseRes.w = 2560; baseRes.h = 1440; }
|
||||
|
||||
if (resConfig.status === 'fulfilled' && resConfig.value.data) {
|
||||
const d = resConfig.value.data;
|
||||
if (d.targetWidth) config.width = d.targetWidth;
|
||||
if (d.targetHeight) config.height = d.targetHeight;
|
||||
config.allowShrink = d.enableShrink ?? true;
|
||||
config.allowEnlarge = d.enableExpand ?? false;
|
||||
config.enableGain = d.enableBrightness ?? false;
|
||||
config.brightness = d.brightness ?? 128;
|
||||
logToParent(200, '配置回显成功', urlConfig, 'GET');
|
||||
}
|
||||
if(baseRes.w > 0) sliderScale.value = (config.width / baseRes.w).toFixed(1);
|
||||
|
||||
} catch (e) { console.error(e); }
|
||||
finally { loading.value = false; validateInputLimit(); }
|
||||
};
|
||||
|
||||
const validateInputLimit = () => {
|
||||
inputError.w = false; inputError.h = false; inputError.msg = "";
|
||||
if (!config.allowEnlarge && baseRes.w > 0) {
|
||||
let corrected = false;
|
||||
if (config.width > baseRes.w) { config.width = baseRes.w; inputError.w = true; corrected = true; }
|
||||
if (config.height > baseRes.h) { config.height = baseRes.h; inputError.h = true; corrected = true; }
|
||||
if (corrected) {
|
||||
inputError.msg = "尺寸限制:最大为源分辨率 (需开启放大)";
|
||||
setTimeout(() => inputError.msg = "", 3000);
|
||||
}
|
||||
}
|
||||
if(baseRes.w > 0) sliderScale.value = (config.width / baseRes.w).toFixed(1);
|
||||
};
|
||||
|
||||
const onSliderChange = () => {
|
||||
config.width = Math.round(baseRes.w * sliderScale.value);
|
||||
config.height = Math.round(baseRes.h * sliderScale.value);
|
||||
validateInputLimit();
|
||||
};
|
||||
|
||||
const onWidthChange = () => {
|
||||
if (lockRatio.value && baseRes.w > 0) config.height = Math.round(config.width * (baseRes.h / baseRes.w));
|
||||
};
|
||||
const onHeightChange = () => {
|
||||
if (lockRatio.value && baseRes.h > 0) config.width = Math.round(config.height * (baseRes.w / baseRes.h));
|
||||
};
|
||||
|
||||
const applyPreset = (p) => {
|
||||
if (p.label === 'Original') { config.width = baseRes.w; config.height = baseRes.h; }
|
||||
else { config.width = p.w; config.height = p.h; }
|
||||
validateInputLimit();
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
validateInputLimit();
|
||||
if(inputError.msg) return;
|
||||
|
||||
loading.value = true;
|
||||
saveSuccess.value = false;
|
||||
errorMsg.value = "";
|
||||
|
||||
const url = `${API_BASE}/api/cameras/${deviceId.value}/processing`;
|
||||
const payload = {
|
||||
DeviceId: deviceId.value, EnableShrink: config.allowShrink, EnableExpand: config.allowEnlarge,
|
||||
TargetWidth: parseInt(config.width), TargetHeight: parseInt(config.height),
|
||||
EnableBrightness: config.enableGain, Brightness: parseInt(config.brightness)
|
||||
};
|
||||
|
||||
try {
|
||||
await axios.post(url, payload);
|
||||
logToParent(200, '配置已应用', url);
|
||||
saveSuccess.value = true;
|
||||
setTimeout(() => { saveSuccess.value = false; }, 2500);
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.message || e.message;
|
||||
errorMsg.value = "提交失败: " + msg;
|
||||
logToParent('ERROR', msg, url);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 【修改】关闭模式,而非关闭弹窗
|
||||
const closeMode = () => window.parent.postMessage({ type: 'CLOSE_PREPROCESS_MODE' }, '*');
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'LOAD_PREPROCESS_DATA') {
|
||||
if (e.data.apiBase) API_BASE = e.data.apiBase;
|
||||
loadDeviceInfo(e.data.deviceId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
deviceId, config, baseRes, presets, loading, errorMsg, saveSuccess,
|
||||
lockRatio, sliderScale, currentScale, inputError,
|
||||
saveConfig, closeMode, applyPreset, onWidthChange, onHeightChange, validateInputLimit, onSliderChange
|
||||
};
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
281
SHH.CameraSdk/Htmls/Subscription.html
Normal file
281
SHH.CameraSdk/Htmls/Subscription.html
Normal file
@@ -0,0 +1,281 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>流控订阅配置中心</title>
|
||||
<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; font-size: 0.85rem; padding: 15px; font-family: "Segoe UI", system-ui, sans-serif; }
|
||||
|
||||
/* 配置面板样式 */
|
||||
.config-section { border: 1px solid #dee2e6; border-radius: 8px; background: #f8f9fa; padding: 15px; margin-bottom: 20px; }
|
||||
|
||||
/* 列表项样式:点击回显 */
|
||||
.sub-item {
|
||||
border: 1px solid #e9ecef; border-radius: 8px; padding: 10px 15px; margin-bottom: 10px;
|
||||
border-left: 4px solid #0d6efd; background: #fff; cursor: pointer; transition: all 0.2s;
|
||||
}
|
||||
.sub-item:hover { border-color: #0d6efd; box-shadow: 0 4px 10px rgba(0,0,0,0.08); background: #fcfcfc; }
|
||||
.sub-item:active { transform: scale(0.99); }
|
||||
|
||||
/* 第一行:标题与备注 */
|
||||
.sub-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; }
|
||||
.sub-title-group { display: flex; align-items: center; gap: 10px; flex-grow: 1; }
|
||||
.app-id { font-family: "Cascadia Code", monospace; font-weight: 700; color: #333; }
|
||||
.sub-memo { color: #6c757d; font-size: 0.8rem; border-left: 1px solid #ddd; padding-left: 10px; max-width: 300px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
/* 第二行:详情展示 */
|
||||
.sub-body { display: flex; align-items: center; gap: 15px; font-size: 0.75rem; color: #555; }
|
||||
.type-badge { font-size: 0.7rem; padding: 1px 6px; border-radius: 4px; background: #e9ecef; color: #495057; font-weight: 600; }
|
||||
.fps-real { color: #198754; font-weight: 700; background: #e8f5e9; padding: 0 4px; border-radius: 3px; }
|
||||
.dynamic-detail { color: #0d6efd; display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
/* 表单布局 */
|
||||
.horizontal-group { display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.horizontal-group label { width: 80px; font-weight: 600; color: #495057; flex-shrink: 0; }
|
||||
.horizontal-group .input-container { flex-grow: 1; display: flex; align-items: center; gap: 8px; }
|
||||
.form-label-top { font-weight: 600; font-size: 0.75rem; color: #495057; margin-bottom: 3px; display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<div v-if="deviceId">
|
||||
<div class="config-section shadow-sm">
|
||||
<h6 class="fw-bold mb-3 text-primary small d-flex align-items-center">
|
||||
<i class="bi bi-gear-wide-connected me-2"></i>订阅策略分发
|
||||
</h6>
|
||||
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label-top">订阅标识 (AppId)</label>
|
||||
<input type="text" class="form-control form-control-sm" v-model.trim="form.appId" placeholder="自动生成 ID">
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<label class="form-label-top">目标帧率</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="number" class="form-control" v-model.number="form.displayFps">
|
||||
<span class="input-group-text">FPS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<label class="form-label-top">业务类型</label>
|
||||
<select class="form-select form-select-sm" v-model.number="form.typeIndex">
|
||||
<option :value="0">本地窗口渲染 (OpenCV)</option>
|
||||
<option :value="1">本地录像存储 (MP4)</option>
|
||||
<option :value="2">窗口句柄穿透 (PlayM4)</option>
|
||||
<option :value="3">网络数据转发 (TCP/UDP)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dynamic-params-box">
|
||||
<div class="horizontal-group" v-if="form.typeIndex === 2">
|
||||
<label>窗口句柄</label>
|
||||
<div class="input-container">
|
||||
<input type="text" class="form-control form-control-sm" v-model="form.handle" placeholder="输入 HWND 句柄值 (如 0x00120F)">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="horizontal-group" v-if="form.typeIndex === 1">
|
||||
<label>录像配置</label>
|
||||
<div class="input-container">
|
||||
<input type="text" class="form-control form-control-sm" v-model="form.savePath" placeholder="存储路径 (如 D:\Recordings)">
|
||||
<span class="small text-muted">时长:</span>
|
||||
<input type="number" class="form-control form-control-sm" style="width: 70px;" v-model.number="form.recordDuration">
|
||||
<span class="small text-muted">分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="horizontal-group" v-if="form.typeIndex === 3">
|
||||
<label>转发目标</label>
|
||||
<div class="input-container">
|
||||
<input type="text" class="form-control form-control-sm" v-model="form.targetIp" placeholder="目标 IP">
|
||||
<span class="fw-bold">:</span>
|
||||
<input type="number" class="form-control form-control-sm" style="width: 90px;" v-model.number="form.targetPort">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="horizontal-group">
|
||||
<label>业务备注</label>
|
||||
<div class="input-container">
|
||||
<input type="text" class="form-control form-control-sm" v-model="form.memo" placeholder="例如:AI识别、大屏显示、审计备份">
|
||||
<button class="btn btn-primary btn-sm px-4 fw-bold" @click="submitSub" :disabled="loading">
|
||||
<i class="bi bi-cloud-arrow-up-fill me-1"></i> {{ loading ? '下发中' : '执行订阅' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sub-list">
|
||||
<h6 class="fw-bold mb-3 px-1 small d-flex justify-content-between align-items-center">
|
||||
<span>活跃订阅列表 <span class="badge bg-success ms-1">{{ activeSubs.length }}</span></span>
|
||||
<span class="text-muted small" style="font-weight:normal">定时刷新: {{ lastUpdateTime }}</span>
|
||||
</h6>
|
||||
|
||||
<div v-for="sub in activeSubs" :key="sub.appId"
|
||||
class="sub-item"
|
||||
@click="echoToForm(sub)"
|
||||
title="点击回显到表单进行编辑">
|
||||
|
||||
<div class="sub-header">
|
||||
<div class="sub-title-group">
|
||||
<span class="app-id">{{ sub.appId }}</span>
|
||||
<span v-if="sub.memo" class="sub-memo">{{ sub.memo }}</span>
|
||||
</div>
|
||||
<button class="btn btn-link btn-sm p-0 text-danger" @click.stop="removeSub(sub.appId)">
|
||||
<i class="bi bi-x-circle-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sub-body">
|
||||
<span class="type-badge">{{ translateType(sub.type) }}</span>
|
||||
<span>目标: {{ sub.targetFps }} FPS</span>
|
||||
<span class="fps-real">实际: {{ (sub.realFps || 0).toFixed(1) }} FPS</span>
|
||||
|
||||
<div class="dynamic-detail">
|
||||
<span v-if="sub.type === 1 || sub.type === 'LocalRecord'">
|
||||
<i class="bi bi-folder-symlink"></i> {{ sub.savePath }}
|
||||
</span>
|
||||
<span v-else-if="sub.type === 2 || sub.type === 'HandleDisplay'">
|
||||
<i class="bi bi-window-stack"></i> HWND: {{ sub.handle }}
|
||||
</span>
|
||||
<span v-else-if="sub.type === 3 || sub.type === 'NetworkStream'">
|
||||
<i class="bi bi-globe"></i> {{ sub.targetIp }}:{{ sub.targetPort }}
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="bi bi-display"></i> 本地渲染
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeSubs.length === 0" class="text-center py-5 text-muted small border rounded bg-light border-dashed">
|
||||
暂无活跃订阅需求,请在上方录入策略
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.staticfile.org/vue/3.3.4/vue.global.prod.min.js"></script>
|
||||
<script src="https://cdn.staticfile.org/axios/1.5.0/axios.min.js"></script>
|
||||
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted, onUnmounted } = Vue;
|
||||
const API_BASE = "http://localhost:5000";
|
||||
|
||||
createApp({
|
||||
setup() {
|
||||
const deviceId = ref(null);
|
||||
const activeSubs = ref([]);
|
||||
const loading = ref(false);
|
||||
const lastUpdateTime = ref('--:--:--');
|
||||
|
||||
const form = reactive({
|
||||
appId: '', typeIndex: 0, displayFps: 15, memo: '',
|
||||
handle: '', recordDuration: 5, savePath: 'C:\\Recordings',
|
||||
targetIp: '127.0.0.1', targetPort: 8080
|
||||
});
|
||||
|
||||
let pollTimer = null;
|
||||
|
||||
// 加载订阅数据
|
||||
const loadSubs = async () => {
|
||||
if (!deviceId.value) return;
|
||||
try {
|
||||
const res = await axios.get(`${API_BASE}/api/Monitor/${deviceId.value}`);
|
||||
// 适配后端强类型 DTO 属性名
|
||||
const raw = res.data.requirements || res.data.Requirements || [];
|
||||
activeSubs.value = raw.map(r => ({
|
||||
appId: r.appId || r.AppId,
|
||||
type: r.type !== undefined ? r.type : r.Type,
|
||||
targetFps: r.targetFps || r.TargetFps,
|
||||
realFps: r.realFps || r.RealFps || 0,
|
||||
memo: r.memo || r.Memo || '',
|
||||
handle: r.handle || r.Handle || '',
|
||||
savePath: r.savePath || r.SavePath || '',
|
||||
targetIp: r.targetIp || r.TargetIp || '',
|
||||
targetPort: r.targetPort || r.TargetPort || 0
|
||||
}));
|
||||
lastUpdateTime.value = new Date().toLocaleTimeString();
|
||||
} catch (e) { console.error("刷新失败", e); }
|
||||
};
|
||||
|
||||
// 点击卡片:回显数据到表单
|
||||
const echoToForm = (sub) => {
|
||||
form.appId = sub.appId;
|
||||
form.displayFps = sub.targetFps;
|
||||
form.memo = sub.memo;
|
||||
form.handle = sub.handle;
|
||||
form.savePath = sub.savePath;
|
||||
form.targetIp = sub.targetIp;
|
||||
form.targetPort = sub.targetPort;
|
||||
|
||||
// 处理类型映射
|
||||
const typeMap = { "LocalWindow": 0, "LocalRecord": 1, "HandleDisplay": 2, "NetworkStream": 3 };
|
||||
if (typeof sub.type === 'string') {
|
||||
form.typeIndex = typeMap[sub.type] ?? 0;
|
||||
} else {
|
||||
form.typeIndex = sub.type;
|
||||
}
|
||||
|
||||
// 视觉反馈:平滑滚动到顶部
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
// 提交订阅
|
||||
const submitSub = async () => {
|
||||
if (!form.appId) form.appId = 'SUB_' + Math.random().toString(36).substring(2, 9).toUpperCase();
|
||||
loading.value = true;
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/subscriptions`, {
|
||||
appId: form.appId,
|
||||
type: form.typeIndex,
|
||||
displayFps: form.displayFps,
|
||||
memo: form.memo,
|
||||
handle: form.handle,
|
||||
savePath: form.savePath,
|
||||
recordDuration: form.recordDuration,
|
||||
targetIp: form.targetIp,
|
||||
targetPort: form.targetPort
|
||||
});
|
||||
await loadSubs();
|
||||
} catch (e) { alert("下发失败,请检查网络或后端接口"); }
|
||||
finally { loading.value = false; }
|
||||
};
|
||||
|
||||
// 注销订阅
|
||||
const removeSub = async (appId) => {
|
||||
if(!confirm(`确定注销订阅: ${appId} ?`)) return;
|
||||
try {
|
||||
await axios.post(`${API_BASE}/api/Cameras/${deviceId.value}/subscriptions`, { appId: appId, displayFps: 0 });
|
||||
await loadSubs();
|
||||
} catch (e) { alert("注销失败"); }
|
||||
};
|
||||
|
||||
const translateType = (t) => {
|
||||
const map = ["本地窗口", "录像存储", "句柄显示", "网络转发"];
|
||||
return typeof t === 'number' ? map[t] : (map[t] || t);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.data.type === 'LOAD_SUBS_DATA') {
|
||||
deviceId.value = e.data.deviceId;
|
||||
loadSubs();
|
||||
if(pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(loadSubs, 2000); // 2秒轮询一次实际帧率
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => { if(pollTimer) clearInterval(pollTimer); });
|
||||
|
||||
return { deviceId, activeSubs, form, loading, lastUpdateTime, submitSub, removeSub, translateType, echoToForm };
|
||||
}
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user