This commit is contained in:
2026-09-15 11:21:06 +08:00
parent 781d1af337
commit f045753d52
6 changed files with 1456 additions and 173 deletions

View File

@@ -16,9 +16,17 @@ export interface HikvisionPreviewConfig {
/** 海康播放器运行事件。 */ /** 海康播放器运行事件。 */
export interface HikvisionPlayerCallbacks { export interface HikvisionPlayerCallbacks {
onError?: (message: string) => void onError?: (message: string) => void
onWindowError?: (windowIndex: number, message: string) => void
onPerformanceLack?: () => void onPerformanceLack?: () => void
} }
/** 多窗口实时预览启动结果。 */
export interface HikvisionMultiPreviewResult {
started: number
failed: number
errors: string[]
}
const SDK_SCRIPT_PATHS = [ const SDK_SCRIPT_PATHS = [
'jsPlugin/jquery.min.js', 'jsPlugin/jquery.min.js',
'encryption/AES.js', 'encryption/AES.js',
@@ -42,7 +50,7 @@ const PLAYER_ERROR_MESSAGES: Record<number, string> = {
let scriptsPromise: Promise<void> | null = null let scriptsPromise: Promise<void> | null = null
let initializationPromise: Promise<void> | null = null let initializationPromise: Promise<void> | null = null
let initializedContainerId = '' let initializedContainerId = ''
let activeDeviceIdentify = '' const activeDeviceIdentifies = new Set<string>()
let runtimeCallbacks: HikvisionPlayerCallbacks = {} let runtimeCallbacks: HikvisionPlayerCallbacks = {}
let previewOperation = 0 let previewOperation = 0
@@ -137,8 +145,10 @@ export async function initializeHikvisionPlayer(containerId: string, callbacks:
const result = controller.I_InsertOBJECTPlugin(containerId) const result = controller.I_InsertOBJECTPlugin(containerId)
finish(result === 0 ? undefined : new Error('海康播放器挂载失败')) finish(result === 0 ? undefined : new Error('海康播放器挂载失败'))
}, },
cbPluginErrorHandler: (_windowIndex, errorCode) => { cbPluginErrorHandler: (windowIndex, errorCode) => {
runtimeCallbacks.onError?.(PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode})`) const message = PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode})`
runtimeCallbacks.onWindowError?.(windowIndex, message)
runtimeCallbacks.onError?.(message)
}, },
cbPerformanceLack: () => runtimeCallbacks.onPerformanceLack?.(), cbPerformanceLack: () => runtimeCallbacks.onPerformanceLack?.(),
cbSecretKeyError: () => runtimeCallbacks.onError?.('码流加密密钥错误'), cbSecretKeyError: () => runtimeCallbacks.onError?.('码流加密密钥错误'),
@@ -153,15 +163,29 @@ export async function initializeHikvisionPlayer(containerId: string, callbacks:
return initializationPromise return initializationPromise
} }
/** 停止当前窗口中的实时预览。 */ /** 根据画面数量返回海康 SDK 的分屏类型。 */
async function stopWindow(): Promise<void> { 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() 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) => { await new Promise<void>((resolve) => {
const timeout = window.setTimeout(resolve, 3000) const timeout = window.setTimeout(resolve, 3000)
controller.I_Stop({ controller.I_Stop({
iIndex: 0, iIndex: windowIndex,
success: () => { success: () => {
window.clearTimeout(timeout) window.clearTimeout(timeout)
resolve() resolve()
@@ -174,22 +198,29 @@ async function stopWindow(): Promise<void> {
}) })
} }
/** 停止预览并注销当前设备。 */ /** 停止全部分屏;SDK 不支持批量停止时逐个窗口回退处理。 */
export async function stopHikvisionPreview(): Promise<void> { async function stopAllWindows(): Promise<void> {
previewOperation += 1 const controller = getController()
if (!window.WebVideoCtrl || !initializedContainerId) return try {
await stopWindow() await controller.I_StopAll()
if (activeDeviceIdentify) { } catch (_error) {
getController().I_Logout(activeDeviceIdentify) await Promise.all(Array.from({ length: 16 }, (_item, index) => stopWindow(index)))
activeDeviceIdentify = ''
} }
} }
/** 登录摄像头并开始实时预览。 */ /** 停止全部预览并注销本次使用的设备。 */
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() const controller = getController()
await stopHikvisionPreview()
const operation = ++previewOperation
const deviceIdentify = `${config.host}_${config.httpPort}` const deviceIdentify = `${config.host}_${config.httpPort}`
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -222,7 +253,18 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
controller.I_Logout(deviceIdentify) controller.I_Logout(deviceIdentify)
throw new Error('摄像头播放已取消') 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 { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -238,7 +280,7 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
error ? reject(error) : resolve() error ? reject(error) : resolve()
} }
controller.I_StartRealPlay(deviceIdentify, { controller.I_StartRealPlay(deviceIdentify, {
iWndIndex: 0, iWndIndex: windowIndex,
iRtspPort: config.rtspPort, iRtspPort: config.rtspPort,
iWSPort: config.webSocketPort, iWSPort: config.webSocketPort,
iStreamType: config.streamType, iStreamType: config.streamType,
@@ -247,7 +289,7 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
bProxy: config.proxyEnabled, bProxy: config.proxyEnabled,
success: () => { success: () => {
if (settled || operation !== previewOperation) { if (settled || operation !== previewOperation) {
void stopWindow().then(() => controller.I_Logout(deviceIdentify)) void stopWindow(windowIndex)
if (settled) return if (settled) return
finish(new Error('摄像头播放已取消')) finish(new Error('摄像头播放已取消'))
return return
@@ -258,13 +300,73 @@ export async function startHikvisionPreview(config: HikvisionPreviewConfig): Pro
}) })
}) })
if (operation !== previewOperation) throw new Error('摄像头播放已取消') 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) { } catch (error) {
await stopHikvisionPreview() await stopWindow(windowIndex)
throw error 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 { export function resizeHikvisionPlayer(width: number, height: number): void {
if (!window.WebVideoCtrl || !initializedContainerId || width <= 0 || height <= 0) return if (!window.WebVideoCtrl || !initializedContainerId || width <= 0 || height <= 0) return

View File

@@ -42,6 +42,7 @@ interface HikvisionWebVideoCtrl {
) => number | void ) => number | void
I_Logout: (deviceIdentify: string) => number I_Logout: (deviceIdentify: string) => number
I_StartRealPlay: (deviceIdentify: string, options: HikvisionRealPlayOptions) => void I_StartRealPlay: (deviceIdentify: string, options: HikvisionRealPlayOptions) => void
I_ChangeWndNum: (windowType: number) => Promise<unknown>
I_GetWindowStatus: (windowIndex: number) => { szDeviceIdentify?: string } | null I_GetWindowStatus: (windowIndex: number) => { szDeviceIdentify?: string } | null
I_Stop: (options: HikvisionSdkCallbacks & { iIndex?: number }) => void I_Stop: (options: HikvisionSdkCallbacks & { iIndex?: number }) => void
I_StopAll: () => Promise<unknown> | void I_StopAll: () => Promise<unknown> | void

View File

@@ -155,7 +155,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, computed, onMounted } from 'vue' import { ref, reactive, computed, onMounted } from 'vue'
import { Message } from '@arco-design/web-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 { FormItem } from '@/components/search-form/types'
import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface' import type { SelectOptionData } from '@arco-design/web-vue/es/select/interface'
import SearchTable from '@/components/search-table/index.vue' 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 DetailDialog from './components/DetailDialog.vue'
import AssignDialog from './components/AssignDialog.vue' import AssignDialog from './components/AssignDialog.vue'
const route = useRoute()
const router = useRouter() const router = useRouter()
const loading = ref(false) const loading = ref(false)
@@ -229,6 +230,8 @@ const formItems = computed<FormItem[]>(() =>
) )
onMounted(async () => { onMounted(async () => {
const routeKeyword = Array.isArray(route.query.keyword) ? route.query.keyword[0] : route.query.keyword
if (routeKeyword) formModel.value.keyword = routeKeyword
await loadSeverityOptions() await loadSeverityOptions()
handleSearch() handleSearch()
loadTree() loadTree()

View File

@@ -34,6 +34,7 @@
■ ■
</button> </button>
<button <button
v-if="props.device?.asset_id"
type="button" type="button"
class="camera-preview-floating__action" class="camera-preview-floating__action"
title="设备管理" title="设备管理"
@@ -56,9 +57,9 @@
<div ref="playerWrapper" class="camera-preview-floating__player"> <div ref="playerWrapper" class="camera-preview-floating__player">
<div :id="PLAYER_CONTAINER_ID" class="camera-preview__canvas"></div> <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" /> <a-spin :size="22" />
<span>播放器初始化中</span> <span>{{ initializing ? '播放器初始化中' : `正在连接 ${previewCount} 路摄像头` }}</span>
</div> </div>
<div <div
v-else-if="!playing" v-else-if="!playing"
@@ -70,7 +71,7 @@
</div> </div>
<div v-else class="camera-preview-floating__live"> <div v-else class="camera-preview-floating__live">
<i></i> <i></i>
实时 {{ startedCount }}/{{ previewCount }} 实时
</div> </div>
</div> </div>
</aside> </aside>
@@ -86,7 +87,7 @@ import {
destroyHikvisionPlayer, destroyHikvisionPlayer,
initializeHikvisionPlayer, initializeHikvisionPlayer,
resizeHikvisionPlayer, resizeHikvisionPlayer,
startHikvisionPreview, startHikvisionPreviews,
stopHikvisionPreview, stopHikvisionPreview,
type HikvisionPreviewConfig, type HikvisionPreviewConfig,
} from '@/services/hikvisionWebSdk' } from '@/services/hikvisionWebSdk'
@@ -95,6 +96,9 @@ import {
interface Props { interface Props {
visible: boolean visible: boolean
device: ThreeMachineRoomDevice | null device: ThreeMachineRoomDevice | null
devices?: ThreeMachineRoomDevice[]
cameraIndex?: number
cameraCount?: number
} }
/** 摄像头预览浮层事件。 */ /** 摄像头预览浮层事件。 */
@@ -119,11 +123,17 @@ interface CameraConnectionForm {
} }
const PLAYER_CONTAINER_ID = 'hikvision-camera-preview-player' const PLAYER_CONTAINER_ID = 'hikvision-camera-preview-player'
const FIXED_CAMERA_HOST = '192.168.1.101' /** 3D 预览使用已验证通过的固定 WebSDK 参数,设备身份信息由资产详情提供。 */
const DEFAULT_CAMERA_USERNAME = 'admin' const FIXED_CAMERA_CONFIG: Omit<CameraConnectionForm, 'host' | 'username' | 'password'> = {
const DEFAULT_CAMERA_PASSWORD = 'Xzrmyy@12' protocol: 2,
const FLOATING_LAYER_WIDTH = 200 httpPort: 443,
const FLOATING_LAYER_HEIGHT = 200 rtspPort: 554,
webSocketPort: 7862,
channelId: 1,
streamType: 2,
proxyEnabled: true,
secretKey: undefined,
}
const FLOATING_LAYER_MARGIN = 16 const FLOATING_LAYER_MARGIN = 16
const FLOATING_LAYER_DEFAULT_TOP = 88 const FLOATING_LAYER_DEFAULT_TOP = 88
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -135,10 +145,13 @@ const starting = ref(false)
const playing = ref(false) const playing = ref(false)
const dragging = ref(false) const dragging = ref(false)
const errorMessage = ref('') 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({ const floatingLayerPosition = reactive({
x: Math.max(0, window.innerWidth - FLOATING_LAYER_WIDTH - FLOATING_LAYER_MARGIN), x: Math.max(0, window.innerWidth - 720 - FLOATING_LAYER_MARGIN),
y: Math.min(FLOATING_LAYER_DEFAULT_TOP, Math.max(0, window.innerHeight - FLOATING_LAYER_HEIGHT)), y: Math.min(FLOATING_LAYER_DEFAULT_TOP, Math.max(0, window.innerHeight - 500)),
}) })
const developmentProxyConfigured = Boolean(import.meta.env.VITE_HIKVISION_PROXY_TARGET) const developmentProxyConfigured = Boolean(import.meta.env.VITE_HIKVISION_PROXY_TARGET)
let resizeObserver: ResizeObserver | null = null let resizeObserver: ResizeObserver | null = null
@@ -148,32 +161,47 @@ let dragPointerId: number | null = null
let dragOffsetX = 0 let dragOffsetX = 0
let dragOffsetY = 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(() => ({ const floatingLayerStyle = computed(() => ({
width: `${floatingLayerDimensions.value.width}px`,
height: `${floatingLayerDimensions.value.height}px`,
transform: `translate3d(${floatingLayerPosition.x}px, ${floatingLayerPosition.y}px, 0)`, transform: `translate3d(${floatingLayerPosition.x}px, ${floatingLayerPosition.y}px, 0)`,
})) }))
const developmentProxyTip = computed(() => { 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,或切换为直连。' 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 { function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value) ? (value as 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 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 if (!import.meta.env.DEV || !connection.proxyEnabled || !developmentProxyConfigured) return true
try { try {
const httpTarget = new URL(import.meta.env.VITE_HIKVISION_PROXY_TARGET!) 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> { function parseSourceAddress(sourceAddress: unknown): Record<string, unknown> {
if (typeof sourceAddress !== 'string' || !sourceAddress.trim()) return {} if (typeof sourceAddress !== 'string' || !sourceAddress.trim()) return {}
const source = sourceAddress.trim() const source = sourceAddress.trim()
@@ -236,80 +247,81 @@ function parseSourceAddress(sourceAddress: unknown): Record<string, unknown> {
try { try {
const sourceUrl = new URL(source.includes('://') ? source : `http://${source}`) 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 { return {
host: sourceUrl.hostname, 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, username: sourceUrl.username ? decodeURIComponent(sourceUrl.username) : undefined,
password: sourceUrl.password ? decodeURIComponent(sourceUrl.password) : 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) { } catch (_error) {
return { host: source } return { host: source }
} }
} }
/** 将资产详情映射为 WebSDK 连接参数。 */ /** 只将资产详情中的 IP、用户名和密码映射到固定 WebSDK 参数。 */
function applyAssetCameraConfig(asset: Record<string, unknown>): void { function applyAssetCameraConfig(asset: Record<string, unknown>): CameraConnectionForm {
const sourceConfig = parseSourceAddress(asset.source_address) const sourceConfig = parseSourceAddress(asset.source_address)
const nestedConfig = const nestedConfig =
asRecord(asset.camera_config) || asRecord(asset.video_config) || asRecord(asset.stream_config) || asRecord(asset.hikvision_config) 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 records = [nestedConfig, asset, sourceConfig].filter((item): item is Record<string, unknown> => Boolean(item))
const protocolValue = readConfigValue(records, ['camera_protocol', 'protocol', 'http_protocol']) return {
const normalizedProtocol = String(protocolValue).toLowerCase() host: String(readConfigValue(records, ['camera_host', 'host', 'ip_address', 'ip', 'device_ip', 'device_address']) || '').trim(),
const protocol: 1 | 2 = protocolValue === 2 || normalizedProtocol === '2' || normalizedProtocol === 'https' ? 2 : 1 username: String(readConfigValue(records, ['camera_username', 'username', 'user', 'login_username', 'login_name']) || '').trim(),
password: String(readConfigValue(records, ['camera_password', 'password', 'login_password']) || ''),
connection.host = FIXED_CAMERA_HOST ...FIXED_CAMERA_CONFIG,
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
} }
/** 获取资产详情中的摄像头连接配置。 */ /** 获取单个资产详情中的摄像头连接配置。 */
async function loadAssetCameraConfig(): Promise<void> { async function loadAssetCameraConfig(device: ThreeMachineRoomDevice): Promise<CameraConnectionForm> {
if (!props.device?.resource_uid) return if (!device.asset_id) throw new Error('摄像头未关联资产')
const response = (await fetchAssetDetail(props.device.resource_uid)) as unknown const response = (await fetchAssetDetail(device.asset_id)) as unknown
const responseRecord = asRecord(response) const responseRecord = asRecord(response)
if (!responseRecord || Number(responseRecord.code) !== 0) { if (!responseRecord || Number(responseRecord.code) !== 0) {
throw new Error(String(responseRecord?.message || '摄像头资产详情获取失败')) throw new Error(String(responseRecord?.message || '摄像头资产详情获取失败'))
} }
const details = asRecord(responseRecord.details) const details = asRecord(responseRecord.details)
if (!details) throw new Error('摄像头资产详情格式错误') 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> { function getCameraDevice(cameraIndex: number): ThreeMachineRoomDevice | null {
try { const listedDevice = props.devices?.[cameraIndex - 1]
await loadAssetCameraConfig() if (listedDevice) return listedDevice
} catch (error) { return cameraIndex === selectedCameraSlot.value ? props.device : null
console.warn('摄像头资产配置获取失败,使用前端预置参数:', error) }
Message.warning('摄像头资产配置获取失败,已使用前端预置参数')
/** 并行加载全部宫格的摄像头身份信息;固定的 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 { function clampFloatingLayerPosition(): void {
const maxX = Math.max(0, window.innerWidth - FLOATING_LAYER_WIDTH) const width = floatingLayer.value?.offsetWidth || floatingLayerDimensions.value.width
const maxY = Math.max(0, window.innerHeight - FLOATING_LAYER_HEIGHT) 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.x = Math.min(Math.max(0, floatingLayerPosition.x), maxX)
floatingLayerPosition.y = Math.min(Math.max(0, floatingLayerPosition.y), maxY) floatingLayerPosition.y = Math.min(Math.max(0, floatingLayerPosition.y), maxY)
} }
@@ -348,39 +360,58 @@ function startDragging(event: PointerEvent): void {
/** 浏览器尺寸变化时保持浮层可见,并同步播放器大小。 */ /** 浏览器尺寸变化时保持浮层可见,并同步播放器大小。 */
function handleViewportResize(): void { function handleViewportResize(): void {
viewportSize.width = window.innerWidth
viewportSize.height = window.innerHeight
clampFloatingLayerPosition() clampFloatingLayerPosition()
resizePlayer() resizePlayer()
} }
/** 打开浮层并准备连接参数,不在页面加载阶段连接摄像头。 */ /** 打开浮层、加载当前摄像头连接参数并自动开始预览。 */
async function openPreview(): Promise<void> { async function openPreview(): Promise<void> {
const sequence = ++openSequence const sequence = ++openSequence
Object.assign(connection, createDefaultConnection()) playbackSequence += 1
await stopHikvisionPreview()
if (sequence !== openSequence || !props.visible) return
connections.value = []
errorMessage.value = '' errorMessage.value = ''
playing.value = false playing.value = false
startedCount.value = 0
failedCount.value = 0
initializing.value = false initializing.value = false
starting.value = false
await loadOptionalAssetCameraConfig() connections.value = await loadPreviewConnections()
if (sequence !== openSequence || !props.visible) return if (sequence !== openSequence || !props.visible) return
await nextTick() await nextTick()
clampFloatingLayerPosition() clampFloatingLayerPosition()
resizePlayer() resizePlayer()
await startPreview()
} }
/** 校验参数并开始实时预览。 */ /** 校验参数并开始实时预览。 */
async function startPreview(): Promise<void> { async function startPreview(): Promise<void> {
const sequence = ++playbackSequence const sequence = ++playbackSequence
errorMessage.value = '' errorMessage.value = ''
if (!connection.host || !connection.username || !connection.password) { const selectedIndex = Math.min(connections.value.length - 1, Math.max(0, selectedCameraSlot.value - 1))
errorMessage.value = '请填写摄像头地址、用户名和密码' 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 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 = '开发环境同源代理尚未配置,请先填写海康代理目标,或切换为直连' errorMessage.value = '开发环境同源代理尚未配置,请先填写海康代理目标,或切换为直连'
return return
} }
if (!matchesDevelopmentProxyTarget()) { if (previewConnections.some((connection) => !matchesDevelopmentProxyTarget(connection))) {
errorMessage.value = '当前摄像头地址与固定开发代理目标不一致,请修改代理配置后重启开发服务器' errorMessage.value = '部分摄像头地址与固定开发代理目标不一致,请修改代理配置后重启开发服务器'
return return
} }
@@ -390,30 +421,33 @@ async function startPreview(): Promise<void> {
await nextTick() await nextTick()
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve())) await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
await initializeHikvisionPlayer(PLAYER_CONTAINER_ID, { await initializeHikvisionPlayer(PLAYER_CONTAINER_ID, {
onError: (message) => { onWindowError: (windowIndex, message) => {
errorMessage.value = message errorMessage.value = `画面 ${windowIndex + 1}:${message}`
playing.value = false
}, },
onPerformanceLack: () => Message.warning('当前浏览器性能不足,建议关闭其他视频窗口或使用子码流'), onPerformanceLack: () => Message.warning('当前浏览器性能不足,建议关闭其他视频窗口或使用子码流'),
}) })
if (sequence !== playbackSequence || !props.visible) return if (sequence !== playbackSequence || !props.visible) return
initializing.value = false initializing.value = false
observePlayerSize() observePlayerSize()
const config: HikvisionPreviewConfig = { ...connection } const result = await startHikvisionPreviews(previewConnections as HikvisionPreviewConfig[])
await startHikvisionPreview(config)
if (sequence !== playbackSequence || !props.visible) { if (sequence !== playbackSequence || !props.visible) {
await stopHikvisionPreview() await stopHikvisionPreview()
return 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() resizePlayer()
} catch (error) { } catch (error) {
if (sequence !== playbackSequence || !props.visible) return if (sequence !== playbackSequence || !props.visible) return
playing.value = false playing.value = false
errorMessage.value = error instanceof Error ? error.message : '摄像头播放失败' errorMessage.value = error instanceof Error ? error.message : '摄像头播放失败'
} finally { } finally {
initializing.value = false if (sequence === playbackSequence) {
starting.value = false initializing.value = false
starting.value = false
}
} }
} }
@@ -422,6 +456,8 @@ async function stopPreview(): Promise<void> {
playbackSequence += 1 playbackSequence += 1
await stopHikvisionPreview() await stopHikvisionPreview()
playing.value = false playing.value = false
startedCount.value = 0
failedCount.value = 0
} }
/** 根据浮层中的容器大小同步 SDK 画布。 */ /** 根据浮层中的容器大小同步 SDK 画布。 */
@@ -457,14 +493,23 @@ function handleClosed(): void {
resizeObserver?.disconnect() resizeObserver?.disconnect()
resizeObserver = null resizeObserver = null
void stopPreview() void stopPreview()
connection.password = '' connections.value.forEach((connection) => {
connection.secretKey = undefined connection.password = ''
connection.secretKey = undefined
})
connections.value = []
errorMessage.value = '' errorMessage.value = ''
} }
watch( watch(
() => props.visible, [
(visible) => { () => props.visible,
() => props.device?.asset_id,
() => props.cameraIndex,
() => props.cameraCount,
() => (props.devices || []).map((device) => device.asset_id).join(','),
],
([visible]) => {
if (visible) void openPreview() if (visible) void openPreview()
else handleClosed() else handleClosed()
}, },
@@ -483,8 +528,11 @@ onBeforeUnmount(() => {
resizeObserver?.disconnect() resizeObserver?.disconnect()
window.removeEventListener('resize', handleViewportResize) window.removeEventListener('resize', handleViewportResize)
void destroyHikvisionPlayer() void destroyHikvisionPlayer()
connection.password = '' connections.value.forEach((connection) => {
connection.secretKey = undefined connection.password = ''
connection.secretKey = undefined
})
connections.value = []
}) })
</script> </script>

View File

@@ -6,6 +6,7 @@
:data-room-id="roomId" :data-room-id="roomId"
:data-mapped-rack-count="sceneSummary.mappedRackCount" :data-mapped-rack-count="sceneSummary.mappedRackCount"
:data-rack-device-count="sceneSummary.equipmentCount" :data-rack-device-count="sceneSummary.equipmentCount"
:data-model-camera-count="sceneSummary.importedCameraCount"
:data-active-alert-count="signalSummary.activeAlertCount" :data-active-alert-count="signalSummary.activeAlertCount"
> >
<div class="scene-toolbar"> <div class="scene-toolbar">
@@ -21,6 +22,12 @@
<a-button size="small" :disabled="!modelReady" @click="toggleExteriorDoors"> <a-button size="small" :disabled="!modelReady" @click="toggleExteriorDoors">
{{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }} {{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }}
</a-button> </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"> <a-button size="small" :disabled="!modelReady" @click="toggleCabinetDoors">
{{ cabinetDoorsOpen ? '关闭当前机柜门' : '打开机柜并查看设备' }} {{ cabinetDoorsOpen ? '关闭当前机柜门' : '打开机柜并查看设备' }}
</a-button> </a-button>
@@ -45,7 +52,11 @@
活动告警 {{ signalSummary.activeAlertCount }} 活动告警 {{ signalSummary.activeAlertCount }}
</span> </span>
<span class="operation-tip"> <span class="operation-tip">
{{ insideAisle ? '当前斜向查看机柜正面;滚轮移动,拖拽转向' : '双击机柜门可开门聚焦,并查看柜内设备信息' }} {{
insideAisle
? '当前为机柜过道第一视角;点击设备可近距离查看,双击柜内设备可抽出或归位'
: '双击门板可开合;机柜开门后双击设备可抽出或归位,点击摄像头播放监控、长按可拖动'
}}
</span> </span>
</div> </div>
</div> </div>
@@ -114,7 +125,14 @@
:scene="roomScene" :scene="roomScene"
@success="handleRoomDevicePlacementSuccess" @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 <device-management-dialog
v-model:visible="deviceManagementVisible" v-model:visible="deviceManagementVisible"
:room-id="roomId" :room-id="roomId"
@@ -141,10 +159,14 @@ import {
fetchThreeMachineRoomDeviceObservability, fetchThreeMachineRoomDeviceObservability,
fetchThreeMachineRoomScene, fetchThreeMachineRoomScene,
fetchThreeMachineRoomSignals, fetchThreeMachineRoomSignals,
saveThreeMachineRoomDevicePlacement,
type ThreeMachineRoomAlert,
type ThreeMachineRoomDevice, type ThreeMachineRoomDevice,
type ThreeMachineRoomObservability, type ThreeMachineRoomObservability,
type ThreeMachineRoomRack, type ThreeMachineRoomRack,
type ThreeMachineRoomScene, type ThreeMachineRoomScene,
type ThreeMachineRoomSignal,
type ThreeMachineRoomTransform,
} from '@/api/ops/three-machine-room' } from '@/api/ops/three-machine-room'
import { getMockDeviceObservability, MOCK_ROOM_SCENE, MOCK_ROOM_SIGNALS } from './scene/MockThreeMachineRoom' import { getMockDeviceObservability, MOCK_ROOM_SCENE, MOCK_ROOM_SIGNALS } from './scene/MockThreeMachineRoom'
import ThreeMap from './scene/ThreeMap' import ThreeMap from './scene/ThreeMap'
@@ -155,6 +177,7 @@ interface SceneSummary {
rackCount: number rackCount: number
mappedRackCount: number mappedRackCount: number
equipmentCount: number equipmentCount: number
importedCameraCount: number
} }
/** 设备信号摘要。 */ /** 设备信号摘要。 */
@@ -165,6 +188,20 @@ interface SignalSummary {
abnormalCount: number 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 route = useRoute()
const router = useRouter() const router = useRouter()
const assetBasePath = `${import.meta.env.BASE_URL}three-machine-room/` const assetBasePath = `${import.meta.env.BASE_URL}three-machine-room/`
@@ -180,12 +217,15 @@ const signalsLoading = ref(false)
const usingMockData = ref(false) const usingMockData = ref(false)
const wallTransparent = ref(true) const wallTransparent = ref(true)
const exteriorDoorsOpen = ref(false) const exteriorDoorsOpen = ref(false)
const aisleDoorsOpen = ref(false)
const powerCabinetDoorsOpen = ref(false)
const cabinetDoorsOpen = ref(false) const cabinetDoorsOpen = ref(false)
const serversVisible = ref(true) const serversVisible = ref(true)
const insideAisle = ref(false) 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 selectedCameraIndex = ref<number>()
const activeRack = ref<ThreeMachineRoomRack | null>(null) const activeRack = ref<ThreeMachineRoomRack | null>(null)
const rackPlacementVisible = ref(false) const rackPlacementVisible = ref(false)
const rackLayoutVisible = ref(false) const rackLayoutVisible = ref(false)
@@ -199,6 +239,7 @@ const sceneSummary = reactive<SceneSummary>({
rackCount: 0, rackCount: 0,
mappedRackCount: 0, mappedRackCount: 0,
equipmentCount: 0, equipmentCount: 0,
importedCameraCount: 0,
}) })
const signalSummary = reactive<SignalSummary>({ const signalSummary = reactive<SignalSummary>({
@@ -220,6 +261,7 @@ 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 activeRackDevices = computed(() => (Array.isArray(activeRack.value?.devices) ? activeRack.value.devices : []))
const sceneCameraDevices = computed(() => getSceneCameraDevices())
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 模型加载中'
@@ -395,10 +437,38 @@ function isCameraDevice(device: ThreeMachineRoomDevice): boolean {
return ['camera', 'cctv', '摄像', '监控'].some((keyword) => searchableText.includes(keyword)) || categoryCode === 'ipc' 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 { function handleDeviceSelected(device: ThreeMachineRoomDevice): void {
selectedDevice.value = device selectedDevice.value = device
if (isCameraDevice(device)) { if (isCameraDevice(device)) {
selectedCameraIndex.value = undefined
cameraPreviewVisible.value = true cameraPreviewVisible.value = true
return return
} }
@@ -410,6 +480,59 @@ function handleDeviceSelected(device: ThreeMachineRoomDevice): void {
deviceManagementVisible.value = true 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 { function handleOpenDeviceManagement(): void {
cameraPreviewVisible.value = false cameraPreviewVisible.value = false
@@ -459,10 +582,20 @@ async function initializeMachineRoom(): Promise<void> {
assetBasePath, assetBasePath,
onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary), onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary),
onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device), onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device),
onAlertCalloutClick: (context: DeviceAlertCalloutContext) => handleAlertCalloutClick(context),
onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack), onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack),
onCabinetDoorChanged: (rack: ThreeMachineRoomRack | null) => { onCabinetDoorChanged: (rack: ThreeMachineRoomRack | null) => {
activeRack.value = rack 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) => { onModelLoading: (loading: boolean) => {
modelLoading.value = loading modelLoading.value = loading
}, },
@@ -507,6 +640,16 @@ function toggleExteriorDoors(): void {
if (machineRoomMap) exteriorDoorsOpen.value = machineRoomMap.toggleExteriorDoors() 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 { function toggleCabinetDoors(): void {
if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors() if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors()