281 lines
14 KiB
HTML
281 lines
14 KiB
HTML
<!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> |