fix(优化)
This commit is contained in:
@@ -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?'],
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
718
src/views/ops/pages/hikvision-video-test/index.vue
Normal file
718
src/views/ops/pages/hikvision-video-test/index.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user