fix(3d)
This commit is contained in:
@@ -16,9 +16,17 @@ export interface HikvisionPreviewConfig {
|
||||
/** 海康播放器运行事件。 */
|
||||
export interface HikvisionPlayerCallbacks {
|
||||
onError?: (message: string) => void
|
||||
onWindowError?: (windowIndex: number, message: string) => void
|
||||
onPerformanceLack?: () => void
|
||||
}
|
||||
|
||||
/** 多窗口实时预览启动结果。 */
|
||||
export interface HikvisionMultiPreviewResult {
|
||||
started: number
|
||||
failed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
const SDK_SCRIPT_PATHS = [
|
||||
'jsPlugin/jquery.min.js',
|
||||
'encryption/AES.js',
|
||||
@@ -42,7 +50,7 @@ const PLAYER_ERROR_MESSAGES: Record<number, string> = {
|
||||
let scriptsPromise: Promise<void> | null = null
|
||||
let initializationPromise: Promise<void> | null = null
|
||||
let initializedContainerId = ''
|
||||
let activeDeviceIdentify = ''
|
||||
const activeDeviceIdentifies = new Set<string>()
|
||||
let runtimeCallbacks: HikvisionPlayerCallbacks = {}
|
||||
let previewOperation = 0
|
||||
|
||||
@@ -137,8 +145,10 @@ export async function initializeHikvisionPlayer(containerId: string, callbacks:
|
||||
const result = controller.I_InsertOBJECTPlugin(containerId)
|
||||
finish(result === 0 ? undefined : new Error('海康播放器挂载失败'))
|
||||
},
|
||||
cbPluginErrorHandler: (_windowIndex, errorCode) => {
|
||||
runtimeCallbacks.onError?.(PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode})`)
|
||||
cbPluginErrorHandler: (windowIndex, errorCode) => {
|
||||
const message = PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode})`
|
||||
runtimeCallbacks.onWindowError?.(windowIndex, message)
|
||||
runtimeCallbacks.onError?.(message)
|
||||
},
|
||||
cbPerformanceLack: () => runtimeCallbacks.onPerformanceLack?.(),
|
||||
cbSecretKeyError: () => runtimeCallbacks.onError?.('码流加密密钥错误'),
|
||||
@@ -153,15 +163,29 @@ export async function initializeHikvisionPlayer(containerId: string, callbacks:
|
||||
return initializationPromise
|
||||
}
|
||||
|
||||
/** 停止当前窗口中的实时预览。 */
|
||||
async function stopWindow(): Promise<void> {
|
||||
/** 根据画面数量返回海康 SDK 的分屏类型。 */
|
||||
function getWindowType(windowCount: number): number {
|
||||
if (windowCount <= 1) return 1
|
||||
if (windowCount <= 4) return 2
|
||||
if (windowCount <= 9) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
/** 按画面数量自动切换为单屏、四宫格、九宫格或十六宫格。 */
|
||||
async function changeWindowCount(windowCount: number): Promise<void> {
|
||||
const controller = getController()
|
||||
if (!controller.I_GetWindowStatus(0)) return
|
||||
await controller.I_ChangeWndNum(getWindowType(Math.max(1, Math.min(16, windowCount))))
|
||||
}
|
||||
|
||||
/** 停止指定窗口中的实时预览。 */
|
||||
async function stopWindow(windowIndex: number): Promise<void> {
|
||||
const controller = getController()
|
||||
if (!controller.I_GetWindowStatus(windowIndex)) return
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = window.setTimeout(resolve, 3000)
|
||||
controller.I_Stop({
|
||||
iIndex: 0,
|
||||
iIndex: windowIndex,
|
||||
success: () => {
|
||||
window.clearTimeout(timeout)
|
||||
resolve()
|
||||
@@ -174,22 +198,29 @@ async function stopWindow(): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
/** 停止预览并注销当前设备。 */
|
||||
export async function stopHikvisionPreview(): Promise<void> {
|
||||
previewOperation += 1
|
||||
if (!window.WebVideoCtrl || !initializedContainerId) return
|
||||
await stopWindow()
|
||||
if (activeDeviceIdentify) {
|
||||
getController().I_Logout(activeDeviceIdentify)
|
||||
activeDeviceIdentify = ''
|
||||
/** 停止全部分屏;SDK 不支持批量停止时逐个窗口回退处理。 */
|
||||
async function stopAllWindows(): Promise<void> {
|
||||
const controller = getController()
|
||||
try {
|
||||
await controller.I_StopAll()
|
||||
} catch (_error) {
|
||||
await Promise.all(Array.from({ length: 16 }, (_item, index) => stopWindow(index)))
|
||||
}
|
||||
}
|
||||
|
||||
/** 登录摄像头并开始实时预览。 */
|
||||
export async function startHikvisionPreview(config: HikvisionPreviewConfig): Promise<void> {
|
||||
/** 停止全部预览并注销本次使用的设备。 */
|
||||
export async function stopHikvisionPreview(): Promise<void> {
|
||||
previewOperation += 1
|
||||
if (!window.WebVideoCtrl || !initializedContainerId) return
|
||||
await stopAllWindows()
|
||||
const controller = getController()
|
||||
activeDeviceIdentifies.forEach((deviceIdentify) => controller.I_Logout(deviceIdentify))
|
||||
activeDeviceIdentifies.clear()
|
||||
}
|
||||
|
||||
/** 登录一台摄像机或录像机,并返回 SDK 设备标识。 */
|
||||
async function loginHikvisionDevice(config: HikvisionPreviewConfig, operation: number): Promise<string> {
|
||||
const controller = getController()
|
||||
await stopHikvisionPreview()
|
||||
const operation = ++previewOperation
|
||||
const deviceIdentify = `${config.host}_${config.httpPort}`
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -222,7 +253,18 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
|
||||
controller.I_Logout(deviceIdentify)
|
||||
throw new Error('摄像头播放已取消')
|
||||
}
|
||||
activeDeviceIdentify = deviceIdentify
|
||||
activeDeviceIdentifies.add(deviceIdentify)
|
||||
return deviceIdentify
|
||||
}
|
||||
|
||||
/** 在指定 SDK 分屏中启动一路实时预览。 */
|
||||
async function startPreviewWindow(
|
||||
config: HikvisionPreviewConfig,
|
||||
deviceIdentify: string,
|
||||
windowIndex: number,
|
||||
operation: number
|
||||
): Promise<void> {
|
||||
const controller = getController()
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -238,7 +280,7 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
|
||||
error ? reject(error) : resolve()
|
||||
}
|
||||
controller.I_StartRealPlay(deviceIdentify, {
|
||||
iWndIndex: 0,
|
||||
iWndIndex: windowIndex,
|
||||
iRtspPort: config.rtspPort,
|
||||
iWSPort: config.webSocketPort,
|
||||
iStreamType: config.streamType,
|
||||
@@ -247,7 +289,7 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
|
||||
bProxy: config.proxyEnabled,
|
||||
success: () => {
|
||||
if (settled || operation !== previewOperation) {
|
||||
void stopWindow().then(() => controller.I_Logout(deviceIdentify))
|
||||
void stopWindow(windowIndex)
|
||||
if (settled) return
|
||||
finish(new Error('摄像头播放已取消'))
|
||||
return
|
||||
@@ -258,13 +300,73 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
|
||||
})
|
||||
})
|
||||
if (operation !== previewOperation) throw new Error('摄像头播放已取消')
|
||||
if (config.secretKey) await controller.I_SetSecretKey(config.secretKey, 0)
|
||||
if (config.secretKey) await controller.I_SetSecretKey(config.secretKey, windowIndex)
|
||||
} catch (error) {
|
||||
await stopHikvisionPreview()
|
||||
await stopWindow(windowIndex)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** 自动分屏并同时启动多路海康实时预览。 */
|
||||
export async function startHikvisionPreviews(configs: HikvisionPreviewConfig[]): Promise<HikvisionMultiPreviewResult> {
|
||||
const previewConfigs = configs.slice(0, 16)
|
||||
if (!previewConfigs.length) throw new Error('没有可播放的摄像头')
|
||||
await stopHikvisionPreview()
|
||||
const operation = ++previewOperation
|
||||
await changeWindowCount(previewConfigs.length)
|
||||
|
||||
const uniqueDevices = new Map<string, HikvisionPreviewConfig>()
|
||||
previewConfigs.forEach((config) => {
|
||||
const deviceIdentify = `${config.host}_${config.httpPort}`
|
||||
if (!uniqueDevices.has(deviceIdentify)) uniqueDevices.set(deviceIdentify, config)
|
||||
})
|
||||
const loginResults = await Promise.allSettled(
|
||||
Array.from(uniqueDevices.entries()).map(async ([deviceIdentify, config]) => {
|
||||
await loginHikvisionDevice(config, operation)
|
||||
return deviceIdentify
|
||||
})
|
||||
)
|
||||
if (operation !== previewOperation) throw new Error('摄像头播放已取消')
|
||||
|
||||
const loggedInDevices = new Set(
|
||||
loginResults.filter((result): result is PromiseFulfilledResult<string> => result.status === 'fulfilled').map((result) => result.value)
|
||||
)
|
||||
const loginErrors = new Map<string, string>()
|
||||
Array.from(uniqueDevices.keys()).forEach((deviceIdentify, index) => {
|
||||
const result = loginResults[index]
|
||||
if (result.status === 'rejected') {
|
||||
loginErrors.set(deviceIdentify, result.reason instanceof Error ? result.reason.message : '摄像头登录失败')
|
||||
}
|
||||
})
|
||||
|
||||
const previewResults = await Promise.allSettled(
|
||||
previewConfigs.map(async (config, windowIndex) => {
|
||||
const deviceIdentify = `${config.host}_${config.httpPort}`
|
||||
if (!loggedInDevices.has(deviceIdentify)) throw new Error(loginErrors.get(deviceIdentify) || '摄像头登录失败')
|
||||
await startPreviewWindow(config, deviceIdentify, windowIndex, operation)
|
||||
})
|
||||
)
|
||||
if (operation !== previewOperation) throw new Error('摄像头播放已取消')
|
||||
|
||||
const errors = previewResults.flatMap((result, windowIndex) => {
|
||||
if (result.status === 'fulfilled') return []
|
||||
const message = result.reason instanceof Error ? result.reason.message : '摄像头取流失败'
|
||||
runtimeCallbacks.onWindowError?.(windowIndex, message)
|
||||
return [`画面 ${windowIndex + 1}:${message}`]
|
||||
})
|
||||
const started = previewResults.length - errors.length
|
||||
if (!started) {
|
||||
await stopHikvisionPreview()
|
||||
throw new Error(errors[0] || '全部摄像头播放失败')
|
||||
}
|
||||
return { started, failed: errors.length, errors }
|
||||
}
|
||||
|
||||
/** 登录摄像头并在单窗口中开始实时预览。 */
|
||||
export async function startHikvisionPreview(config: HikvisionPreviewConfig): Promise<void> {
|
||||
await startHikvisionPreviews([config])
|
||||
}
|
||||
|
||||
/** 调整播放器画布尺寸。 */
|
||||
export function resizeHikvisionPlayer(width: number, height: number): void {
|
||||
if (!window.WebVideoCtrl || !initializedContainerId || width <= 0 || height <= 0) return
|
||||
|
||||
1
src/types/hikvision-web-sdk.d.ts
vendored
1
src/types/hikvision-web-sdk.d.ts
vendored
@@ -42,6 +42,7 @@ interface HikvisionWebVideoCtrl {
|
||||
) => number | void
|
||||
I_Logout: (deviceIdentify: string) => number
|
||||
I_StartRealPlay: (deviceIdentify: string, options: HikvisionRealPlayOptions) => void
|
||||
I_ChangeWndNum: (windowType: number) => Promise<unknown>
|
||||
I_GetWindowStatus: (windowIndex: number) => { szDeviceIdentify?: string } | null
|
||||
I_Stop: (options: HikvisionSdkCallbacks & { iIndex?: number }) => void
|
||||
I_StopAll: () => Promise<unknown> | void
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { Message } from '@arco-design/web-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { FormItem } from '@/components/search-form/types'
|
||||
import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
|
||||
import SearchTable from '@/components/search-table/index.vue'
|
||||
@@ -171,6 +171,7 @@ import CommentDialog from './components/CommentDialog.vue'
|
||||
import DetailDialog from './components/DetailDialog.vue'
|
||||
import AssignDialog from './components/AssignDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -229,6 +230,8 @@ const formItems = computed<FormItem[]>(() =>
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
const routeKeyword = Array.isArray(route.query.keyword) ? route.query.keyword[0] : route.query.keyword
|
||||
if (routeKeyword) formModel.value.keyword = routeKeyword
|
||||
await loadSeverityOptions()
|
||||
handleSearch()
|
||||
loadTree()
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
■
|
||||
</button>
|
||||
<button
|
||||
v-if="props.device?.asset_id"
|
||||
type="button"
|
||||
class="camera-preview-floating__action"
|
||||
title="设备管理"
|
||||
@@ -56,9 +57,9 @@
|
||||
|
||||
<div ref="playerWrapper" class="camera-preview-floating__player">
|
||||
<div :id="PLAYER_CONTAINER_ID" class="camera-preview__canvas"></div>
|
||||
<div v-if="initializing" class="camera-preview-floating__message">
|
||||
<div v-if="initializing || starting" class="camera-preview-floating__message">
|
||||
<a-spin :size="22" />
|
||||
<span>播放器初始化中</span>
|
||||
<span>{{ initializing ? '播放器初始化中' : `正在连接 ${previewCount} 路摄像头` }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!playing"
|
||||
@@ -70,7 +71,7 @@
|
||||
</div>
|
||||
<div v-else class="camera-preview-floating__live">
|
||||
<i></i>
|
||||
实时
|
||||
{{ startedCount }}/{{ previewCount }} 实时
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -86,7 +87,7 @@ import {
|
||||
destroyHikvisionPlayer,
|
||||
initializeHikvisionPlayer,
|
||||
resizeHikvisionPlayer,
|
||||
startHikvisionPreview,
|
||||
startHikvisionPreviews,
|
||||
stopHikvisionPreview,
|
||||
type HikvisionPreviewConfig,
|
||||
} from '@/services/hikvisionWebSdk'
|
||||
@@ -95,6 +96,9 @@ import {
|
||||
interface Props {
|
||||
visible: boolean
|
||||
device: ThreeMachineRoomDevice | null
|
||||
devices?: ThreeMachineRoomDevice[]
|
||||
cameraIndex?: number
|
||||
cameraCount?: number
|
||||
}
|
||||
|
||||
/** 摄像头预览浮层事件。 */
|
||||
@@ -119,11 +123,17 @@ interface CameraConnectionForm {
|
||||
}
|
||||
|
||||
const PLAYER_CONTAINER_ID = 'hikvision-camera-preview-player'
|
||||
const FIXED_CAMERA_HOST = '192.168.1.101'
|
||||
const DEFAULT_CAMERA_USERNAME = 'admin'
|
||||
const DEFAULT_CAMERA_PASSWORD = 'Xzrmyy@12'
|
||||
const FLOATING_LAYER_WIDTH = 200
|
||||
const FLOATING_LAYER_HEIGHT = 200
|
||||
/** 3D 预览使用已验证通过的固定 WebSDK 参数,设备身份信息由资产详情提供。 */
|
||||
const FIXED_CAMERA_CONFIG: Omit<CameraConnectionForm, 'host' | 'username' | 'password'> = {
|
||||
protocol: 2,
|
||||
httpPort: 443,
|
||||
rtspPort: 554,
|
||||
webSocketPort: 7862,
|
||||
channelId: 1,
|
||||
streamType: 2,
|
||||
proxyEnabled: true,
|
||||
secretKey: undefined,
|
||||
}
|
||||
const FLOATING_LAYER_MARGIN = 16
|
||||
const FLOATING_LAYER_DEFAULT_TOP = 88
|
||||
const props = defineProps<Props>()
|
||||
@@ -135,10 +145,13 @@ const starting = ref(false)
|
||||
const playing = ref(false)
|
||||
const dragging = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const connection = reactive<CameraConnectionForm>(createDefaultConnection())
|
||||
const connections = ref<CameraConnectionForm[]>([])
|
||||
const startedCount = ref(0)
|
||||
const failedCount = ref(0)
|
||||
const viewportSize = reactive({ width: window.innerWidth, height: window.innerHeight })
|
||||
const floatingLayerPosition = reactive({
|
||||
x: Math.max(0, window.innerWidth - FLOATING_LAYER_WIDTH - FLOATING_LAYER_MARGIN),
|
||||
y: Math.min(FLOATING_LAYER_DEFAULT_TOP, Math.max(0, window.innerHeight - FLOATING_LAYER_HEIGHT)),
|
||||
x: Math.max(0, window.innerWidth - 720 - FLOATING_LAYER_MARGIN),
|
||||
y: Math.min(FLOATING_LAYER_DEFAULT_TOP, Math.max(0, window.innerHeight - 500)),
|
||||
})
|
||||
const developmentProxyConfigured = Boolean(import.meta.env.VITE_HIKVISION_PROXY_TARGET)
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
@@ -148,32 +161,47 @@ let dragPointerId: number | null = null
|
||||
let dragOffsetX = 0
|
||||
let dragOffsetY = 0
|
||||
|
||||
const floatingTitle = computed(() => props.device?.asset_name || props.device?.asset_code || '摄像头预览')
|
||||
const selectedCameraSlot = computed(() => {
|
||||
if (props.cameraIndex) return props.cameraIndex
|
||||
const selectedAssetId = Number(props.device?.asset_id)
|
||||
const deviceIndex = (props.devices || []).findIndex((device) => Number(device.asset_id) === selectedAssetId)
|
||||
return deviceIndex >= 0 ? deviceIndex + 1 : 1
|
||||
})
|
||||
const previewCount = computed(() =>
|
||||
Math.min(16, Math.max(1, Number(props.cameraCount) || 0, props.devices?.length || 0, selectedCameraSlot.value))
|
||||
)
|
||||
const splitName = computed(() => {
|
||||
if (previewCount.value <= 1) return '单画面'
|
||||
if (previewCount.value <= 4) return '四宫格'
|
||||
if (previewCount.value <= 9) return '九宫格'
|
||||
return '十六宫格'
|
||||
})
|
||||
const floatingTitle = computed(() => `监控预览 · ${splitName.value}(${previewCount.value} 路)· 当前摄像头 ${selectedCameraSlot.value}`)
|
||||
const floatingLayerDimensions = computed(() => {
|
||||
const preferred =
|
||||
previewCount.value <= 1
|
||||
? { width: 420, height: 300 }
|
||||
: previewCount.value <= 4
|
||||
? { width: 600, height: 420 }
|
||||
: previewCount.value <= 9
|
||||
? { width: 720, height: 500 }
|
||||
: { width: 820, height: 560 }
|
||||
return {
|
||||
width: Math.max(280, Math.min(preferred.width, viewportSize.width - FLOATING_LAYER_MARGIN * 2)),
|
||||
height: Math.max(220, Math.min(preferred.height, viewportSize.height - FLOATING_LAYER_MARGIN * 2)),
|
||||
}
|
||||
})
|
||||
const floatingLayerStyle = computed(() => ({
|
||||
width: `${floatingLayerDimensions.value.width}px`,
|
||||
height: `${floatingLayerDimensions.value.height}px`,
|
||||
transform: `translate3d(${floatingLayerPosition.x}px, ${floatingLayerPosition.y}px, 0)`,
|
||||
}))
|
||||
const developmentProxyTip = computed(() => {
|
||||
if (!import.meta.env.DEV || !connection.proxyEnabled || developmentProxyConfigured) return ''
|
||||
const proxyEnabled = connections.value.length ? connections.value.some((connection) => connection.proxyEnabled) : true
|
||||
if (!import.meta.env.DEV || !proxyEnabled || developmentProxyConfigured) return ''
|
||||
return '开发环境尚未配置海康代理;请配置 VITE_HIKVISION_PROXY_TARGET,或切换为直连。'
|
||||
})
|
||||
|
||||
/** 创建安全的默认连接参数。 */
|
||||
function createDefaultConnection(): CameraConnectionForm {
|
||||
return {
|
||||
host: FIXED_CAMERA_HOST,
|
||||
protocol: window.location.protocol === 'https:' ? 2 : 1,
|
||||
httpPort: window.location.protocol === 'https:' ? 443 : 80,
|
||||
rtspPort: 554,
|
||||
webSocketPort: undefined,
|
||||
username: DEFAULT_CAMERA_USERNAME,
|
||||
password: DEFAULT_CAMERA_PASSWORD,
|
||||
channelId: 1,
|
||||
streamType: 2,
|
||||
proxyEnabled: true,
|
||||
secretKey: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将未知值转换为对象。 */
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : null
|
||||
@@ -190,25 +218,8 @@ function readConfigValue(records: Record<string, unknown>[], keys: string[]): un
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** 将未知值转换为合法端口或正整数。 */
|
||||
function toPositiveInteger(value: unknown, fallback?: number): number | undefined {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
/** 将未知值转换为布尔配置。 */
|
||||
function toBoolean(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (typeof value === 'number') return value !== 0
|
||||
if (typeof value === 'string') {
|
||||
if (['true', '1', 'yes', 'on'].includes(value.toLowerCase())) return true
|
||||
if (['false', '0', 'no', 'off'].includes(value.toLowerCase())) return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** 校验浮层设备与固定开发代理是否为同一目标,避免凭据被发往错误设备。 */
|
||||
function matchesDevelopmentProxyTarget(): boolean {
|
||||
function matchesDevelopmentProxyTarget(connection: CameraConnectionForm): boolean {
|
||||
if (!import.meta.env.DEV || !connection.proxyEnabled || !developmentProxyConfigured) return true
|
||||
try {
|
||||
const httpTarget = new URL(import.meta.env.VITE_HIKVISION_PROXY_TARGET!)
|
||||
@@ -222,7 +233,7 @@ function matchesDevelopmentProxyTarget(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析资产 source_address 中的地址、通道与码流。 */
|
||||
/** 解析资产 source_address 中的设备地址与登录凭据。 */
|
||||
function parseSourceAddress(sourceAddress: unknown): Record<string, unknown> {
|
||||
if (typeof sourceAddress !== 'string' || !sourceAddress.trim()) return {}
|
||||
const source = sourceAddress.trim()
|
||||
@@ -236,80 +247,81 @@ function parseSourceAddress(sourceAddress: unknown): Record<string, unknown> {
|
||||
|
||||
try {
|
||||
const sourceUrl = new URL(source.includes('://') ? source : `http://${source}`)
|
||||
const channelMatch = sourceUrl.pathname.match(/\/Streaming\/Channels\/(\d+)/i)
|
||||
const trackMatch = sourceUrl.pathname.match(/\/Streaming\/tracks\/(\d+)/i)
|
||||
const legacyChannelMatch = sourceUrl.pathname.match(/\/h264\/ch(\d+)\/(main|sub)\/av_stream/i)
|
||||
const channelCode = Number(channelMatch?.[1] || trackMatch?.[1] || 0)
|
||||
const protocol = sourceUrl.protocol === 'https:' ? 2 : 1
|
||||
return {
|
||||
host: sourceUrl.hostname,
|
||||
protocol,
|
||||
http_port: ['http:', 'https:'].includes(sourceUrl.protocol) ? Number(sourceUrl.port) || (protocol === 2 ? 443 : 80) : undefined,
|
||||
rtsp_port: sourceUrl.protocol === 'rtsp:' ? Number(sourceUrl.port) || 554 : undefined,
|
||||
username: sourceUrl.username ? decodeURIComponent(sourceUrl.username) : undefined,
|
||||
password: sourceUrl.password ? decodeURIComponent(sourceUrl.password) : undefined,
|
||||
channel_id: channelCode >= 100 ? Math.floor(channelCode / 100) : toPositiveInteger(legacyChannelMatch?.[1]),
|
||||
stream_type:
|
||||
channelCode >= 100 ? channelCode % 100 : legacyChannelMatch?.[2]?.toLowerCase() === 'main' ? 1 : legacyChannelMatch ? 2 : undefined,
|
||||
}
|
||||
} catch (_error) {
|
||||
return { host: source }
|
||||
}
|
||||
}
|
||||
|
||||
/** 将资产详情映射为 WebSDK 连接参数。 */
|
||||
function applyAssetCameraConfig(asset: Record<string, unknown>): void {
|
||||
/** 只将资产详情中的 IP、用户名和密码映射到固定 WebSDK 参数。 */
|
||||
function applyAssetCameraConfig(asset: Record<string, unknown>): CameraConnectionForm {
|
||||
const sourceConfig = parseSourceAddress(asset.source_address)
|
||||
const nestedConfig =
|
||||
asRecord(asset.camera_config) || asRecord(asset.video_config) || asRecord(asset.stream_config) || asRecord(asset.hikvision_config)
|
||||
const records = [nestedConfig, asset, sourceConfig].filter((item): item is Record<string, unknown> => Boolean(item))
|
||||
const protocolValue = readConfigValue(records, ['camera_protocol', 'protocol', 'http_protocol'])
|
||||
const normalizedProtocol = String(protocolValue).toLowerCase()
|
||||
const protocol: 1 | 2 = protocolValue === 2 || normalizedProtocol === '2' || normalizedProtocol === 'https' ? 2 : 1
|
||||
|
||||
connection.host = FIXED_CAMERA_HOST
|
||||
connection.protocol = protocol
|
||||
connection.httpPort = toPositiveInteger(
|
||||
readConfigValue(records, ['camera_http_port', 'http_port', 'https_port', 'port']),
|
||||
protocol === 2 ? 443 : 80
|
||||
)!
|
||||
connection.rtspPort = toPositiveInteger(readConfigValue(records, ['camera_rtsp_port', 'rtsp_port']), 554)!
|
||||
connection.webSocketPort = toPositiveInteger(readConfigValue(records, ['camera_ws_port', 'websocket_port', 'ws_port']))
|
||||
connection.username = String(readConfigValue(records, ['camera_username', 'username', 'user']) || DEFAULT_CAMERA_USERNAME)
|
||||
connection.password = String(readConfigValue(records, ['camera_password', 'password']) || DEFAULT_CAMERA_PASSWORD)
|
||||
connection.channelId = toPositiveInteger(readConfigValue(records, ['camera_channel_id', 'channel_id', 'channel']), 1)!
|
||||
connection.streamType = toPositiveInteger(readConfigValue(records, ['camera_stream_type', 'stream_type']), 2)!
|
||||
connection.proxyEnabled = toBoolean(readConfigValue(records, ['camera_proxy_enabled', 'proxy_enabled', 'use_proxy']), true)
|
||||
connection.secretKey = String(readConfigValue(records, ['camera_secret_key', 'secret_key']) || '') || undefined
|
||||
return {
|
||||
host: String(readConfigValue(records, ['camera_host', 'host', 'ip_address', 'ip', 'device_ip', 'device_address']) || '').trim(),
|
||||
username: String(readConfigValue(records, ['camera_username', 'username', 'user', 'login_username', 'login_name']) || '').trim(),
|
||||
password: String(readConfigValue(records, ['camera_password', 'password', 'login_password']) || ''),
|
||||
...FIXED_CAMERA_CONFIG,
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取资产详情中的摄像头连接配置。 */
|
||||
async function loadAssetCameraConfig(): Promise<void> {
|
||||
if (!props.device?.resource_uid) return
|
||||
const response = (await fetchAssetDetail(props.device.resource_uid)) as unknown
|
||||
/** 获取单个资产详情中的摄像头连接配置。 */
|
||||
async function loadAssetCameraConfig(device: ThreeMachineRoomDevice): Promise<CameraConnectionForm> {
|
||||
if (!device.asset_id) throw new Error('摄像头未关联资产')
|
||||
const response = (await fetchAssetDetail(device.asset_id)) as unknown
|
||||
const responseRecord = asRecord(response)
|
||||
if (!responseRecord || Number(responseRecord.code) !== 0) {
|
||||
throw new Error(String(responseRecord?.message || '摄像头资产详情获取失败'))
|
||||
}
|
||||
const details = asRecord(responseRecord.details)
|
||||
if (!details) throw new Error('摄像头资产详情格式错误')
|
||||
applyAssetCameraConfig(details)
|
||||
const connection = applyAssetCameraConfig(details)
|
||||
if (!connection.host || !connection.username || !connection.password) throw new Error('摄像头资产详情缺少 IP、用户名或密码')
|
||||
return connection
|
||||
}
|
||||
|
||||
/** 加载资产配置;失败时保留前端预置参数,不阻断摄像头播放。 */
|
||||
async function loadOptionalAssetCameraConfig(): Promise<void> {
|
||||
try {
|
||||
await loadAssetCameraConfig()
|
||||
} catch (error) {
|
||||
console.warn('摄像头资产配置获取失败,使用前端预置参数:', error)
|
||||
Message.warning('摄像头资产配置获取失败,已使用前端预置参数')
|
||||
/** 返回指定宫格位置对应的摄像头资产。 */
|
||||
function getCameraDevice(cameraIndex: number): ThreeMachineRoomDevice | null {
|
||||
const listedDevice = props.devices?.[cameraIndex - 1]
|
||||
if (listedDevice) return listedDevice
|
||||
return cameraIndex === selectedCameraSlot.value ? props.device : null
|
||||
}
|
||||
|
||||
/** 并行加载全部宫格的摄像头身份信息;固定的 WebSDK 参数不依赖接口返回。 */
|
||||
async function loadPreviewConnections(): Promise<CameraConnectionForm[]> {
|
||||
let failureCount = 0
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: previewCount.value }, async (_item, index) => {
|
||||
const cameraIndex = index + 1
|
||||
const device = getCameraDevice(cameraIndex)
|
||||
if (!device) return null
|
||||
try {
|
||||
return await loadAssetCameraConfig(device)
|
||||
} catch (error) {
|
||||
failureCount += 1
|
||||
console.warn(`摄像头 ${cameraIndex} 资产身份信息获取失败:`, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
if (failureCount > 0) {
|
||||
Message.warning(`${failureCount} 路摄像头的 IP、用户名或密码获取失败`)
|
||||
}
|
||||
return results.filter((connection): connection is CameraConnectionForm => Boolean(connection))
|
||||
}
|
||||
|
||||
/** 将视频浮层限制在浏览器可视区域内。 */
|
||||
function clampFloatingLayerPosition(): void {
|
||||
const maxX = Math.max(0, window.innerWidth - FLOATING_LAYER_WIDTH)
|
||||
const maxY = Math.max(0, window.innerHeight - FLOATING_LAYER_HEIGHT)
|
||||
const width = floatingLayer.value?.offsetWidth || floatingLayerDimensions.value.width
|
||||
const height = floatingLayer.value?.offsetHeight || floatingLayerDimensions.value.height
|
||||
const maxX = Math.max(0, window.innerWidth - width)
|
||||
const maxY = Math.max(0, window.innerHeight - height)
|
||||
floatingLayerPosition.x = Math.min(Math.max(0, floatingLayerPosition.x), maxX)
|
||||
floatingLayerPosition.y = Math.min(Math.max(0, floatingLayerPosition.y), maxY)
|
||||
}
|
||||
@@ -348,39 +360,58 @@ function startDragging(event: PointerEvent): void {
|
||||
|
||||
/** 浏览器尺寸变化时保持浮层可见,并同步播放器大小。 */
|
||||
function handleViewportResize(): void {
|
||||
viewportSize.width = window.innerWidth
|
||||
viewportSize.height = window.innerHeight
|
||||
clampFloatingLayerPosition()
|
||||
resizePlayer()
|
||||
}
|
||||
|
||||
/** 打开浮层并准备连接参数,不在页面加载阶段连接摄像头。 */
|
||||
/** 打开浮层、加载当前摄像头连接参数并自动开始预览。 */
|
||||
async function openPreview(): Promise<void> {
|
||||
const sequence = ++openSequence
|
||||
Object.assign(connection, createDefaultConnection())
|
||||
playbackSequence += 1
|
||||
await stopHikvisionPreview()
|
||||
if (sequence !== openSequence || !props.visible) return
|
||||
connections.value = []
|
||||
errorMessage.value = ''
|
||||
playing.value = false
|
||||
startedCount.value = 0
|
||||
failedCount.value = 0
|
||||
initializing.value = false
|
||||
starting.value = false
|
||||
|
||||
await loadOptionalAssetCameraConfig()
|
||||
connections.value = await loadPreviewConnections()
|
||||
if (sequence !== openSequence || !props.visible) return
|
||||
await nextTick()
|
||||
clampFloatingLayerPosition()
|
||||
resizePlayer()
|
||||
await startPreview()
|
||||
}
|
||||
|
||||
/** 校验参数并开始实时预览。 */
|
||||
async function startPreview(): Promise<void> {
|
||||
const sequence = ++playbackSequence
|
||||
errorMessage.value = ''
|
||||
if (!connection.host || !connection.username || !connection.password) {
|
||||
errorMessage.value = '请填写摄像头地址、用户名和密码'
|
||||
const selectedIndex = Math.min(connections.value.length - 1, Math.max(0, selectedCameraSlot.value - 1))
|
||||
const orderedConnections = [
|
||||
connections.value[selectedIndex],
|
||||
...connections.value.filter((_connection, index) => index !== selectedIndex),
|
||||
].filter((connection): connection is CameraConnectionForm => Boolean(connection))
|
||||
const previewConnections = orderedConnections.map((connection) => ({ ...connection }))
|
||||
if (!previewConnections.length) {
|
||||
errorMessage.value = '没有可播放的摄像头'
|
||||
return
|
||||
}
|
||||
if (import.meta.env.DEV && connection.proxyEnabled && !developmentProxyConfigured) {
|
||||
if (previewConnections.some((connection) => !connection.host || !connection.username || !connection.password)) {
|
||||
errorMessage.value = '部分摄像头缺少地址、用户名或密码'
|
||||
return
|
||||
}
|
||||
if (import.meta.env.DEV && previewConnections.some((connection) => connection.proxyEnabled) && !developmentProxyConfigured) {
|
||||
errorMessage.value = '开发环境同源代理尚未配置,请先填写海康代理目标,或切换为直连'
|
||||
return
|
||||
}
|
||||
if (!matchesDevelopmentProxyTarget()) {
|
||||
errorMessage.value = '当前摄像头地址与固定开发代理目标不一致,请修改代理配置后重启开发服务器'
|
||||
if (previewConnections.some((connection) => !matchesDevelopmentProxyTarget(connection))) {
|
||||
errorMessage.value = '部分摄像头地址与固定开发代理目标不一致,请修改代理配置后重启开发服务器'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -390,38 +421,43 @@ async function startPreview(): Promise<void> {
|
||||
await nextTick()
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
await initializeHikvisionPlayer(PLAYER_CONTAINER_ID, {
|
||||
onError: (message) => {
|
||||
errorMessage.value = message
|
||||
playing.value = false
|
||||
onWindowError: (windowIndex, message) => {
|
||||
errorMessage.value = `画面 ${windowIndex + 1}:${message}`
|
||||
},
|
||||
onPerformanceLack: () => Message.warning('当前浏览器性能不足,建议关闭其他视频窗口或使用子码流'),
|
||||
})
|
||||
if (sequence !== playbackSequence || !props.visible) return
|
||||
initializing.value = false
|
||||
observePlayerSize()
|
||||
const config: HikvisionPreviewConfig = { ...connection }
|
||||
await startHikvisionPreview(config)
|
||||
const result = await startHikvisionPreviews(previewConnections as HikvisionPreviewConfig[])
|
||||
if (sequence !== playbackSequence || !props.visible) {
|
||||
await stopHikvisionPreview()
|
||||
return
|
||||
}
|
||||
playing.value = true
|
||||
startedCount.value = result.started
|
||||
failedCount.value = result.failed
|
||||
playing.value = result.started > 0
|
||||
if (result.failed > 0) Message.warning(`${result.started} 路播放成功,${result.failed} 路连接失败`)
|
||||
resizePlayer()
|
||||
} catch (error) {
|
||||
if (sequence !== playbackSequence || !props.visible) return
|
||||
playing.value = false
|
||||
errorMessage.value = error instanceof Error ? error.message : '摄像头播放失败'
|
||||
} finally {
|
||||
if (sequence === playbackSequence) {
|
||||
initializing.value = false
|
||||
starting.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 停止当前摄像头预览。 */
|
||||
async function stopPreview(): Promise<void> {
|
||||
playbackSequence += 1
|
||||
await stopHikvisionPreview()
|
||||
playing.value = false
|
||||
startedCount.value = 0
|
||||
failedCount.value = 0
|
||||
}
|
||||
|
||||
/** 根据浮层中的容器大小同步 SDK 画布。 */
|
||||
@@ -457,14 +493,23 @@ function handleClosed(): void {
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
void stopPreview()
|
||||
connections.value.forEach((connection) => {
|
||||
connection.password = ''
|
||||
connection.secretKey = undefined
|
||||
})
|
||||
connections.value = []
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
watch(
|
||||
[
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
() => props.device?.asset_id,
|
||||
() => props.cameraIndex,
|
||||
() => props.cameraCount,
|
||||
() => (props.devices || []).map((device) => device.asset_id).join(','),
|
||||
],
|
||||
([visible]) => {
|
||||
if (visible) void openPreview()
|
||||
else handleClosed()
|
||||
},
|
||||
@@ -483,9 +528,12 @@ onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
window.removeEventListener('resize', handleViewportResize)
|
||||
void destroyHikvisionPlayer()
|
||||
connections.value.forEach((connection) => {
|
||||
connection.password = ''
|
||||
connection.secretKey = undefined
|
||||
})
|
||||
connections.value = []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
:data-room-id="roomId"
|
||||
:data-mapped-rack-count="sceneSummary.mappedRackCount"
|
||||
:data-rack-device-count="sceneSummary.equipmentCount"
|
||||
:data-model-camera-count="sceneSummary.importedCameraCount"
|
||||
:data-active-alert-count="signalSummary.activeAlertCount"
|
||||
>
|
||||
<div class="scene-toolbar">
|
||||
@@ -21,6 +22,12 @@
|
||||
<a-button size="small" :disabled="!modelReady" @click="toggleExteriorDoors">
|
||||
{{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!modelReady" @click="toggleAisleDoors">
|
||||
{{ aisleDoorsOpen ? '关闭过道门' : '打开过道门' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!modelReady" @click="togglePowerCabinetDoors">
|
||||
{{ powerCabinetDoorsOpen ? '关闭配电箱透明门' : '打开配电箱透明门' }}
|
||||
</a-button>
|
||||
<a-button size="small" :disabled="!modelReady" @click="toggleCabinetDoors">
|
||||
{{ cabinetDoorsOpen ? '关闭当前机柜门' : '打开机柜并查看设备' }}
|
||||
</a-button>
|
||||
@@ -45,7 +52,11 @@
|
||||
活动告警 {{ signalSummary.activeAlertCount }}
|
||||
</span>
|
||||
<span class="operation-tip">
|
||||
{{ insideAisle ? '当前斜向查看机柜正面;滚轮移动,拖拽转向' : '双击机柜门可开门聚焦,并查看柜内设备信息' }}
|
||||
{{
|
||||
insideAisle
|
||||
? '当前为机柜过道第一视角;点击设备可近距离查看,双击柜内设备可抽出或归位'
|
||||
: '双击门板可开合;机柜开门后双击设备可抽出或归位,点击摄像头播放监控、长按可拖动'
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +125,14 @@
|
||||
:scene="roomScene"
|
||||
@success="handleRoomDevicePlacementSuccess"
|
||||
/>
|
||||
<camera-preview-dialog v-model:visible="cameraPreviewVisible" :device="selectedDevice" @manage="handleOpenDeviceManagement" />
|
||||
<camera-preview-dialog
|
||||
v-model:visible="cameraPreviewVisible"
|
||||
:device="selectedDevice"
|
||||
:devices="sceneCameraDevices"
|
||||
:camera-index="selectedCameraIndex"
|
||||
:camera-count="sceneSummary.importedCameraCount"
|
||||
@manage="handleOpenDeviceManagement"
|
||||
/>
|
||||
<device-management-dialog
|
||||
v-model:visible="deviceManagementVisible"
|
||||
:room-id="roomId"
|
||||
@@ -141,10 +159,14 @@ import {
|
||||
fetchThreeMachineRoomDeviceObservability,
|
||||
fetchThreeMachineRoomScene,
|
||||
fetchThreeMachineRoomSignals,
|
||||
saveThreeMachineRoomDevicePlacement,
|
||||
type ThreeMachineRoomAlert,
|
||||
type ThreeMachineRoomDevice,
|
||||
type ThreeMachineRoomObservability,
|
||||
type ThreeMachineRoomRack,
|
||||
type ThreeMachineRoomScene,
|
||||
type ThreeMachineRoomSignal,
|
||||
type ThreeMachineRoomTransform,
|
||||
} from '@/api/ops/three-machine-room'
|
||||
import { getMockDeviceObservability, MOCK_ROOM_SCENE, MOCK_ROOM_SIGNALS } from './scene/MockThreeMachineRoom'
|
||||
import ThreeMap from './scene/ThreeMap'
|
||||
@@ -155,6 +177,7 @@ interface SceneSummary {
|
||||
rackCount: number
|
||||
mappedRackCount: number
|
||||
equipmentCount: number
|
||||
importedCameraCount: number
|
||||
}
|
||||
|
||||
/** 设备信号摘要。 */
|
||||
@@ -165,6 +188,20 @@ interface SignalSummary {
|
||||
abnormalCount: number
|
||||
}
|
||||
|
||||
/** 告警设备弹窗点击上下文。 */
|
||||
interface DeviceAlertCalloutContext {
|
||||
assetId: string | number
|
||||
device: ThreeMachineRoomDevice | null
|
||||
signal: ThreeMachineRoomSignal | null
|
||||
alert: ThreeMachineRoomAlert | null
|
||||
}
|
||||
|
||||
/** 模型内置摄像头点击上下文。 */
|
||||
interface ImportedCameraContext {
|
||||
index: number
|
||||
name: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const assetBasePath = `${import.meta.env.BASE_URL}three-machine-room/`
|
||||
@@ -180,12 +217,15 @@ const signalsLoading = ref(false)
|
||||
const usingMockData = ref(false)
|
||||
const wallTransparent = ref(true)
|
||||
const exteriorDoorsOpen = ref(false)
|
||||
const aisleDoorsOpen = ref(false)
|
||||
const powerCabinetDoorsOpen = ref(false)
|
||||
const cabinetDoorsOpen = ref(false)
|
||||
const serversVisible = ref(true)
|
||||
const insideAisle = ref(false)
|
||||
const roomScene = ref<ThreeMachineRoomScene | null>(null)
|
||||
const selectedRack = ref<ThreeMachineRoomRack | null>(null)
|
||||
const selectedDevice = ref<ThreeMachineRoomDevice | null>(null)
|
||||
const selectedCameraIndex = ref<number>()
|
||||
const activeRack = ref<ThreeMachineRoomRack | null>(null)
|
||||
const rackPlacementVisible = ref(false)
|
||||
const rackLayoutVisible = ref(false)
|
||||
@@ -199,6 +239,7 @@ const sceneSummary = reactive<SceneSummary>({
|
||||
rackCount: 0,
|
||||
mappedRackCount: 0,
|
||||
equipmentCount: 0,
|
||||
importedCameraCount: 0,
|
||||
})
|
||||
|
||||
const signalSummary = reactive<SignalSummary>({
|
||||
@@ -220,6 +261,7 @@ const roomId = resolveRoomId()
|
||||
const roomName = computed(() => roomScene.value?.room?.name || `机房 ${roomId}`)
|
||||
const layoutVersion = computed(() => Number(roomScene.value?.room?.layout_version) || 0)
|
||||
const activeRackDevices = computed(() => (Array.isArray(activeRack.value?.devices) ? activeRack.value.devices : []))
|
||||
const sceneCameraDevices = computed(() => getSceneCameraDevices())
|
||||
const loadingText = computed(() => {
|
||||
if (modelLoading.value) {
|
||||
return modelProgress.value > 0 ? `3D 模型加载中 ${modelProgress.value}%` : '3D 模型加载中'
|
||||
@@ -395,10 +437,38 @@ function isCameraDevice(device: ThreeMachineRoomDevice): boolean {
|
||||
return ['camera', 'cctv', '摄像', '监控'].some((keyword) => searchableText.includes(keyword)) || categoryCode === 'ipc'
|
||||
}
|
||||
|
||||
/** 获取当前场景中可作为模型摄像头播放源的资产,并按资产 ID 保持稳定顺序。 */
|
||||
function getSceneCameraDevices(): ThreeMachineRoomDevice[] {
|
||||
const deviceMap = new Map<number, ThreeMachineRoomDevice>()
|
||||
const devices = [
|
||||
...(Array.isArray(roomScene.value?.room_devices) ? roomScene.value.room_devices : []),
|
||||
...(Array.isArray(roomScene.value?.racks)
|
||||
? roomScene.value.racks.flatMap((rack) => (Array.isArray(rack.devices) ? rack.devices : []))
|
||||
: []),
|
||||
]
|
||||
devices.forEach((device) => {
|
||||
const assetId = Number(device.asset_id)
|
||||
if (isCameraDevice(device) && Number.isInteger(assetId) && assetId > 0 && !deviceMap.has(assetId)) {
|
||||
deviceMap.set(assetId, device)
|
||||
}
|
||||
})
|
||||
return Array.from(deviceMap.values()).sort((left, right) => Number(left.asset_id) - Number(right.asset_id))
|
||||
}
|
||||
|
||||
/** 点击模型内置摄像头时,按序关联场景摄像头资产并打开对应通道。 */
|
||||
function handleImportedCameraSelected(camera: ImportedCameraContext): void {
|
||||
const cameraIndex = Number(camera.index)
|
||||
if (!Number.isInteger(cameraIndex) || cameraIndex <= 0) return
|
||||
selectedCameraIndex.value = cameraIndex
|
||||
selectedDevice.value = sceneCameraDevices.value[cameraIndex - 1] || null
|
||||
cameraPreviewVisible.value = true
|
||||
}
|
||||
|
||||
/** 点击摄像头时打开预览,其他设备进入详情管理。 */
|
||||
function handleDeviceSelected(device: ThreeMachineRoomDevice): void {
|
||||
selectedDevice.value = device
|
||||
if (isCameraDevice(device)) {
|
||||
selectedCameraIndex.value = undefined
|
||||
cameraPreviewVisible.value = true
|
||||
return
|
||||
}
|
||||
@@ -410,6 +480,59 @@ function handleDeviceSelected(device: ThreeMachineRoomDevice): void {
|
||||
deviceManagementVisible.value = true
|
||||
}
|
||||
|
||||
/** 从设备告警弹窗进入告警受理处理页面。 */
|
||||
function handleAlertCalloutClick(context: DeviceAlertCalloutContext): void {
|
||||
const query: Record<string, string> = {
|
||||
source: 'three-machine-room',
|
||||
room_id: String(roomId),
|
||||
asset_id: String(context.assetId),
|
||||
}
|
||||
const keyword = context.alert?.alert_name || context.alert?.summary
|
||||
if (keyword) query.keyword = keyword
|
||||
if (context.alert?.id) query.alert_record_id = String(context.alert.id)
|
||||
void router.push({ path: '/alert/tackle', query })
|
||||
}
|
||||
|
||||
/** 保存用户在场景中拖拽后的独立摄像头位置。 */
|
||||
async function handleRoomDeviceMoved(device: ThreeMachineRoomDevice, transform: ThreeMachineRoomTransform): Promise<boolean> {
|
||||
if (usingMockData.value) {
|
||||
Message.warning('演示数据不支持保存摄像头位置')
|
||||
return false
|
||||
}
|
||||
const assetId = Number(device.asset_id)
|
||||
const expectedVersion = layoutVersion.value
|
||||
if (!Number.isInteger(assetId) || assetId <= 0 || expectedVersion <= 0) {
|
||||
Message.error('摄像头或布局版本无效,请刷新场景后重试')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const result = await saveThreeMachineRoomDevicePlacement(roomId, assetId, {
|
||||
expectedVersion,
|
||||
positionX: transform.position_x,
|
||||
positionY: transform.position_y,
|
||||
positionZ: transform.position_z,
|
||||
rotationX: Number(transform.rotation_x) || 0,
|
||||
rotationY: Number(transform.rotation_y) || 0,
|
||||
rotationZ: Number(transform.rotation_z) || 0,
|
||||
sceneWidth: Math.max(0.01, Number(device.size?.width) || 1),
|
||||
sceneHeight: Math.max(0.01, Number(device.size?.height) || 1),
|
||||
sceneDepth: Math.max(0.01, Number(device.size?.depth) || 1),
|
||||
})
|
||||
device.transform = transform
|
||||
if (roomScene.value?.room) roomScene.value.room.layout_version = result.layout_version
|
||||
Message.success('摄像头位置已保存')
|
||||
return true
|
||||
} catch (error) {
|
||||
Message.error(getErrorMessage(error, '摄像头位置保存失败,已恢复原位置'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 提示模型内置摄像头已在当前场景中完成移动。 */
|
||||
function handleImportedCameraMoved(camera: { index: number }): void {
|
||||
Message.success(`模型摄像头 ${camera.index} 位置已调整`)
|
||||
}
|
||||
|
||||
/** 从摄像头预览切换到设备详情管理。 */
|
||||
function handleOpenDeviceManagement(): void {
|
||||
cameraPreviewVisible.value = false
|
||||
@@ -459,10 +582,20 @@ async function initializeMachineRoom(): Promise<void> {
|
||||
assetBasePath,
|
||||
onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary),
|
||||
onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device),
|
||||
onAlertCalloutClick: (context: DeviceAlertCalloutContext) => handleAlertCalloutClick(context),
|
||||
onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack),
|
||||
onCabinetDoorChanged: (rack: ThreeMachineRoomRack | null) => {
|
||||
activeRack.value = rack
|
||||
},
|
||||
onDoorStateChanged: (states: { exteriorOpen: boolean; aisleOpen: boolean; powerCabinetOpen: boolean; cabinetOpen: boolean }) => {
|
||||
exteriorDoorsOpen.value = states.exteriorOpen
|
||||
aisleDoorsOpen.value = states.aisleOpen
|
||||
powerCabinetDoorsOpen.value = states.powerCabinetOpen
|
||||
cabinetDoorsOpen.value = states.cabinetOpen
|
||||
},
|
||||
onRoomDeviceMoved: (device: ThreeMachineRoomDevice, transform: ThreeMachineRoomTransform) => handleRoomDeviceMoved(device, transform),
|
||||
onImportedCameraSelected: (camera: ImportedCameraContext) => handleImportedCameraSelected(camera),
|
||||
onImportedCameraMoved: (camera: { index: number }) => handleImportedCameraMoved(camera),
|
||||
onModelLoading: (loading: boolean) => {
|
||||
modelLoading.value = loading
|
||||
},
|
||||
@@ -507,6 +640,16 @@ function toggleExteriorDoors(): void {
|
||||
if (machineRoomMap) exteriorDoorsOpen.value = machineRoomMap.toggleExteriorDoors()
|
||||
}
|
||||
|
||||
/** 切换封闭过道两端的双开门。 */
|
||||
function toggleAisleDoors(): void {
|
||||
if (machineRoomMap) aisleDoorsOpen.value = machineRoomMap.toggleAisleDoors()
|
||||
}
|
||||
|
||||
/** 切换配电箱透明门并查看原始电气面板贴图。 */
|
||||
function togglePowerCabinetDoors(): void {
|
||||
if (machineRoomMap) powerCabinetDoorsOpen.value = machineRoomMap.togglePowerCabinetDoors()
|
||||
}
|
||||
|
||||
/** 打开有设备的机柜并聚焦,或关闭当前机柜门。 */
|
||||
function toggleCabinetDoors(): void {
|
||||
if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user