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

422 lines
20 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; }
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>