支持通过网页增加、删除、修改摄像头配置信息
支持摄像头配置信息中句柄的设置,并实测有效
This commit is contained in:
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