fix(优化)

This commit is contained in:
2026-08-26 14:01:04 +08:00
parent 9fbfe54f92
commit a6a6198eaa
4 changed files with 1307 additions and 51 deletions

View File

@@ -100,6 +100,19 @@ const OPS: AppRouteRecordRaw = {
roles: ['*'], roles: ['*'],
}, },
}, },
{
path: 'hikvision-video-test',
alias: ['/hikvision-video-test'],
name: 'HikvisionVideoTest',
component: () => import('@/views/ops/pages/hikvision-video-test/index.vue'),
meta: {
locale: '海康视频拉流测试',
requiresAuth: true,
roles: ['*'],
hideInMenu: true,
ignoreCache: true,
},
},
{ {
path: 'datacenter/three-machine-room/:room_id?', path: 'datacenter/three-machine-room/:room_id?',
alias: ['/datacenter/three-machine-room/:room_id?'], alias: ['/datacenter/three-machine-room/:room_id?'],

View File

@@ -22,13 +22,13 @@
{{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }} {{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }}
</a-button> </a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleCabinetDoors"> <a-button size="small" :disabled="!modelReady" @click="toggleCabinetDoors">
{{ cabinetDoorsOpen ? '关闭全部机柜门' : '打开全部机柜门' }} {{ cabinetDoorsOpen ? '关闭当前机柜门' : '打开机柜并查看设备' }}
</a-button> </a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleServers"> <a-button size="small" :disabled="!modelReady" @click="toggleServers">
{{ serversVisible ? '隐藏服务器设备' : '显示服务器设备' }} {{ serversVisible ? '隐藏服务器设备' : '显示服务器设备' }}
</a-button> </a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleAisleView"> <a-button size="small" :disabled="!modelReady" @click="toggleAisleView">
{{ insideAisle ? '返回总览' : '进入右侧过道' }} {{ insideAisle ? '返回总览' : '进入机柜过道' }}
</a-button> </a-button>
</a-space> </a-space>
@@ -45,7 +45,7 @@
活动告警 {{ signalSummary.activeAlertCount }} 活动告警 {{ signalSummary.activeAlertCount }}
</span> </span>
<span class="operation-tip"> <span class="operation-tip">
{{ insideAisle ? '滚轮前后移动,拖拽转向;点击设备查看实时信息' : '击机柜上架设备;双击门板开关' }} {{ insideAisle ? '当前斜向查看机柜正面;滚轮移动,拖拽转向' : '击机柜门可开门聚焦,并查看柜内设备信息' }}
</span> </span>
</div> </div>
</div> </div>
@@ -58,6 +58,40 @@
<a-progress v-if="modelLoading && modelProgress > 0" class="scene-loading__progress" :percent="modelProgress / 100" /> <a-progress v-if="modelLoading && modelProgress > 0" class="scene-loading__progress" :percent="modelProgress / 100" />
</div> </div>
<aside v-if="activeRack" class="rack-equipment-panel" aria-label="机柜设备信息">
<header class="rack-equipment-panel__header">
<div>
<strong>{{ activeRack.name || activeRack.code || `机柜 ${activeRack.id}` }}</strong>
<span>{{ activeRack.code || '未设置机柜编码' }}</span>
</div>
<button type="button" aria-label="关闭机柜设备信息" @click="closeActiveRackPanel">×</button>
</header>
<div class="rack-equipment-panel__summary">
<span>柜内设备 {{ activeRackDevices.length }} </span>
<span>高度 {{ activeRack.height || 42 }}U</span>
</div>
<div v-if="activeRackDevices.length" class="rack-equipment-panel__list">
<button
v-for="device in activeRackDevices"
:key="device.asset_id"
type="button"
class="rack-equipment-panel__device"
@click="handleDeviceSelected(device)"
>
<span class="rack-equipment-panel__device-main">
<strong>{{ device.asset_name || device.asset_code || `设备 ${device.asset_id}` }}</strong>
<small>{{ device.category_name || device.category_code || '未分类设备' }}</small>
</span>
<span class="rack-equipment-panel__device-meta">
<b>{{ formatDeviceUnits(device) }}</b>
<small v-if="device.power_consumption">{{ device.power_consumption }} W</small>
</span>
</button>
</div>
<div v-else class="rack-equipment-panel__empty">该机柜暂无上架设备</div>
<p class="rack-equipment-panel__tip">点击设备可查看实时指标资源绑定和告警详情</p>
</aside>
<rack-placement-dialog <rack-placement-dialog
v-model:visible="rackPlacementVisible" v-model:visible="rackPlacementVisible"
:room-id="roomId" :room-id="roomId"
@@ -152,11 +186,12 @@ const insideAisle = ref(false)
const roomScene = ref<ThreeMachineRoomScene | null>(null) const roomScene = ref<ThreeMachineRoomScene | null>(null)
const selectedRack = ref<ThreeMachineRoomRack | null>(null) const selectedRack = ref<ThreeMachineRoomRack | null>(null)
const selectedDevice = ref<ThreeMachineRoomDevice | null>(null) const selectedDevice = ref<ThreeMachineRoomDevice | null>(null)
const activeRack = ref<ThreeMachineRoomRack | null>(null)
const rackPlacementVisible = ref(false) const rackPlacementVisible = ref(false)
const rackLayoutVisible = ref(false) const rackLayoutVisible = ref(false)
const sceneConfigVisible = ref(false) const sceneConfigVisible = ref(false)
const roomDevicePlacementVisible = ref(false) const roomDevicePlacementVisible = ref(false)
const cameraPreviewVisible = ref(true) const cameraPreviewVisible = ref(false)
const deviceManagementVisible = ref(false) const deviceManagementVisible = ref(false)
const exportLoading = ref(false) const exportLoading = ref(false)
@@ -184,6 +219,7 @@ function resolveRoomId(): number {
const roomId = resolveRoomId() const roomId = resolveRoomId()
const roomName = computed(() => roomScene.value?.room?.name || `机房 ${roomId}`) const roomName = computed(() => roomScene.value?.room?.name || `机房 ${roomId}`)
const layoutVersion = computed(() => Number(roomScene.value?.room?.layout_version) || 0) const layoutVersion = computed(() => Number(roomScene.value?.room?.layout_version) || 0)
const activeRackDevices = computed(() => (Array.isArray(activeRack.value?.devices) ? activeRack.value.devices : []))
const loadingText = computed(() => { const loadingText = computed(() => {
if (modelLoading.value) { if (modelLoading.value) {
return modelProgress.value > 0 ? `3D 模型加载中 ${modelProgress.value}%` : '3D 模型加载中' return modelProgress.value > 0 ? `3D 模型加载中 ${modelProgress.value}%` : '3D 模型加载中'
@@ -199,6 +235,20 @@ function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback return error instanceof Error && error.message ? error.message : fallback
} }
/** 格式化机柜设备占用的 U 位范围。 */
function formatDeviceUnits(device: ThreeMachineRoomDevice): string {
const start = Number(device.unit_start)
if (!Number.isInteger(start) || start <= 0) return 'U位未设置'
const end = Number(device.unit_end) || start + Math.max(1, Number(device.occupied_units) || 1) - 1
return end > start ? `U${start}-U${end}` : `U${start}`
}
/** 收起机柜设备信息面板。 */
function closeActiveRackPanel(): void {
activeRack.value = null
machineRoomMap?.clearActiveCabinetSelection()
}
/** 停止设备状态轮询。 */ /** 停止设备状态轮询。 */
function stopSignalPolling(): void { function stopSignalPolling(): void {
if (signalTimer !== undefined) { if (signalTimer !== undefined) {
@@ -410,6 +460,9 @@ async function initializeMachineRoom(): Promise<void> {
onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary), onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary),
onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device), onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device),
onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack), onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack),
onCabinetDoorChanged: (rack: ThreeMachineRoomRack | null) => {
activeRack.value = rack
},
onModelLoading: (loading: boolean) => { onModelLoading: (loading: boolean) => {
modelLoading.value = loading modelLoading.value = loading
}, },
@@ -454,7 +507,7 @@ function toggleExteriorDoors(): void {
if (machineRoomMap) exteriorDoorsOpen.value = machineRoomMap.toggleExteriorDoors() if (machineRoomMap) exteriorDoorsOpen.value = machineRoomMap.toggleExteriorDoors()
} }
/** 切换全部机柜门。 */ /** 打开有设备的机柜并聚焦,或关闭当前机柜门。 */
function toggleCabinetDoors(): void { function toggleCabinetDoors(): void {
if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors() if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors()
} }
@@ -467,7 +520,7 @@ function toggleServers(): void {
/** 切换总览与过道视角。 */ /** 切换总览与过道视角。 */
function toggleAisleView(): void { function toggleAisleView(): void {
if (!machineRoomMap) return if (!machineRoomMap) return
const changed = insideAisle.value ? machineRoomMap.setOverviewView() : machineRoomMap.setAisleView() const changed = insideAisle.value ? machineRoomMap.setOverviewView() : machineRoomMap.setAisleView('right')
if (changed) { if (changed) {
insideAisle.value = !insideAisle.value insideAisle.value = !insideAisle.value
} else { } else {
@@ -614,6 +667,146 @@ export default {
width: 280px; width: 280px;
} }
} }
.rack-equipment-panel {
position: absolute;
z-index: 9;
right: 22px;
bottom: 22px;
width: min(360px, calc(100vw - 44px));
max-height: calc(100vh - 150px);
overflow: hidden;
border: 1px solid rgba(51, 211, 255, 0.5);
border-radius: 8px;
background: linear-gradient(150deg, rgba(3, 52, 79, 0.97), rgba(3, 17, 42, 0.97));
box-shadow:
0 16px 42px rgba(0, 5, 24, 0.65),
0 0 26px rgba(28, 199, 255, 0.14);
backdrop-filter: blur(10px);
&__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 12px;
border-bottom: 1px solid rgba(56, 211, 255, 0.18);
div {
display: flex;
flex-direction: column;
gap: 3px;
}
strong {
color: #e7faff;
font-size: 16px;
}
span {
color: #7fc7df;
font-size: 11px;
}
button {
width: 28px;
height: 28px;
border: 0;
border-radius: 50%;
color: #a5d9e9;
background: rgba(55, 171, 207, 0.12);
font-size: 20px;
line-height: 26px;
cursor: pointer;
}
}
&__summary {
display: flex;
justify-content: space-between;
padding: 9px 16px;
color: #8fcde1;
background: rgba(28, 157, 196, 0.08);
font-size: 12px;
}
&__list {
max-height: min(390px, calc(100vh - 300px));
padding: 8px;
overflow-y: auto;
}
&__device {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 11px;
border: 1px solid transparent;
border-radius: 5px;
color: inherit;
background: transparent;
text-align: left;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: rgba(43, 211, 255, 0.36);
background: rgba(22, 164, 205, 0.12);
outline: none;
}
}
&__device-main,
&__device-meta {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
&__device-main {
strong {
overflow: hidden;
color: #e4f8ff;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
small {
color: #779fb0;
}
}
&__device-meta {
flex: 0 0 auto;
align-items: flex-end;
b {
color: #40ddff;
font-size: 12px;
}
small {
color: #94adba;
}
}
&__empty {
padding: 28px 16px;
color: #789cac;
text-align: center;
}
&__tip {
margin: 0;
padding: 9px 16px 11px;
border-top: 1px solid rgba(56, 211, 255, 0.12);
color: #6fa7ba;
font-size: 11px;
}
}
} }
.rack-device-callout-layer { .rack-device-callout-layer {

View File

@@ -52,6 +52,9 @@ export default class ThreeMap {
this.outerWallMeshes = [] this.outerWallMeshes = []
this.exteriorDoorPanels = [] this.exteriorDoorPanels = []
this.cabinetDoorPanels = [] this.cabinetDoorPanels = []
this.cabinetDisplayPanels = []
this.cabinetRowPairs = []
this.cabinetShellMeshes = []
this.roomSceneData = null this.roomSceneData = null
this.roomSignalData = null this.roomSignalData = null
this.rackPanelMap = new Map() this.rackPanelMap = new Map()
@@ -64,6 +67,7 @@ export default class ThreeMap {
this.deviceCallouts = new Map() this.deviceCallouts = new Map()
this.hoveredEquipment = null this.hoveredEquipment = null
this.selectedEquipment = null this.selectedEquipment = null
this.activeRackId = null
this._singleClickTimer = null this._singleClickTimer = null
this._dbClickResetTimer = null this._dbClickResetTimer = null
@@ -388,6 +392,10 @@ export default class ThreeMap {
this.deviceSignals.clear() this.deviceSignals.clear()
this.deviceObservability.clear() this.deviceObservability.clear()
this.rackPanelMap.clear() this.rackPanelMap.clear()
this.cabinetDisplayPanels = []
this.cabinetRowPairs = []
this.cabinetShellMeshes = []
this.activeRackId = null
this._onMouseDown = null this._onMouseDown = null
this._onMouseMove = null this._onMouseMove = null
} }
@@ -738,6 +746,56 @@ export default class ThreeMap {
return panel return panel
} }
/** 为外墙门克隆独立材质,避免开门效果污染墙体和其他门板。 */
prepareExteriorDoorMaterials(panel) {
panel.materialStates = []
panel.meshes.forEach((mesh) => {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const clonedMaterials = materials.map((material) => material.clone())
mesh.material = Array.isArray(mesh.material) ? clonedMaterials : clonedMaterials[0]
panel.materialStates.push(
clonedMaterials.map((material) => ({
color: material.color ? material.color.clone() : null,
emissive: material.emissive ? material.emissive.clone() : null,
emissiveIntensity: material.emissiveIntensity,
opacity: material.opacity,
transparent: material.transparent,
depthWrite: material.depthWrite,
side: material.side,
}))
)
})
}
/** 打开大门时使用半透明蓝灰内侧,关闭后恢复模型原材质。 */
setExteriorDoorAppearance(panel, open) {
if (!panel || !panel.materialStates) {
return
}
panel.meshes.forEach((mesh, meshIndex) => {
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const states = panel.materialStates[meshIndex] || []
materials.forEach((material, materialIndex) => {
const state = states[materialIndex]
if (!state) {
return
}
if (material.color && state.color) {
material.color.copy(open ? new THREE.Color(0x4f7f92) : state.color)
}
if (material.emissive && state.emissive) {
material.emissive.copy(open ? new THREE.Color(0x0a2533) : state.emissive)
material.emissiveIntensity = open ? 0.16 : state.emissiveIntensity
}
material.transparent = open ? true : state.transparent
material.opacity = open ? 0.68 : state.opacity
material.depthWrite = open ? false : state.depthWrite
material.side = open ? THREE.DoubleSide : state.side
material.needsUpdate = true
})
})
}
createExteriorDoorPanels(object, meshEntries) { createExteriorDoorPanels(object, meshEntries) {
const meshByName = {} const meshByName = {}
meshEntries.forEach((entry) => { meshEntries.forEach((entry) => {
@@ -782,6 +840,7 @@ export default class ThreeMap {
'exterior' 'exterior'
) )
if (panel) { if (panel) {
this.prepareExteriorDoorMaterials(panel)
panels.push(panel) panels.push(panel)
} }
}) })
@@ -790,9 +849,7 @@ export default class ThreeMap {
createCabinetDoorPanels(object, meshEntries, modelBox) { createCabinetDoorPanels(object, meshEntries, modelBox) {
const modelSize = new THREE.Vector3() const modelSize = new THREE.Vector3()
const modelCenter = new THREE.Vector3()
modelBox.getSize(modelSize) modelBox.getSize(modelSize)
modelBox.getCenter(modelCenter)
const candidates = meshEntries.filter((entry) => { const candidates = meshEntries.filter((entry) => {
const size = new THREE.Vector3() const size = new THREE.Vector3()
entry.box.getSize(size) entry.box.getSize(size)
@@ -820,7 +877,7 @@ export default class ThreeMap {
const center = new THREE.Vector3() const center = new THREE.Vector3()
entry.box.getCenter(center) entry.box.getCenter(center)
let cluster = clusters.find( let cluster = clusters.find(
(item) => Math.abs(item.centerX - center.x) < modelSize.x * 0.009 && Math.abs(item.centerZ - center.z) < modelSize.z * 0.022 (item) => Math.abs(item.centerX - center.x) < modelSize.x * 0.014 && Math.abs(item.centerZ - center.z) < modelSize.z * 0.022
) )
if (!cluster) { if (!cluster) {
cluster = { centerX: center.x, centerZ: center.z, entries: [] } cluster = { centerX: center.x, centerZ: center.z, entries: [] }
@@ -832,14 +889,13 @@ export default class ThreeMap {
return clusters return clusters
.map((cluster, index) => { .map((cluster, index) => {
const box = this.getCombinedBox(cluster.entries) const box = this.getCombinedBox(cluster.entries)
const openAngle = cluster.centerX < modelCenter.x ? -0.5 * Math.PI : 0.5 * Math.PI
return this.createImportedDoorPanel( return this.createImportedDoorPanel(
object, object,
'cabinet_door_' + (index + 1), 'cabinet_door_' + (index + 1),
cluster.entries, cluster.entries,
(box.min.x + box.max.x) / 2, (box.min.x + box.max.x) / 2,
box.min.z, box.min.z,
openAngle, 0.5 * Math.PI,
'cabinet' 'cabinet'
) )
}) })
@@ -865,18 +921,139 @@ export default class ThreeMap {
}) })
xLevels.sort((a, b) => a.centerX - b.centerX) xLevels.sort((a, b) => a.centerX - b.centerX)
xLevels.forEach((level, index) => { this.cabinetRowPairs = []
let direction this.cabinetDisplayPanels = []
if (index % 2 === 0 && index + 1 < xLevels.length) {
direction = 1 for (let index = 0; index < xLevels.length; index += 2) {
} else if (index % 2 === 1) { const leftLevel = xLevels[index]
direction = -1 const rightLevel = xLevels[index + 1]
} else { if (!rightLevel) {
direction = level.centerX < modelCenter.x ? 1 : -1 const fallbackDirection = leftLevel.centerX < modelCenter.x ? 1 : -1
leftLevel.panels.forEach((panel) => {
panel.interiorDirection = fallbackDirection
panel.openAngle = -fallbackDirection * Math.PI * 0.58
this.cabinetDisplayPanels.push(panel)
})
continue
} }
level.panels.forEach((panel) => {
panel.interiorDirection = direction const rowPair = {
index: this.cabinetRowPairs.length,
leftLevel: leftLevel,
rightLevel: rightLevel,
}
this.cabinetRowPairs.push(rowPair)
leftLevel.panels.forEach((panel) => {
panel.interiorDirection = 1
panel.openAngle = -Math.PI * 0.58
panel.cabinetRowIndex = rowPair.index
panel.cabinetSide = 'left'
}) })
rightLevel.panels.forEach((panel) => {
panel.interiorDirection = -1
panel.openAngle = Math.PI * 0.58
panel.cabinetRowIndex = rowPair.index
panel.cabinetSide = 'right'
})
leftLevel.panels.forEach((leftPanel) => {
const leftZ = leftPanel.box.getCenter(new THREE.Vector3()).z
const mate = rightLevel.panels.find((rightPanel) => {
const rightZ = rightPanel.box.getCenter(new THREE.Vector3()).z
return Math.abs(rightZ - leftZ) < modelSize.z * 0.035
})
if (mate) {
leftPanel.cabinetMate = mate
mate.cabinetMate = leftPanel
}
})
}
this.cabinetRowPairs.forEach((rowPair, index) => {
const previousRow = this.cabinetRowPairs[index - 1]
const nextRow = this.cabinetRowPairs[index + 1]
const leftGap = previousRow ? rowPair.leftLevel.centerX - previousRow.rightLevel.centerX : -1
const rightGap = nextRow ? nextRow.leftLevel.centerX - rowPair.rightLevel.centerX : -1
const displayLevel = rightGap >= leftGap ? rowPair.rightLevel : rowPair.leftLevel
displayLevel.panels.forEach((panel) => {
panel.isDisplayPanel = true
this.cabinetDisplayPanels.push(panel)
})
})
}
/** 将每扇机柜门关联到对应柜体,开门时淡化封闭外壳以露出内部设备。 */
assignCabinetShells(panels, meshEntries, modelBox) {
const modelSize = modelBox.getSize(new THREE.Vector3())
const shellEntries = meshEntries.filter((entry) => {
const size = entry.box.getSize(new THREE.Vector3())
return (
entry.mesh.name.indexOf('Obj3d66_11807706_1509_') === 0 &&
size.x > modelSize.x * 0.04 &&
size.x < modelSize.x * 0.14 &&
size.y > modelSize.y * 0.42 &&
size.z > modelSize.z * 0.025 &&
size.z < modelSize.z * 0.12
)
})
this.cabinetShellMeshes = shellEntries.map((entry) => {
const mesh = entry.mesh
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
const clonedMaterials = materials.map((material) => material.clone())
mesh.material = Array.isArray(mesh.material) ? clonedMaterials : clonedMaterials[0]
mesh.userData.cabinetShellMaterialState = clonedMaterials.map((material) => ({
opacity: material.opacity,
transparent: material.transparent,
depthWrite: material.depthWrite,
}))
mesh.userData.openCabinetDoorCount = 0
return mesh
})
panels.forEach((panel) => {
const center = panel.box.getCenter(new THREE.Vector3())
const matchingEntry = shellEntries
.filter(
(entry) =>
center.x >= entry.box.min.x - modelSize.x * 0.01 &&
center.x <= entry.box.max.x + modelSize.x * 0.01 &&
center.z >= entry.box.min.z - modelSize.z * 0.01 &&
center.z <= entry.box.max.z + modelSize.z * 0.01
)
.sort((a, b) => {
const aCenter = a.box.getCenter(new THREE.Vector3())
const bCenter = b.box.getCenter(new THREE.Vector3())
return (
Math.abs(aCenter.x - center.x) +
Math.abs(aCenter.z - center.z) -
Math.abs(bCenter.x - center.x) -
Math.abs(bCenter.z - center.z)
)
})[0]
panel.cabinetShell = matchingEntry ? matchingEntry.mesh : null
})
}
/** 根据机柜门状态隐藏或恢复柜体外壳。 */
setCabinetShellOpen(panel, open) {
const shell = panel && panel.cabinetShell
if (!shell) {
return
}
const currentCount = Number(shell.userData.openCabinetDoorCount) || 0
shell.userData.openCabinetDoorCount = Math.max(0, currentCount + (open ? 1 : -1))
const shouldFade = shell.userData.openCabinetDoorCount > 0
shell.visible = !shouldFade
const materials = Array.isArray(shell.material) ? shell.material : [shell.material]
const states = shell.userData.cabinetShellMaterialState || []
materials.forEach((material, index) => {
const state = states[index] || { opacity: 1, transparent: false, depthWrite: true }
material.transparent = shouldFade ? true : state.transparent
material.opacity = shouldFade ? 0.035 : state.opacity
material.depthWrite = shouldFade ? false : state.depthWrite
material.needsUpdate = true
}) })
} }
@@ -897,23 +1074,29 @@ export default class ThreeMap {
sortSceneRacks(racks) { sortSceneRacks(racks) {
return (racks || []).slice().sort((a, b) => { return (racks || []).slice().sort((a, b) => {
const rowDiff = (a.row || 0) - (b.row || 0) const aHasLayout = Number(a.row) > 0 || Number(a.column) > 0
if (rowDiff !== 0) { const bHasLayout = Number(b.row) > 0 || Number(b.column) > 0
return rowDiff if (aHasLayout && bHasLayout) {
} const rowDiff = (a.row || 0) - (b.row || 0)
const columnDiff = (a.column || 0) - (b.column || 0) if (rowDiff !== 0) {
if (columnDiff !== 0) { return rowDiff
return columnDiff }
const columnDiff = (a.column || 0) - (b.column || 0)
if (columnDiff !== 0) {
return columnDiff
}
} }
const codeDiff = String(a.code || a.name || '').localeCompare(String(b.code || b.name || ''), 'zh-CN', { numeric: true })
if (codeDiff !== 0) return codeDiff
return (a.id || 0) - (b.id || 0) return (a.id || 0) - (b.id || 0)
}) })
} }
bindSceneRacksToPanels(panels, racks) { bindSceneRacksToPanels(panels, racks) {
const sortedPanels = this.sortCabinetPanels(panels) const sortedPanels = this.sortCabinetPanels(this.cabinetDisplayPanels.length ? this.cabinetDisplayPanels : panels)
const sortedRacks = this.sortSceneRacks(racks) const sortedRacks = this.sortSceneRacks(racks)
this.rackPanelMap.clear() this.rackPanelMap.clear()
sortedPanels.forEach((panel) => { panels.forEach((panel) => {
panel.rackData = null panel.rackData = null
panel.rackId = null panel.rackId = null
panel.meshes.forEach((mesh) => { panel.meshes.forEach((mesh) => {
@@ -1388,8 +1571,8 @@ export default class ThreeMap {
const rackBottom = panel.box.min.y + panelSize.y * 0.09 const rackBottom = panel.box.min.y + panelSize.y * 0.09
const serverDepth = Math.max(modelSize.x * 0.004, panelSize.x * 2) const serverDepth = Math.max(modelSize.x * 0.004, panelSize.x * 2)
const serverWidth = panelSize.z * 0.78 const serverWidth = panelSize.z * 0.78
const interiorOffset = serverDepth * 0.35 const exteriorOffset = serverDepth * 0.62
const centerX = panelCenter.x + panel.interiorDirection * interiorOffset const centerX = panelCenter.x - panel.interiorDirection * exteriorOffset
rack.devices.forEach((device, deviceIndex) => { rack.devices.forEach((device, deviceIndex) => {
const startUnit = Math.max(1, Number(device.unit_start) || 1) const startUnit = Math.max(1, Number(device.unit_start) || 1)
@@ -1421,11 +1604,17 @@ export default class ThreeMap {
equipmentBodyMaterial, equipmentBodyMaterial,
] ]
const server = new THREE.Mesh(geometry, materials) const server = new THREE.Mesh(geometry, materials)
const outline = new THREE.LineSegments(
new THREE.EdgesGeometry(geometry),
new THREE.LineBasicMaterial({ color: 0x35d9ff, transparent: true, opacity: 0.82 })
)
outline.raycast = function () {}
server.add(outline)
server.name = 'rack_device_' + device.asset_id server.name = 'rack_device_' + device.asset_id
server.position.set(centerX, centerY, panelCenter.z) server.position.set(centerX, centerY, panelCenter.z)
server.castShadow = true server.castShadow = true
server.receiveShadow = true server.receiveShadow = true
server.visible = this.serverEquipmentVisible server.visible = this.serverEquipmentVisible && panel.isOpen
server.userData.equipmentType = appearance.type server.userData.equipmentType = appearance.type
server.userData.cabinetDoor = panel.name server.userData.cabinetDoor = panel.name
server.userData.rackId = rack.id server.userData.rackId = rack.id
@@ -1519,6 +1708,15 @@ export default class ThreeMap {
if (equipment.geometry) { if (equipment.geometry) {
geometries.add(equipment.geometry) geometries.add(equipment.geometry)
} }
equipment.children.forEach((child) => {
if (child.geometry) {
geometries.add(child.geometry)
}
const childMaterials = Array.isArray(child.material) ? child.material : [child.material]
childMaterials.forEach((material) => {
if (material) materials.add(material)
})
})
const equipmentMaterials = Array.isArray(equipment.material) ? equipment.material : [equipment.material] const equipmentMaterials = Array.isArray(equipment.material) ? equipment.material : [equipment.material]
equipmentMaterials.forEach((material) => { equipmentMaterials.forEach((material) => {
if (material) { if (material) {
@@ -1614,6 +1812,7 @@ export default class ThreeMap {
this.exteriorDoorPanels = this.createExteriorDoorPanels(object, modelInfo.meshes) this.exteriorDoorPanels = this.createExteriorDoorPanels(object, modelInfo.meshes)
this.cabinetDoorPanels = this.createCabinetDoorPanels(object, modelInfo.meshes, modelInfo.box) this.cabinetDoorPanels = this.createCabinetDoorPanels(object, modelInfo.meshes, modelInfo.box)
this.assignCabinetInteriorDirections(this.cabinetDoorPanels, modelInfo.box) this.assignCabinetInteriorDirections(this.cabinetDoorPanels, modelInfo.box)
this.assignCabinetShells(this.cabinetDoorPanels, modelInfo.meshes, modelInfo.box)
this.serverEquipment = [] this.serverEquipment = []
const doorMeshes = new Set() const doorMeshes = new Set()
this.exteriorDoorPanels.concat(this.cabinetDoorPanels).forEach((panel) => { this.exteriorDoorPanels.concat(this.cabinetDoorPanels).forEach((panel) => {
@@ -1654,11 +1853,71 @@ export default class ThreeMap {
setServerEquipmentVisible(visible) { setServerEquipmentVisible(visible) {
this.serverEquipmentVisible = visible !== false this.serverEquipmentVisible = visible !== false
this.serverEquipment.forEach((server) => { this.serverEquipment.forEach((server) => {
server.visible = this.serverEquipmentVisible const panel = this.cabinetDoorPanels.find((item) => item.name === server.userData.cabinetDoor)
server.visible = this.serverEquipmentVisible && (!panel || panel.isOpen)
}) })
return this.serverEquipmentVisible return this.serverEquipmentVisible
} }
/** 机柜门开关后同步柜内设备的可见状态。 */
updateCabinetEquipmentVisibility(panel) {
if (!panel) return
this.serverEquipment.forEach((server) => {
if (server.userData.cabinetDoor === panel.name) {
server.visible = this.serverEquipmentVisible && panel.isOpen
}
})
}
/** 将模型局部坐标转换为场景世界坐标。 */
getImportedModelWorldPoint(point) {
this.importedModel.updateMatrixWorld(true)
return this.importedModel.localToWorld(point.clone())
}
/** 通知页面展示或收起当前机柜的设备清单。 */
setActiveCabinetPanel(panel) {
const rack = panel && panel.rackData ? panel.rackData : null
this.activeRackId = rack && rack.id !== undefined ? String(rack.id) : null
if (typeof this.props.onCabinetDoorChanged === 'function') {
this.props.onCabinetDoorChanged(rack)
}
}
/** 由页面主动收起机柜设备清单。 */
clearActiveCabinetSelection() {
this.setActiveCabinetPanel(null)
}
/** 将相机移动到门板正前方,清楚展示机柜内部。 */
focusCabinetPanel(panel) {
if (!panel || !this.importedModel || !this.camera || !this.camera.isPerspectiveCamera || !this.controls) {
return false
}
this.importedModel.updateMatrixWorld(true)
const panelSize = panel.box.getSize(new THREE.Vector3())
const panelCenter = panel.box.getCenter(new THREE.Vector3())
const worldScale = this.importedModel.getWorldScale(new THREE.Vector3())
const rotationMatrix = new THREE.Matrix4().extractRotation(this.importedModel.matrixWorld)
const exteriorDirection = new THREE.Vector3(-panel.interiorDirection, 0, 0).applyMatrix4(rotationMatrix).normalize()
const interiorDirection = exteriorDirection.clone().multiplyScalar(-1)
const worldCenter = this.getImportedModelWorldPoint(panelCenter)
const worldHeight = panelSize.y * Math.abs(worldScale.y)
const position = worldCenter.clone().add(exteriorDirection.multiplyScalar(worldHeight * 1.52))
position.y += worldHeight * 0.06
const target = worldCenter.clone().add(interiorDirection.multiplyScalar(worldHeight * 0.035))
this.camera.fov = 46
this.camera.near = Math.max(0.1, worldHeight / 1000)
this.camera.far = Math.max(this.camera.far, worldHeight * 30)
this.camera.updateProjectionMatrix()
this.controls.minDistance = Math.max(1, worldHeight * 0.08)
this.controls.maxDistance = worldHeight * 5
this.moveCameraTo(position, target, 720)
this.viewMode = 'cabinet'
return true
}
setImportedDoorState(panel, open) { setImportedDoorState(panel, open) {
if (!panel) { if (!panel) {
return false return false
@@ -1670,7 +1929,14 @@ export default class ThreeMap {
if (panel.tween) { if (panel.tween) {
panel.tween.stop() panel.tween.stop()
} }
const wasOpen = panel.isOpen
panel.isOpen = shouldOpen panel.isOpen = shouldOpen
if (panel.type === 'exterior') {
this.setExteriorDoorAppearance(panel, shouldOpen)
} else if (panel.type === 'cabinet' && wasOpen !== shouldOpen) {
this.setCabinetShellOpen(panel, shouldOpen)
this.updateCabinetEquipmentVisibility(panel)
}
panel.tween = new TWEEN.Tween(panel.pivot.rotation) panel.tween = new TWEEN.Tween(panel.pivot.rotation)
.to({ y: shouldOpen ? panel.openAngle : 0 }, 480) .to({ y: shouldOpen ? panel.openAngle : 0 }, 480)
.easing(TWEEN.Easing.Quadratic.Out) .easing(TWEEN.Easing.Quadratic.Out)
@@ -1682,7 +1948,16 @@ export default class ThreeMap {
} }
toggleImportedDoor(panel) { toggleImportedDoor(panel) {
return this.setImportedDoorState(panel, !panel.isOpen) const shouldOpen = this.setImportedDoorState(panel, !panel.isOpen)
if (panel && panel.type === 'cabinet') {
if (shouldOpen) {
this.setActiveCabinetPanel(panel)
this.focusCabinetPanel(panel)
} else if (this.activeRackId && panel.rackId !== undefined && String(panel.rackId) === this.activeRackId) {
this.setActiveCabinetPanel(null)
}
}
return shouldOpen
} }
toggleExteriorDoors(forceOpen) { toggleExteriorDoors(forceOpen) {
@@ -1698,8 +1973,26 @@ export default class ThreeMap {
if (this.cabinetDoorPanels.length === 0) { if (this.cabinetDoorPanels.length === 0) {
return false return false
} }
const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !this.cabinetDoorPanels.every((panel) => panel.isOpen) const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : !this.cabinetDoorPanels.some((panel) => panel.isOpen)
this.cabinetDoorPanels.forEach((panel) => this.setImportedDoorState(panel, shouldOpen)) if (shouldOpen) {
const modelCenter = this.importedModelBox ? this.importedModelBox.getCenter(new THREE.Vector3()) : new THREE.Vector3()
const equipmentPanel =
this.cabinetDisplayPanels
.filter((panel) => panel.rackData && Array.isArray(panel.rackData.devices) && panel.rackData.devices.length > 0)
.sort((a, b) => {
const aCenter = this.getImportedModelWorldPoint(a.box.getCenter(new THREE.Vector3()))
const bCenter = this.getImportedModelWorldPoint(b.box.getCenter(new THREE.Vector3()))
return Math.abs(aCenter.z - modelCenter.z) - Math.abs(bCenter.z - modelCenter.z)
})[0] || this.cabinetDisplayPanels.find((panel) => panel.rackData)
if (equipmentPanel) {
this.cabinetDoorPanels.forEach((panel) => this.setImportedDoorState(panel, panel === equipmentPanel))
this.setActiveCabinetPanel(equipmentPanel)
this.focusCabinetPanel(equipmentPanel)
}
} else {
this.cabinetDoorPanels.forEach((panel) => this.setImportedDoorState(panel, false))
this.setActiveCabinetPanel(null)
}
return shouldOpen return shouldOpen
} }
@@ -1866,36 +2159,75 @@ export default class ThreeMap {
this.controls.minDistance = this.overviewView.minDistance this.controls.minDistance = this.overviewView.minDistance
this.controls.maxDistance = this.overviewView.maxDistance this.controls.maxDistance = this.overviewView.maxDistance
this.moveCameraTo(this.overviewView.position, this.overviewView.target, 760) this.moveCameraTo(this.overviewView.position, this.overviewView.target, 760)
this.setActiveCabinetPanel(null)
this.viewMode = 'overview' this.viewMode = 'overview'
return true return true
} }
setAisleView() { /** 根据机柜排之间的实际空隙选择最宽的可行走过道。 */
getPreferredCabinetAisle() {
if (this.cabinetRowPairs.length < 2) {
return null
}
const aisleList = []
for (let index = 0; index < this.cabinetRowPairs.length - 1; index += 1) {
const leftRow = this.cabinetRowPairs[index]
const rightRow = this.cabinetRowPairs[index + 1]
const leftX = leftRow.rightLevel.centerX
const rightX = rightRow.leftLevel.centerX
aisleList.push({
leftX: leftX,
rightX: rightX,
width: rightX - leftX,
panels: leftRow.rightLevel.panels.concat(rightRow.leftLevel.panels),
})
}
return aisleList.sort((a, b) => b.width - a.width)[0] || null
}
/** 切换到最宽过道,并斜向观察指定一侧的机柜门。 */
setAisleView(viewSide) {
if (!this.importedModel || !this.camera || !this.camera.isPerspectiveCamera) { if (!this.importedModel || !this.camera || !this.camera.isPerspectiveCamera) {
return false return false
} }
var box = this.importedModelBox ? this.importedModelBox.clone() : new THREE.Box3().setFromObject(this.importedModel) var box = this.getMeshListAndBox(this.importedModel).box
var size = new THREE.Vector3() var size = new THREE.Vector3()
var center = new THREE.Vector3() var center = new THREE.Vector3()
box.getSize(size) box.getSize(size)
box.getCenter(center) box.getCenter(center)
var aisleOptions = (this.importedModelOptions && this.importedModelOptions.aisleView) || {} var aisleOptions = (this.importedModelOptions && this.importedModelOptions.aisleView) || {}
var xRatio = aisleOptions.xRatio === undefined ? 0.18 : aisleOptions.xRatio var preferredAisle = this.getPreferredCabinetAisle()
var startZRatio = aisleOptions.startZRatio === undefined ? 0.82 : aisleOptions.startZRatio var eyeHeightRatio = aisleOptions.eyeHeightRatio === undefined ? 0.43 : aisleOptions.eyeHeightRatio
var targetZRatio = aisleOptions.targetZRatio === undefined ? 0.28 : aisleOptions.targetZRatio
var eyeHeightRatio = aisleOptions.eyeHeightRatio === undefined ? 0.46 : aisleOptions.eyeHeightRatio
var eyeY = box.min.y + size.y * eyeHeightRatio var eyeY = box.min.y + size.y * eyeHeightRatio
var aisleX = center.x + size.x * xRatio var aisleX = preferredAisle ? (preferredAisle.leftX + preferredAisle.rightX) / 2 : center.x
var position = new THREE.Vector3(aisleX, eyeY, box.min.z + size.z * startZRatio) var targetX = aisleX
var target = new THREE.Vector3(aisleX, eyeY, box.min.z + size.z * targetZRatio) var startZ = box.min.z + size.z * 0.82
var targetZ = box.min.z + size.z * 0.34
this.camera.fov = aisleOptions.fov || 58 if (preferredAisle && preferredAisle.panels.length > 0) {
this.camera.near = Math.max(0.1, size.y / 2000) const doorBox = new THREE.Box3().makeEmpty()
this.camera.far = Math.max(size.x, size.y, size.z) * 12 preferredAisle.panels.forEach((panel) => {
doorBox.expandByPoint(panel.box.min)
doorBox.expandByPoint(panel.box.max)
})
const doorSize = doorBox.getSize(new THREE.Vector3())
startZ = Math.min(box.max.z - size.z * 0.04, doorBox.max.z + Math.max(doorSize.z / preferredAisle.panels.length, size.z * 0.06))
targetZ = doorBox.min.z + doorSize.z * 0.44
targetX = viewSide === 'left' ? preferredAisle.leftX : preferredAisle.rightX
}
var position = this.getImportedModelWorldPoint(new THREE.Vector3(aisleX, eyeY, startZ))
var target = this.getImportedModelWorldPoint(new THREE.Vector3(targetX, eyeY - size.y * 0.035, targetZ))
const worldSize = this.importedModel.getWorldScale(new THREE.Vector3()).multiply(size)
this.camera.fov = aisleOptions.fov || 54
this.camera.near = Math.max(0.1, worldSize.y / 2000)
this.camera.far = Math.max(worldSize.x, worldSize.y, worldSize.z) * 12
this.camera.updateProjectionMatrix() this.camera.updateProjectionMatrix()
this.controls.minDistance = Math.max(1, size.y * 0.01) this.controls.minDistance = Math.max(1, worldSize.y * 0.01)
this.controls.maxDistance = Math.max(size.x, size.z) * 2 this.controls.maxDistance = Math.max(worldSize.x, worldSize.z) * 2
this.moveCameraTo(position, target, 820) this.moveCameraTo(position, target, 820)
this.setActiveCabinetPanel(null)
this.viewMode = 'aisle' this.viewMode = 'aisle'
return true return true
} }

View File

@@ -0,0 +1,718 @@
<template>
<div class="hikvision-test">
<div class="page-heading">
<div>
<h2>海康视频拉流测试</h2>
<p>通过海康 WebSDK 登录摄像机或录像机验证实时预览通道及码流配置</p>
</div>
<a-tag :color="statusColor" size="large">
<span class="status-dot" :class="{ 'is-live': playerState === 'playing' }"></span>
{{ statusText }}
</a-tag>
</div>
<a-row :gutter="16" class="content-grid">
<a-col :span="8">
<a-card class="config-card" title="连接参数" :bordered="false">
<a-alert v-if="proxyTip" type="warning" class="proxy-alert">
{{ proxyTip }}
</a-alert>
<a-form :model="connection" layout="vertical" @submit-success="startPreview">
<a-form-item field="host" label="设备地址" :rules="[{ required: true, message: '请输入摄像机或录像机 IP / 域名' }]">
<a-input v-model.trim="connection.host" allow-clear placeholder="例如192.168.1.101" />
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item field="protocol" label="登录协议">
<a-select v-model="connection.protocol">
<a-option :value="1">HTTP</a-option>
<a-option :value="2">HTTPS</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="httpPort" label="HTTP(S) 端口">
<a-input-number v-model="connection.httpPort" :min="1" :max="65535" hide-button style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item field="rtspPort" label="RTSP 端口">
<a-input-number v-model="connection.rtspPort" :min="1" :max="65535" hide-button style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="webSocketPort" label="WebSocket 端口">
<a-input-number
v-model="connection.webSocketPort"
:min="1"
:max="65535"
hide-button
placeholder="自动探测"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="username" label="用户名" :rules="[{ required: true, message: '请输入设备用户名' }]">
<a-input v-model.trim="connection.username" allow-clear autocomplete="username" placeholder="例如admin" />
</a-form-item>
<a-form-item field="password" label="密码" :rules="[{ required: true, message: '请输入设备密码' }]">
<a-input-password
v-model="connection.password"
allow-clear
autocomplete="new-password"
placeholder="仅用于本次播放,不会保存"
/>
</a-form-item>
<a-row :gutter="12">
<a-col :span="12">
<a-form-item field="channelId" label="通道号">
<a-input-number v-model="connection.channelId" :min="1" :precision="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="streamType" label="码流类型">
<a-select v-model="connection.streamType">
<a-option :value="1">主码流</a-option>
<a-option :value="2">子码流</a-option>
<a-option :value="3">第三码流</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="secretKey" label="码流加密密钥(可选)">
<a-input-password v-model="connection.secretKey" allow-clear placeholder="设备开启码流加密时填写" />
</a-form-item>
<div class="proxy-setting">
<div>
<strong>同源代理</strong>
<p>跨域或 HTTPS 页面连接设备时建议开启</p>
</div>
<a-switch v-model="connection.proxyEnabled" />
</div>
<div class="form-actions">
<a-button :disabled="busy || playerState === 'playing'" @click="resetConnection">
<template #icon><icon-refresh /></template>
重置
</a-button>
<a-button html-type="submit" type="primary" :loading="busy">
<template #icon><icon-play-circle /></template>
{{ playerState === 'playing' ? '重新拉流' : '开始拉流' }}
</a-button>
</div>
</a-form>
</a-card>
</a-col>
<a-col :span="16">
<a-card class="preview-card" :bordered="false">
<template #title>
<div class="card-title">
<span>实时预览</span>
<span v-if="playerState === 'playing'" class="live-badge">
<i></i>
LIVE
</span>
</div>
</template>
<template #extra>
<a-space>
<a-button size="small" :disabled="playerState !== 'playing'" @click="stopPreview">
<template #icon><icon-stop /></template>
停止
</a-button>
<a-button size="small" :disabled="playerState !== 'playing'" @click="enterFullscreen">
<template #icon><icon-fullscreen /></template>
全屏
</a-button>
</a-space>
</template>
<div ref="playerWrapper" class="player-wrapper">
<div :id="PLAYER_CONTAINER_ID" class="player-canvas"></div>
<div v-if="playerState !== 'playing'" class="player-placeholder" :class="{ 'is-error': playerState === 'error' }">
<a-spin v-if="busy" :size="32" />
<div v-else class="placeholder-icon">{{ playerState === 'error' ? '!' : '▶' }}</div>
<strong>{{ placeholderTitle }}</strong>
<span>{{ placeholderDescription }}</span>
</div>
</div>
<div class="connection-summary">
<span>
<b>目标</b>
{{ connection.host || '-' }}
</span>
<span>
<b>通道</b>
{{ connection.channelId }}
</span>
<span>
<b>码流</b>
{{ streamTypeText }}
</span>
<span>
<b>模式</b>
{{ connection.proxyEnabled ? '同源代理' : '设备直连' }}
</span>
</div>
</a-card>
<a-card class="log-card" :bordered="false">
<template #title>运行日志</template>
<template #extra>
<a-button type="text" size="small" :disabled="!logs.length" @click="logs = []">清空</a-button>
</template>
<div class="log-list">
<div v-if="!logs.length" class="empty-log">开始拉流后将在这里显示运行状态</div>
<div v-for="(log, index) in logs" :key="`${log.time}-${index}`" class="log-item" :class="`is-${log.level}`">
<time>{{ log.time }}</time>
<span>{{ log.message }}</span>
</div>
</div>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { IconFullscreen, IconPlayCircle, IconRefresh, IconStop } from '@arco-design/web-vue/es/icon'
import {
destroyHikvisionPlayer,
initializeHikvisionPlayer,
resizeHikvisionPlayer,
startHikvisionPreview,
stopHikvisionPreview,
type HikvisionPreviewConfig,
} from '@/services/hikvisionWebSdk'
/** 播放器状态。 */
type PlayerState = 'idle' | 'initializing' | 'connecting' | 'playing' | 'error'
/** 运行日志等级。 */
type LogLevel = 'info' | 'success' | 'error'
/** 测试页面中的设备连接参数。 */
interface CameraConnectionForm extends HikvisionPreviewConfig {
webSocketPort?: number
secretKey?: string
}
/** 播放器运行日志。 */
interface PlayerLog {
time: string
level: LogLevel
message: string
}
const PLAYER_CONTAINER_ID = 'hikvision-video-test-player'
const playerWrapper = ref<HTMLElement>()
const playerState = ref<PlayerState>('idle')
const errorMessage = ref('')
const logs = ref<PlayerLog[]>([])
const connection = reactive<CameraConnectionForm>(createDefaultConnection())
let resizeObserver: ResizeObserver | null = null
let playbackSequence = 0
const busy = computed(() => ['initializing', 'connecting'].includes(playerState.value))
const statusText = computed(() => {
const labels: Record<PlayerState, string> = {
idle: '等待连接',
initializing: '初始化中',
connecting: '正在拉流',
playing: '播放中',
error: '连接失败',
}
return labels[playerState.value]
})
const statusColor = computed(() => {
const colors: Record<PlayerState, string> = {
idle: 'gray',
initializing: 'arcoblue',
connecting: 'arcoblue',
playing: 'green',
error: 'red',
}
return colors[playerState.value]
})
const streamTypeText = computed(
() => ({ 1: '主码流', 2: '子码流', 3: '第三码流' })[connection.streamType] || `码流 ${connection.streamType}`
)
const placeholderTitle = computed(() => {
if (playerState.value === 'initializing') return '播放器初始化中'
if (playerState.value === 'connecting') return '正在登录设备并获取码流'
if (playerState.value === 'error') return '拉流失败'
return '尚未开始播放'
})
const placeholderDescription = computed(() => {
if (playerState.value === 'error') return errorMessage.value
if (busy.value) return '首次加载 WebSDK 可能需要几秒钟'
return '填写左侧设备参数,然后点击“开始拉流”'
})
const proxyTip = computed(() => {
if (!connection.proxyEnabled) {
if (window.location.protocol === 'https:' && connection.protocol === 1)
return 'HTTPS 页面无法直连 HTTP 设备,请改用 HTTPS 或开启同源代理。'
return ''
}
if (import.meta.env.DEV && !import.meta.env.VITE_HIKVISION_PROXY_TARGET) {
return '开发环境尚未配置海康代理。请设置 VITE_HIKVISION_PROXY_TARGET 并重启开发服务,或关闭同源代理改为直连。'
}
return ''
})
/** 创建默认测试参数,不预置任何敏感凭据。 */
function createDefaultConnection(): CameraConnectionForm {
const useHttps = window.location.protocol === 'https:'
return {
host: '192.168.1.101',
protocol: useHttps ? 2 : 1,
httpPort: useHttps ? 443 : 80,
rtspPort: 554,
webSocketPort: undefined,
username: 'admin',
password: '',
channelId: 1,
streamType: 2,
proxyEnabled: Boolean(import.meta.env.VITE_HIKVISION_PROXY_TARGET),
secretKey: undefined,
}
}
/** 追加一条带时间戳的运行日志。 */
function addLog(message: string, level: LogLevel = 'info'): void {
logs.value.unshift({
time: new Date().toLocaleTimeString('zh-CN', { hour12: false }),
level,
message,
})
logs.value = logs.value.slice(0, 50)
}
/** 校验开发代理仅会将凭据发送到配置中的固定目标。 */
function matchesDevelopmentProxyTarget(): boolean {
if (!import.meta.env.DEV || !connection.proxyEnabled || !import.meta.env.VITE_HIKVISION_PROXY_TARGET) return true
try {
const target = new URL(import.meta.env.VITE_HIKVISION_PROXY_TARGET)
const port = Number(target.port) || (target.protocol === 'https:' ? 443 : 80)
return target.hostname.toLowerCase() === connection.host.toLowerCase() && port === connection.httpPort
} catch (_error) {
return false
}
}
/** 开始海康实时预览。 */
async function startPreview(): Promise<void> {
const sequence = ++playbackSequence
errorMessage.value = ''
if (proxyTip.value) {
playerState.value = 'error'
errorMessage.value = proxyTip.value
addLog(errorMessage.value, 'error')
return
}
if (!matchesDevelopmentProxyTarget()) {
playerState.value = 'error'
errorMessage.value = '页面填写的设备地址与 VITE_HIKVISION_PROXY_TARGET 不一致,请修改代理配置并重启开发服务。'
addLog(errorMessage.value, 'error')
return
}
playerState.value = 'initializing'
addLog('正在加载并初始化海康 WebSDK')
try {
await nextTick()
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
await initializeHikvisionPlayer(PLAYER_CONTAINER_ID, {
onError: (message) => {
if (sequence !== playbackSequence) return
playerState.value = 'error'
errorMessage.value = message
addLog(message, 'error')
},
onPerformanceLack: () => {
addLog('浏览器播放资源不足,建议使用子码流或关闭其他视频窗口', 'error')
Message.warning('浏览器播放资源不足,建议使用子码流或关闭其他视频窗口')
},
})
if (sequence !== playbackSequence) return
observePlayerSize()
playerState.value = 'connecting'
addLog(`正在连接 ${connection.host}:${connection.httpPort},通道 ${connection.channelId}`)
await startHikvisionPreview({ ...connection })
if (sequence !== playbackSequence) {
await stopHikvisionPreview()
return
}
playerState.value = 'playing'
addLog(`${streamTypeText.value}拉流成功`, 'success')
resizePlayer()
} catch (error) {
if (sequence !== playbackSequence) return
playerState.value = 'error'
errorMessage.value = error instanceof Error ? error.message : '海康视频拉流失败'
addLog(errorMessage.value, 'error')
}
}
/** 停止当前预览。 */
async function stopPreview(): Promise<void> {
playbackSequence += 1
try {
await stopHikvisionPreview()
playerState.value = 'idle'
errorMessage.value = ''
addLog('实时预览已停止')
} catch (error) {
playerState.value = 'error'
errorMessage.value = error instanceof Error ? error.message : '停止播放失败'
addLog(errorMessage.value, 'error')
}
}
/** 停止播放并恢复非敏感默认参数。 */
async function resetConnection(): Promise<void> {
if (playerState.value === 'playing') await stopPreview()
Object.assign(connection, createDefaultConnection())
errorMessage.value = ''
playerState.value = 'idle'
addLog('连接参数已重置')
}
/** 根据预览区域大小同步 SDK 画布。 */
function resizePlayer(): void {
const rect = playerWrapper.value?.getBoundingClientRect()
if (rect) resizeHikvisionPlayer(rect.width, rect.height)
}
/** 监听播放器容器尺寸。 */
function observePlayerSize(): void {
resizeObserver?.disconnect()
if (!playerWrapper.value) return
resizeObserver = new ResizeObserver(() => resizePlayer())
resizeObserver.observe(playerWrapper.value)
resizePlayer()
}
/** 将当前播放器区域切换为浏览器全屏。 */
async function enterFullscreen(): Promise<void> {
if (!playerWrapper.value) return
try {
await playerWrapper.value.requestFullscreen()
window.setTimeout(() => resizePlayer(), 100)
} catch (_error) {
Message.warning('当前浏览器不允许进入全屏模式')
}
}
watch(
() => connection.protocol,
(protocol) => {
if ([80, 443].includes(connection.httpPort)) connection.httpPort = protocol === 2 ? 443 : 80
}
)
onMounted(() => {
observePlayerSize()
document.addEventListener('fullscreenchange', resizePlayer)
})
onBeforeUnmount(() => {
playbackSequence += 1
resizeObserver?.disconnect()
document.removeEventListener('fullscreenchange', resizePlayer)
connection.password = ''
connection.secretKey = undefined
void destroyHikvisionPlayer().catch((error) => console.warn('海康播放器销毁失败:', error))
})
</script>
<script lang="ts">
export default { name: 'HikvisionVideoTest' }
</script>
<style scoped lang="less">
.hikvision-test {
min-height: 100%;
padding: 20px;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 16px;
h2 {
margin: 0 0 6px;
color: var(--color-text-1);
font-size: 22px;
}
p {
margin: 0;
color: var(--color-text-3);
}
}
.status-dot {
display: inline-block;
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: currentcolor;
&.is-live {
animation: status-pulse 1.4s ease-out infinite;
}
}
.content-grid {
align-items: stretch;
}
.config-card,
.preview-card,
.log-card {
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
}
.proxy-alert {
margin-bottom: 16px;
}
.config-card {
height: 100%;
:deep(.arco-card-body) {
padding-bottom: 18px;
}
:deep(.arco-form-item) {
margin-bottom: 15px;
}
}
.proxy-setting {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border-radius: 6px;
background: var(--color-fill-2);
strong {
color: var(--color-text-1);
font-size: 14px;
}
p {
margin: 3px 0 0;
color: var(--color-text-3);
font-size: 12px;
}
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
.preview-card {
overflow: hidden;
:deep(.arco-card-body) {
padding: 0;
}
}
.card-title {
display: flex;
align-items: center;
gap: 10px;
}
.live-badge {
display: inline-flex;
align-items: center;
gap: 5px;
color: rgb(var(--red-6));
font-size: 11px;
font-weight: 700;
i {
width: 7px;
height: 7px;
border-radius: 50%;
background: currentcolor;
animation: status-pulse 1.4s ease-out infinite;
}
}
.player-wrapper {
position: relative;
width: 100%;
min-height: 440px;
overflow: hidden;
background: #02060b;
aspect-ratio: 16 / 9;
}
.player-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.player-placeholder {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: rgba(255, 255, 255, 0.86);
background: radial-gradient(circle at center, rgba(31, 65, 96, 0.32), rgba(2, 6, 11, 0.98) 62%);
&.is-error {
background: radial-gradient(circle at center, rgba(120, 35, 35, 0.32), rgba(2, 6, 11, 0.98) 62%);
}
strong {
font-size: 16px;
}
span {
max-width: 70%;
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
line-height: 1.6;
text-align: center;
}
}
.placeholder-icon {
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 50%;
color: rgba(255, 255, 255, 0.7);
font-size: 18px;
}
.connection-summary {
display: flex;
align-items: center;
gap: 24px;
min-height: 44px;
padding: 0 16px;
color: var(--color-text-2);
background: var(--color-bg-2);
span {
display: flex;
gap: 6px;
min-width: 0;
}
b {
color: var(--color-text-3);
font-weight: 400;
}
}
.log-card {
margin-top: 16px;
}
.log-list {
height: 152px;
overflow: auto;
border-radius: 4px;
color: #b7c5d3;
background: #101820;
font-family: SFMono-Regular, Consolas, 'Liberation Mono', monospace;
font-size: 12px;
}
.empty-log {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #637587;
}
.log-item {
display: flex;
gap: 12px;
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
time {
flex: 0 0 auto;
color: #637587;
}
&.is-success span {
color: #65d18a;
}
&.is-error span {
color: #ff7373;
}
}
@keyframes status-pulse {
0% {
box-shadow: 0 0 0 0 currentcolor;
}
70% {
box-shadow: 0 0 0 5px transparent;
}
100% {
box-shadow: 0 0 0 0 transparent;
}
}
@media (max-width: 1200px) {
.player-wrapper {
min-height: 360px;
}
.connection-summary {
flex-wrap: wrap;
gap: 8px 18px;
padding-block: 10px;
}
}
</style>