Files
Ayay/SHH.CameraSdk/Htmls/Subscription.html
twice109 2ee25a4f7c 支持通过网页增加、删除、修改摄像头配置信息
支持摄像头配置信息中句柄的设置,并实测有效
2025-12-28 08:07:55 +08:00

281 lines
14 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>