支持通过网页增加、删除、修改摄像头配置信息

支持摄像头配置信息中句柄的设置,并实测有效
This commit is contained in:
2025-12-28 08:07:55 +08:00
parent 3718465463
commit 2ee25a4f7c
25 changed files with 2298 additions and 75 deletions

View 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>