feat: 重建 ONVIF 与 RTSP 视频接入 (#66)

This commit is contained in:
QiuSW
2026-08-14 16:50:00 +08:00
parent 5856b76de9
commit 2bb1614a3c
25 changed files with 1531 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function discoverDevices() { return request({ url: '/api/v1/admission/discover', method: 'get' }) }
export function getAdmission(deviceId) { return request({ url: `/api/v1/admission/devices/${deviceId}`, method: 'get' }) }
export function probeDevice(deviceId, data) { return request({ url: `/api/v1/admission/devices/${deviceId}/probe`, method: 'post', data }) }
@@ -0,0 +1,7 @@
export function buildProbePayload(form) {
return { address: String(form.address || '').trim(), version: Number(form.version) }
}
export function addressHasCredentials(value) {
try { return Boolean(new URL(value).username || new URL(value).password) } catch (_) { return false }
}
@@ -0,0 +1,80 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div><h3>视频接入</h3><p>选择已登记的视频设备,发现或填写 ONVIF 地址,然后验证主、子码流。</p></div>
<el-button v-permisaction="['sense:admission:discover']" type="primary" plain :loading="discovering" @click="handleDiscover">发现设备</el-button>
</div>
<el-alert title="发现只使用服务端配置的获准网卡;手工地址也只能访问获准网段。" type="info" :closable="false" show-icon />
<el-form ref="probeFormRef" :model="form" :rules="rules" label-width="120px" class="probe-form">
<el-form-item label="设备" prop="deviceId">
<el-select v-model="form.deviceId" filterable placeholder="请选择已登记的视频设备" @change="selectDevice">
<el-option v-for="device in devices" :key="device.id" :label="`${device.name} · ${device.location || '未填写位置'}`" :value="device.id" :disabled="device.status === 'disabled' || !device.onvifCredentialConfigured" />
</el-select>
<span class="field-hint">未配置凭据或已停用的设备不可探测。</span>
</el-form-item>
<el-form-item label="ONVIF 地址" prop="address">
<el-input v-model="form.address" placeholder="例如:http://设备地址/onvif/device_service" />
<span class="field-hint">地址中不能包含用户名或密码;凭据从设备管理安全读取。</span>
</el-form-item>
<el-form-item>
<el-button v-permisaction="['sense:admission:probe']" type="primary" :loading="probing" @click="handleProbe">验证接入</el-button>
<el-button @click="loadSaved">查看上次结果</el-button>
</el-form-item>
</el-form>
<el-divider />
<el-empty v-if="!result" description="尚无接入验证结果" />
<template v-else>
<el-descriptions :column="2" border>
<el-descriptions-item label="设备状态"><el-tag :type="result.status === 'ready' ? 'success' : 'warning'">{{ statusLabel(result.status) }}</el-tag></el-descriptions-item>
<el-descriptions-item label="检查时间">{{ parseTime(result.checkedAt) }}</el-descriptions-item>
<el-descriptions-item label="结果说明" :span="2">{{ result.detail }}</el-descriptions-item>
</el-descriptions>
<el-table :data="result.profiles || []" border stripe class="profile-table">
<el-table-column prop="name" label="Profile" min-width="130" />
<el-table-column label="用途" width="90"><template #default="scope"><el-tag size="small">{{ scope.row.kind === 'main' ? '主码流' : scope.row.kind === 'sub' ? '子码流' : '其他' }}</el-tag></template></el-table-column>
<el-table-column label="分辨率" width="110"><template #default="scope">{{ scope.row.width }} × {{ scope.row.height }}</template></el-table-column>
<el-table-column prop="encoding" label="编码" width="90" />
<el-table-column label="验证状态" width="130"><template #default="scope"><el-tag :type="scope.row.verificationStatus === 'ready' ? 'success' : 'danger'">{{ statusLabel(scope.row.verificationStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="verificationDetail" label="说明" min-width="180" />
</el-table>
</template>
</el-card>
<el-dialog v-model="discoveryOpen" title="发现结果" width="720px">
<el-empty v-if="!discovered.length" description="获准网卡内未发现设备" />
<el-table v-else :data="discovered.map(address => ({ address }))" border>
<el-table-column prop="address" label="ONVIF 地址" show-overflow-tooltip />
<el-table-column label="操作" width="100"><template #default="scope"><el-button type="primary" link @click="useAddress(scope.row.address)">使用</el-button></template></el-table-column>
</el-table>
</el-dialog>
</template>
</BasicLayout>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { listDevices } from '@/api/sense/device'
import { discoverDevices, getAdmission, probeDevice } from '@/api/sense/admission'
import { addressHasCredentials, buildProbePayload } from './admissionPayload'
const devices = ref([]); const result = ref(null); const discovered = ref([])
const discovering = ref(false); const probing = ref(false); const discoveryOpen = ref(false); const probeFormRef = ref()
const form = reactive({ deviceId: '', address: '', version: 0 })
const rules = { deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }], address: [{ required: true, message: '请输入 ONVIF 地址', trigger: 'blur' }, { validator: (_r, value, done) => addressHasCredentials(value) ? done(new Error('地址中不能包含用户名或密码')) : done(), trigger: 'blur' }] }
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
function selectDevice(id) { const device = devices.value.find(item => item.id === id); form.version = device?.version || 0; result.value = null }
async function loadDevices() { const response = await listDevices({ pageIndex: 1, pageSize: 100, modality: 'video' }); const payload = unwrap(response); devices.value = payload?.list || payload?.data || payload || [] }
async function handleDiscover() { discovering.value = true; try { const response = await discoverDevices(); const payload = unwrap(response); discovered.value = payload?.addresses || []; discoveryOpen.value = true } catch (error) { ElMessage.error(error.message || '发现失败,请检查获准网卡配置') } finally { discovering.value = false } }
function useAddress(address) { form.address = address; discoveryOpen.value = false }
async function handleProbe() { const valid = await probeFormRef.value?.validate().catch(() => false); if (!valid) return; probing.value = true; try { const response = await probeDevice(form.deviceId, buildProbePayload(form)); result.value = unwrap(response); const device = devices.value.find(item => item.id === form.deviceId); if (device) { device.version += 1; form.version = device.version } ElMessage.success('接入验证完成') } catch (error) { ElMessage.error(error.message || '接入验证失败') } finally { probing.value = false } }
async function loadSaved() { if (!form.deviceId) return ElMessage.warning('请先选择设备'); try { result.value = unwrap(await getAdmission(form.deviceId)) } catch (error) { ElMessage.warning(error.message || '尚无验证结果') } }
function statusLabel(status) { return ({ ready: '可用', profile_failed: '部分码流失败', authentication_failed: '认证失败', target_not_allowed: '目标未获准', redirect_rejected: '重定向已拒绝', timeout: '响应超时', clock_skew: '设备时间异常', unreachable: '无法连接', failed: '验证失败' })[status] || status || '未知' }
onMounted(loadDevices)
</script>
<style scoped>
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.probe-form{max-width:820px;margin-top:22px}.probe-form .el-select{width:100%}.field-hint{display:block;color:#909399;font-size:12px;line-height:20px}.profile-table{margin-top:18px}
</style>
+2 -1
View File
@@ -14,6 +14,7 @@
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="全部状态" clearable size="small">
<el-option label="待接入" value="pending" />
<el-option label="已接入" value="active" />
<el-option label="已停用" value="disabled" />
</el-select>
</el-form-item>
@@ -54,7 +55,7 @@
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'disabled' ? 'info' : 'success'">{{ scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
<el-tag :type="scope.row.status === 'active' ? 'success' : scope.row.status === 'disabled' ? 'info' : 'warning'">{{ scope.row.status === 'active' ? '已接入' : scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="80" align="center" prop="version" />