fix(优化)

This commit is contained in:
2026-08-24 22:02:55 +08:00
parent 695755546f
commit 73247053dd
71 changed files with 2970812 additions and 71 deletions

View File

@@ -0,0 +1,370 @@
import { request } from '@/api/request'
/** 3D 机房接口标准响应。 */
interface ThreeMachineRoomResponse<T> {
code: number
message?: string
details: T
}
/** 告警级别。 */
export interface ThreeMachineRoomSeverity {
id?: number
name?: string
code?: string
color?: string
priority?: number
}
/** 三维坐标和旋转。 */
export interface ThreeMachineRoomTransform {
position_x: number
position_y: number
position_z: number
rotation_x?: number
rotation_y?: number
rotation_z?: number
}
/** 独立设备三维尺寸。 */
export interface ThreeMachineRoomSize {
width: number
height: number
depth: number
}
/** 设备绑定的监控资源。 */
export interface ThreeMachineRoomBinding {
resource_uid: string
resource_category?: string
service_identity?: string
display_name?: string
business_system_id?: number | null
}
/** 监控指标。 */
export interface ThreeMachineRoomMetric {
name: string
value?: string | number | null
unit?: string
type?: string
timestamp?: string
}
/** 设备资源运行数据。 */
export interface ThreeMachineRoomResource extends ThreeMachineRoomBinding {
status?: string
metrics_at?: string
metrics?: ThreeMachineRoomMetric[]
}
/** 活动告警。 */
export interface ThreeMachineRoomAlert {
id?: number
alert_name?: string
summary?: string
severity?: ThreeMachineRoomSeverity
status?: string
starts_at?: string
last_seen_at?: string
updated_at?: string
}
/** 按监控资源分组的活动告警。 */
export interface ThreeMachineRoomAlertGroup {
resource_uid: string
active_count?: number
highest_severity?: ThreeMachineRoomSeverity
alerts?: ThreeMachineRoomAlert[]
}
/** 机柜 U 位。 */
export interface ThreeMachineRoomUnit {
id?: number
unit_number: number
status: 'available' | 'occupied' | 'reserved' | 'disabled'
asset_id?: number | null
}
/** 机柜中的资产设备。 */
export interface ThreeMachineRoomDevice {
asset_id: number
asset_code?: string
asset_name?: string
category_id?: number | null
category_code?: string
category_name?: string
placement_type?: 'rack' | 'room' | 'unplaced'
rack_id?: number | null
unit_start?: number | null
unit_end?: number | null
occupied_units?: number
power_consumption?: number
transform?: ThreeMachineRoomTransform
size?: ThreeMachineRoomSize
bindings?: ThreeMachineRoomBinding[]
}
/** 3D 场景机柜。 */
export interface ThreeMachineRoomRack {
id: number
code?: string
name?: string
row?: number
column?: number
height?: number
width_mm?: number
depth_mm?: number
status?: string
transform?: ThreeMachineRoomTransform
utilization_rate?: number
units?: ThreeMachineRoomUnit[]
devices?: ThreeMachineRoomDevice[]
}
/** 3D 机房场景数据。 */
export interface ThreeMachineRoomScene {
room: {
id: number
datacenter_id?: number
floor_id?: number
name?: string
code?: string
scene_length?: number
scene_width?: number
scene_height?: number
layout_version?: number
[key: string]: unknown
}
racks: ThreeMachineRoomRack[]
room_devices?: ThreeMachineRoomDevice[]
summary?: {
rack_count?: number
unit_count?: number
rack_device_count?: number
room_device_count?: number
device_count?: number
}
[key: string]: unknown
}
/** 设备状态信号。 */
export interface ThreeMachineRoomSignal {
asset_id: number
status?: 'normal' | 'warning' | 'abnormal' | string
active_alert_count?: number
highest_alert_severity?: ThreeMachineRoomSeverity
resources?: ThreeMachineRoomResource[]
alerts?: ThreeMachineRoomAlertGroup[]
[key: string]: unknown
}
/** 机房状态信号集合。 */
export interface ThreeMachineRoomSignals {
signals: ThreeMachineRoomSignal[]
[key: string]: unknown
}
/** 设备可观测数据。 */
export interface ThreeMachineRoomObservability {
device?: ThreeMachineRoomDevice
resources?: ThreeMachineRoomResource[]
alerts?: ThreeMachineRoomAlertGroup[]
[key: string]: unknown
}
/** 3D 场景全量导出数据。 */
export interface ThreeMachineRoomExport {
datacenters: Array<{
id: number
name?: string
code?: string
status?: string
latitude?: string
longitude?: string
rooms?: ThreeMachineRoomScene[]
}>
summary?: {
datacenter_count?: number
room_count?: number
rack_count?: number
unit_count?: number
device_count?: number
}
}
/** 机柜布局保存项。 */
export interface ThreeMachineRoomRackLayout {
rack_id: number
row?: number
column?: number
position_x: number
position_y: number
position_z: number
rotation_y: number
}
/** 机房 3D 初始化配置。 */
export interface ThreeMachineRoomConfigData {
expectedVersion: number
sceneLength: number
sceneWidth: number
sceneHeight: number
racks: ThreeMachineRoomRackLayout[]
}
/** 独立设备放置参数。 */
export interface RoomDevicePlacement {
expectedVersion: number
positionX: number
positionY: number
positionZ: number
rotationX: number
rotationY: number
rotationZ: number
sceneWidth: number
sceneHeight: number
sceneDepth: number
}
/** 设备机柜上架参数。 */
export interface RackDevicePlacement {
expectedVersion: number
rackId: number
startUnit: number
occupiedUnits: number
powerConsumption?: number
}
/** 校验响应并返回 3D 机房业务数据。 */
function getResponseDetails<T>(response: ThreeMachineRoomResponse<T>): T {
if (!response || typeof response !== 'object') {
throw new Error('接口返回格式错误')
}
if (response.code !== 0) {
throw new Error(response.message || '接口调用失败')
}
return response.details
}
/** 获取指定机房的 3D 场景。 */
export async function fetchThreeMachineRoomScene(roomId: number): Promise<ThreeMachineRoomScene> {
const response = await request.get<ThreeMachineRoomResponse<ThreeMachineRoomScene>>(
`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/scene`
)
return getResponseDetails(response)
}
/** 获取权限范围内的全部 3D 场景。 */
export async function exportThreeMachineRoomScenes(): Promise<ThreeMachineRoomExport> {
const response = await request.get<ThreeMachineRoomResponse<ThreeMachineRoomExport>>('/Assets/v1/three-d/export')
return getResponseDetails(response)
}
/** 保存机房尺寸和初始机柜布局。 */
export async function saveThreeMachineRoomConfig(
roomId: number,
config: ThreeMachineRoomConfigData
): Promise<{ room_id: number; layout_version: number }> {
const response = await request.put<ThreeMachineRoomResponse<{ room_id: number; layout_version: number }>>(
`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/config`,
{
expected_version: config.expectedVersion,
scene_length: config.sceneLength,
scene_width: config.sceneWidth,
scene_height: config.sceneHeight,
racks: config.racks,
}
)
return getResponseDetails(response)
}
/** 批量保存机柜布局。 */
export async function saveThreeMachineRoomRackLayout(
roomId: number,
expectedVersion: number,
racks: ThreeMachineRoomRackLayout[]
): Promise<{ room_id: number; layout_version: number }> {
const response = await request.put<ThreeMachineRoomResponse<{ room_id: number; layout_version: number }>>(
`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/layout`,
{
expected_version: expectedVersion,
racks,
}
)
return getResponseDetails(response)
}
/** 获取指定机房的设备状态与告警。 */
export async function fetchThreeMachineRoomSignals(roomId: number): Promise<ThreeMachineRoomSignals> {
const response = await request.get<ThreeMachineRoomResponse<ThreeMachineRoomSignals>>(
`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/signals`
)
return getResponseDetails(response)
}
/** 获取设备可观测指标与告警详情。 */
export async function fetchThreeMachineRoomDeviceObservability(assetId: number): Promise<ThreeMachineRoomObservability> {
const response = await request.get<ThreeMachineRoomResponse<ThreeMachineRoomObservability>>(
`/Assets/v1/three-d/devices/${encodeURIComponent(assetId)}/observability`
)
return getResponseDetails(response)
}
/** 保存设备机柜及 U 位。 */
export async function saveThreeMachineRoomRackPlacement(
roomId: number,
assetId: number,
placement: RackDevicePlacement
): Promise<{ layout_version: number }> {
const response = await request.put<ThreeMachineRoomResponse<{ layout_version: number }>>(
`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/devices/${encodeURIComponent(assetId)}/placement`,
{
expected_version: placement.expectedVersion,
placement_type: 'rack',
rack_id: placement.rackId,
start_unit: placement.startUnit,
occupied_units: placement.occupiedUnits,
power_consumption: placement.powerConsumption || 0,
}
)
return getResponseDetails(response)
}
/** 将设备放置在机房场景中。 */
export async function saveThreeMachineRoomDevicePlacement(
roomId: number,
assetId: number,
placement: RoomDevicePlacement
): Promise<{ room_id: number; asset_id: number; placement_type: 'room'; layout_version: number }> {
const response = await request.put<
ThreeMachineRoomResponse<{ room_id: number; asset_id: number; placement_type: 'room'; layout_version: number }>
>(`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/devices/${encodeURIComponent(assetId)}/placement`, {
expected_version: placement.expectedVersion,
placement_type: 'room',
position_x: placement.positionX,
position_y: placement.positionY,
position_z: placement.positionZ,
rotation_x: placement.rotationX,
rotation_y: placement.rotationY,
rotation_z: placement.rotationZ,
scene_width: placement.sceneWidth,
scene_height: placement.sceneHeight,
scene_depth: placement.sceneDepth,
})
return getResponseDetails(response)
}
/** 解除设备在机柜或机房中的位置。 */
export async function removeThreeMachineRoomDevicePlacement(
roomId: number,
assetId: number,
expectedVersion: number
): Promise<{ room_id: number; asset_id: number; placement_type: 'unplaced'; layout_version: number }> {
const response = await request.delete<
ThreeMachineRoomResponse<{ room_id: number; asset_id: number; placement_type: 'unplaced'; layout_version: number }>
>(`/Assets/v1/three-d/rooms/${encodeURIComponent(roomId)}/devices/${encodeURIComponent(assetId)}/placement`, {
data: { expected_version: expectedVersion },
})
return getResponseDetails(response)
}

View File

@@ -100,6 +100,20 @@ const OPS: AppRouteRecordRaw = {
roles: ['*'],
},
},
{
path: 'datacenter/three-machine-room/:room_id?',
alias: ['/datacenter/three-machine-room/:room_id?'],
name: 'ThreeMachineRoom',
component: () => import('@/views/ops/pages/datacenter/three-machine-room/index.vue'),
meta: {
locale: '3D 机房',
requiresAuth: true,
roles: ['*'],
hideInMenu: true,
ignoreCache: true,
is_full: true,
},
},
{
path: 'governance',
alias: ['/governance'],

View File

@@ -0,0 +1,282 @@
/** 海康摄像头实时预览参数。 */
export interface HikvisionPreviewConfig {
host: string
protocol: 1 | 2
httpPort: number
rtspPort: number
webSocketPort?: number
username: string
password: string
channelId: number
streamType: number
proxyEnabled: boolean
secretKey?: string
}
/** 海康播放器运行事件。 */
export interface HikvisionPlayerCallbacks {
onError?: (message: string) => void
onPerformanceLack?: () => void
}
const SDK_SCRIPT_PATHS = [
'jsPlugin/jquery.min.js',
'encryption/AES.js',
'encryption/cryptico.min.js',
'encryption/crypto-3.1.2.min.js',
'webVideoCtrl.js',
]
const PLAYER_ERROR_MESSAGES: Record<number, string> = {
1001: '码流传输异常',
1003: '取流失败,连接被动断开',
1006: '视频编码格式不支持,仅支持 H.264/H.265',
1007: '网络异常导致 WebSocket 断开',
1008: '首帧等待超时',
1011: '视频数据接收异常,请检查设备编码配置',
1012: '浏览器播放资源不足',
1015: '播放地址获取失败',
1017: '设备认证失败',
}
let scriptsPromise: Promise<void> | null = null
let initializationPromise: Promise<void> | null = null
let initializedContainerId = ''
let activeDeviceIdentify = ''
let runtimeCallbacks: HikvisionPlayerCallbacks = {}
let previewOperation = 0
/** 取得 WebSDK 静态资源的部署地址。 */
function getSdkAssetUrl(relativePath: string): string {
const applicationDirectory = new URL('.', window.location.href)
return new URL(`vendor/hikvision/${relativePath}`, applicationDirectory).toString()
}
/** 动态加载一个 SDK 脚本。 */
function loadScript(relativePath: string): Promise<void> {
const source = getSdkAssetUrl(relativePath)
const existing = Array.from(document.scripts).find((script) => script.src === source)
if (existing?.dataset.loaded === 'true') return Promise.resolve()
return new Promise((resolve, reject) => {
const script = existing || document.createElement('script')
const handleLoad = () => {
script.dataset.loaded = 'true'
resolve()
}
const handleError = () => reject(new Error(`海康 WebSDK 资源加载失败:${relativePath}`))
script.addEventListener('load', handleLoad, { once: true })
script.addEventListener('error', handleError, { once: true })
if (!existing) {
script.src = source
script.async = false
script.dataset.hikvisionSdk = 'true'
if (relativePath === 'webVideoCtrl.js') script.id = 'videonode'
document.head.appendChild(script)
}
})
}
/** 按官方 Demo 顺序加载 WebSDK 依赖。 */
async function loadSdkScripts(): Promise<void> {
if (window.WebVideoCtrl) return
if (!scriptsPromise) {
scriptsPromise = SDK_SCRIPT_PATHS.reduce((promise, path) => promise.then(() => loadScript(path)), Promise.resolve()).catch((error) => {
scriptsPromise = null
throw error
})
}
await scriptsPromise
if (!window.WebVideoCtrl) throw new Error('海康 WebSDK 未正确挂载到页面')
}
/** 获取已加载的海康控制器。 */
function getController(): HikvisionWebVideoCtrl {
if (!window.WebVideoCtrl) throw new Error('海康 WebSDK 尚未初始化')
return window.WebVideoCtrl
}
/** 将 SDK 状态码转换为可读错误。 */
function createSdkError(action: string, status?: number): Error {
if (status === 401) return new Error(`${action}失败:用户名或密码错误`)
if (status === 403) return new Error(`${action}失败:设备不支持 WebSocket 取流或当前账号无权限`)
return new Error(status ? `${action}失败(状态码 ${status}` : `${action}失败`)
}
/** 初始化单窗口无插件播放器。 */
export async function initializeHikvisionPlayer(containerId: string, callbacks: HikvisionPlayerCallbacks = {}): Promise<void> {
runtimeCallbacks = callbacks
if (initializedContainerId === containerId) return
if (initializationPromise) return initializationPromise
initializationPromise = (async () => {
await loadSdkScripts()
const controller = getController()
if (!controller.I_SupportNoPlugin()) throw new Error('当前浏览器不支持海康无插件播放器,请升级 Chrome、Edge 或 Firefox')
if (!document.getElementById(containerId)) throw new Error('摄像头播放器容器尚未创建')
await new Promise<void>((resolve, reject) => {
let settled = false
const timeout = window.setTimeout(() => {
if (!settled) reject(new Error('海康播放器初始化超时'))
}, 20000)
const finish = (error?: Error) => {
if (settled) return
settled = true
window.clearTimeout(timeout)
error ? reject(error) : resolve()
}
controller.I_InitPlugin('100%', '100%', {
bWndFull: true,
iPackageType: 2,
iWndowType: 1,
bNoPlugin: true,
cbInitPluginComplete: () => {
const result = controller.I_InsertOBJECTPlugin(containerId)
finish(result === 0 ? undefined : new Error('海康播放器挂载失败'))
},
cbPluginErrorHandler: (_windowIndex, errorCode) => {
runtimeCallbacks.onError?.(PLAYER_ERROR_MESSAGES[errorCode] || `播放器异常(错误码 ${errorCode}`)
},
cbPerformanceLack: () => runtimeCallbacks.onPerformanceLack?.(),
cbSecretKeyError: () => runtimeCallbacks.onError?.('码流加密密钥错误'),
})
})
initializedContainerId = containerId
})().catch((error) => {
initializationPromise = null
throw error
})
return initializationPromise
}
/** 停止当前窗口中的实时预览。 */
async function stopWindow(): Promise<void> {
const controller = getController()
if (!controller.I_GetWindowStatus(0)) return
await new Promise<void>((resolve) => {
const timeout = window.setTimeout(resolve, 3000)
controller.I_Stop({
iIndex: 0,
success: () => {
window.clearTimeout(timeout)
resolve()
},
error: () => {
window.clearTimeout(timeout)
resolve()
},
})
})
}
/** 停止预览并注销当前设备。 */
export async function stopHikvisionPreview(): Promise<void> {
previewOperation += 1
if (!window.WebVideoCtrl || !initializedContainerId) return
await stopWindow()
if (activeDeviceIdentify) {
getController().I_Logout(activeDeviceIdentify)
activeDeviceIdentify = ''
}
}
/** 登录摄像头并开始实时预览。 */
export async function startHikvisionPreview(config: HikvisionPreviewConfig): Promise<void> {
const controller = getController()
await stopHikvisionPreview()
const operation = ++previewOperation
const deviceIdentify = `${config.host}_${config.httpPort}`
await new Promise<void>((resolve, reject) => {
let settled = false
const timeout = window.setTimeout(() => {
settled = true
reject(new Error('摄像头登录超时'))
}, 20000)
const finish = (error?: Error) => {
if (settled) return
settled = true
window.clearTimeout(timeout)
error ? reject(error) : resolve()
}
const result = controller.I_Login(config.host, config.protocol, String(config.httpPort), config.username, config.password, {
success: () => {
if (settled || operation !== previewOperation) {
controller.I_Logout(deviceIdentify)
if (settled) return
finish(new Error('摄像头播放已取消'))
return
}
finish()
},
error: (status) => finish(createSdkError('摄像头登录', status)),
})
if (result === -1) finish()
})
if (operation !== previewOperation) {
controller.I_Logout(deviceIdentify)
throw new Error('摄像头播放已取消')
}
activeDeviceIdentify = deviceIdentify
try {
await new Promise<void>((resolve, reject) => {
let settled = false
const timeout = window.setTimeout(() => {
settled = true
reject(new Error('摄像头取流超时'))
}, 20000)
const finish = (error?: Error) => {
if (settled) return
settled = true
window.clearTimeout(timeout)
error ? reject(error) : resolve()
}
controller.I_StartRealPlay(deviceIdentify, {
iWndIndex: 0,
iRtspPort: config.rtspPort,
iWSPort: config.webSocketPort,
iStreamType: config.streamType,
iChannelID: config.channelId,
bZeroChannel: false,
bProxy: config.proxyEnabled,
success: () => {
if (settled || operation !== previewOperation) {
void stopWindow().then(() => controller.I_Logout(deviceIdentify))
if (settled) return
finish(new Error('摄像头播放已取消'))
return
}
finish()
},
error: (status) => finish(createSdkError('摄像头取流', status)),
})
})
if (operation !== previewOperation) throw new Error('摄像头播放已取消')
if (config.secretKey) await controller.I_SetSecretKey(config.secretKey, 0)
} catch (error) {
await stopHikvisionPreview()
throw error
}
}
/** 调整播放器画布尺寸。 */
export function resizeHikvisionPlayer(width: number, height: number): void {
if (!window.WebVideoCtrl || !initializedContainerId || width <= 0 || height <= 0) return
getController().I_Resize(Math.floor(width), Math.floor(height))
}
/** 销毁播放器 Worker仅在离开 3D 页面时调用。 */
export async function destroyHikvisionPlayer(): Promise<void> {
if (!window.WebVideoCtrl || !initializedContainerId) return
await stopHikvisionPreview()
getController().I_DestroyWorker()
initializedContainerId = ''
initializationPromise = null
runtimeCallbacks = {}
}

3
src/types/env.d.ts vendored
View File

@@ -2,6 +2,9 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_API_PROXY_TARGET?: string
readonly VITE_HIKVISION_PROXY_TARGET?: string
readonly VITE_HIKVISION_WS_PROXY_TARGET?: string
readonly VITE_LOGS_API_BASE_URL?: string
// 在这里可以继续补充其他 VITE_ 前缀的环境变量
}

55
src/types/hikvision-web-sdk.d.ts vendored Normal file
View File

@@ -0,0 +1,55 @@
/** 海康 WebSDK 通用异步回调。 */
interface HikvisionSdkCallbacks {
success?: (xmlDoc?: Document) => void
error?: (status?: number, xmlDoc?: Document) => void
}
/** 海康 WebSDK 初始化参数。 */
interface HikvisionPluginOptions {
bWndFull: boolean
iPackageType: number
iWndowType: number
bNoPlugin: boolean
cbInitPluginComplete: () => void
cbPluginErrorHandler: (windowIndex: number, errorCode: number, error?: unknown) => void
cbPerformanceLack: () => void
cbSecretKeyError: (windowIndex: number) => void
}
/** 海康实时预览参数。 */
interface HikvisionRealPlayOptions extends HikvisionSdkCallbacks {
iWndIndex: number
iRtspPort: number
iStreamType: number
iChannelID: number
iWSPort?: number
bZeroChannel: boolean
bProxy: boolean
}
/** 海康 WebSDK 暴露到 window 的控制器。 */
interface HikvisionWebVideoCtrl {
I_SupportNoPlugin: () => boolean
I_InitPlugin: (width: string, height: string, options: HikvisionPluginOptions) => void
I_InsertOBJECTPlugin: (containerId: string) => number
I_Login: (
host: string,
protocol: number,
port: string,
username: string,
password: string,
callbacks: HikvisionSdkCallbacks
) => number | void
I_Logout: (deviceIdentify: string) => number
I_StartRealPlay: (deviceIdentify: string, options: HikvisionRealPlayOptions) => void
I_GetWindowStatus: (windowIndex: number) => { szDeviceIdentify?: string } | null
I_Stop: (options: HikvisionSdkCallbacks & { iIndex?: number }) => void
I_StopAll: () => Promise<unknown> | void
I_Resize: (width: number, height: number) => void
I_DestroyWorker: () => void
I_SetSecretKey: (secretKey: string, windowIndex: number) => Promise<unknown>
}
interface Window {
WebVideoCtrl?: HikvisionWebVideoCtrl
}

View File

@@ -47,7 +47,7 @@
<a-button type="text" size="small" @click="handleDetail(record)">详情</a-button>
<a-button type="text" size="small" @click="handleRacks(record)">机柜</a-button>
<a-button type="text" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="text" size="small" @click="handleThreeDRoom(record)">3D机房</a-button>
<a-button type="text" size="small" @click="handleOpenThreeMachineRoom(record)">3D机房</a-button>
<a-button type="text" size="small" status="danger" @click="handleDelete(record)">删除</a-button>
</template>
</search-table>
@@ -90,7 +90,6 @@ const statusMap: Record<string, { text: string; color: string }> = {
maintenance: { text: '维护中', color: 'gold' },
offline: { text: '已下线', color: 'red' },
}
const loading = ref(false)
const tableData = ref<any[]>([])
const formModel = ref({
@@ -295,8 +294,12 @@ const handleRacks = (record: any) => {
})
}
const handleThreeDRoom = (record: any) => {
router.push(`/datacenter/room-3d/${record.id}`)
/** 打开当前机房的 3D 数字孪生场景。 */
const handleOpenThreeMachineRoom = (record: any) => {
router.push({
name: 'ThreeMachineRoom',
params: { room_id: record.id },
})
}
const handleDelete = async (record: any) => {

View File

@@ -0,0 +1,635 @@
<template>
<teleport to="body">
<aside
v-show="props.visible"
ref="floatingLayer"
class="camera-preview-floating"
:class="{ 'is-dragging': dragging }"
:style="floatingLayerStyle"
role="region"
:aria-label="floatingTitle"
>
<header class="camera-preview-floating__header" @pointerdown="startDragging">
<span class="camera-preview-floating__drag-mark" aria-hidden="true"></span>
<span class="camera-preview-floating__title" :title="floatingTitle">{{ floatingTitle }}</span>
<div class="camera-preview-floating__actions" @pointerdown.stop>
<button
type="button"
class="camera-preview-floating__action"
:disabled="initializing || starting"
title="开始播放"
aria-label="开始播放"
@click.stop="startPreview"
>
{{ starting ? '…' : '▶' }}
</button>
<button
type="button"
class="camera-preview-floating__action"
:disabled="!playing"
title="停止播放"
aria-label="停止播放"
@click.stop="stopPreview"
>
</button>
<button
type="button"
class="camera-preview-floating__action"
title="设备管理"
aria-label="设备管理"
@click.stop="openDeviceManagement"
>
</button>
<button
type="button"
class="camera-preview-floating__action"
title="关闭视频浮层"
aria-label="关闭视频浮层"
@click.stop="closePreviewLayer"
>
×
</button>
</div>
</header>
<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">
<a-spin :size="22" />
<span>播放器初始化中</span>
</div>
<div
v-else-if="!playing"
class="camera-preview-floating__message"
:class="{ 'is-error': Boolean(errorMessage) }"
:title="errorMessage || developmentProxyTip"
>
{{ errorMessage || developmentProxyTip || '点击顶部播放按钮连接摄像头' }}
</div>
<div v-else class="camera-preview-floating__live">
<i></i>
实时
</div>
</div>
</aside>
</teleport>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchAssetDetail } from '@/api/ops/asset'
import type { ThreeMachineRoomDevice } from '@/api/ops/three-machine-room'
import {
destroyHikvisionPlayer,
initializeHikvisionPlayer,
resizeHikvisionPlayer,
startHikvisionPreview,
stopHikvisionPreview,
type HikvisionPreviewConfig,
} from '@/services/hikvisionWebSdk'
/** 摄像头预览浮层属性。 */
interface Props {
visible: boolean
device: ThreeMachineRoomDevice | null
}
/** 摄像头预览浮层事件。 */
interface Emits {
(event: 'update:visible', value: boolean): void
(event: 'manage'): void
}
/** 摄像头连接配置。 */
interface CameraConnectionForm {
host: string
protocol: 1 | 2
httpPort: number
rtspPort: number
webSocketPort?: number
username: string
password: string
channelId: number
streamType: number
proxyEnabled: boolean
secretKey?: string
}
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
const FLOATING_LAYER_MARGIN = 16
const FLOATING_LAYER_DEFAULT_TOP = 88
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const floatingLayer = ref<HTMLElement>()
const playerWrapper = ref<HTMLElement>()
const initializing = ref(false)
const starting = ref(false)
const playing = ref(false)
const dragging = ref(false)
const errorMessage = ref('')
const connection = reactive<CameraConnectionForm>(createDefaultConnection())
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)),
})
const developmentProxyConfigured = Boolean(import.meta.env.VITE_HIKVISION_PROXY_TARGET)
let resizeObserver: ResizeObserver | null = null
let openSequence = 0
let playbackSequence = 0
let dragPointerId: number | null = null
let dragOffsetX = 0
let dragOffsetY = 0
const floatingTitle = computed(() => props.device?.asset_name || props.device?.asset_code || '摄像头预览')
const floatingLayerStyle = computed(() => ({
transform: `translate3d(${floatingLayerPosition.x}px, ${floatingLayerPosition.y}px, 0)`,
}))
const developmentProxyTip = computed(() => {
if (!import.meta.env.DEV || !connection.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
}
/** 从若干配置对象中读取第一个有效字段。 */
function readConfigValue(records: Record<string, unknown>[], keys: string[]): unknown {
for (const record of records) {
for (const key of keys) {
const value = record[key]
if (value !== undefined && value !== null && value !== '') return value
}
}
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 {
if (!import.meta.env.DEV || !connection.proxyEnabled || !developmentProxyConfigured) return true
try {
const httpTarget = new URL(import.meta.env.VITE_HIKVISION_PROXY_TARGET!)
const httpPort = Number(httpTarget.port) || (httpTarget.protocol === 'https:' ? 443 : 80)
const httpMatches = httpTarget.hostname.toLowerCase() === connection.host.toLowerCase() && httpPort === connection.httpPort
if (!httpMatches || !import.meta.env.VITE_HIKVISION_WS_PROXY_TARGET) return httpMatches
const webSocketTarget = new URL(import.meta.env.VITE_HIKVISION_WS_PROXY_TARGET)
return webSocketTarget.hostname.toLowerCase() === connection.host.toLowerCase()
} catch (_error) {
return false
}
}
/** 解析资产 source_address 中的地址、通道与码流。 */
function parseSourceAddress(sourceAddress: unknown): Record<string, unknown> {
if (typeof sourceAddress !== 'string' || !sourceAddress.trim()) return {}
const source = sourceAddress.trim()
if (source.startsWith('{')) {
try {
return asRecord(JSON.parse(source)) || {}
} catch (_error) {
return { host: source }
}
}
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 {
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
}
/** 获取资产详情中的摄像头连接配置。 */
async function loadAssetCameraConfig(): Promise<void> {
if (!props.device?.asset_id) return
const response = (await fetchAssetDetail(props.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)
}
/** 加载资产配置;失败时保留前端预置参数,不阻断摄像头播放。 */
async function loadOptionalAssetCameraConfig(): Promise<void> {
try {
await loadAssetCameraConfig()
} catch (error) {
console.warn('摄像头资产配置获取失败,使用前端预置参数:', error)
Message.warning('摄像头资产配置获取失败,已使用前端预置参数')
}
}
/** 将视频浮层限制在浏览器可视区域内。 */
function clampFloatingLayerPosition(): void {
const maxX = Math.max(0, window.innerWidth - FLOATING_LAYER_WIDTH)
const maxY = Math.max(0, window.innerHeight - FLOATING_LAYER_HEIGHT)
floatingLayerPosition.x = Math.min(Math.max(0, floatingLayerPosition.x), maxX)
floatingLayerPosition.y = Math.min(Math.max(0, floatingLayerPosition.y), maxY)
}
/** 拖动摄像头浮层。 */
function handleDrag(event: PointerEvent): void {
if (!dragging.value || event.pointerId !== dragPointerId) return
floatingLayerPosition.x = event.clientX - dragOffsetX
floatingLayerPosition.y = event.clientY - dragOffsetY
clampFloatingLayerPosition()
}
/** 结束摄像头浮层拖动。 */
function stopDragging(): void {
if (!dragging.value) return
dragging.value = false
dragPointerId = null
window.removeEventListener('pointermove', handleDrag)
window.removeEventListener('pointerup', stopDragging)
window.removeEventListener('pointercancel', stopDragging)
}
/** 从标题栏开始拖动摄像头浮层。 */
function startDragging(event: PointerEvent): void {
if (event.button !== 0 || !floatingLayer.value) return
const rect = floatingLayer.value.getBoundingClientRect()
dragging.value = true
dragPointerId = event.pointerId
dragOffsetX = event.clientX - rect.left
dragOffsetY = event.clientY - rect.top
window.addEventListener('pointermove', handleDrag)
window.addEventListener('pointerup', stopDragging)
window.addEventListener('pointercancel', stopDragging)
event.preventDefault()
}
/** 浏览器尺寸变化时保持浮层可见,并同步播放器大小。 */
function handleViewportResize(): void {
clampFloatingLayerPosition()
resizePlayer()
}
/** 打开浮层并准备连接参数,不在页面加载阶段连接摄像头。 */
async function openPreview(): Promise<void> {
const sequence = ++openSequence
Object.assign(connection, createDefaultConnection())
errorMessage.value = ''
playing.value = false
initializing.value = false
await loadOptionalAssetCameraConfig()
if (sequence !== openSequence || !props.visible) return
await nextTick()
clampFloatingLayerPosition()
resizePlayer()
}
/** 校验参数并开始实时预览。 */
async function startPreview(): Promise<void> {
const sequence = ++playbackSequence
errorMessage.value = ''
if (!connection.host || !connection.username || !connection.password) {
errorMessage.value = '请填写摄像头地址、用户名和密码'
return
}
if (import.meta.env.DEV && connection.proxyEnabled && !developmentProxyConfigured) {
errorMessage.value = '开发环境同源代理尚未配置,请先填写海康代理目标,或切换为直连'
return
}
if (!matchesDevelopmentProxyTarget()) {
errorMessage.value = '当前摄像头地址与固定开发代理目标不一致,请修改代理配置后重启开发服务器'
return
}
starting.value = true
try {
initializing.value = true
await nextTick()
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
await initializeHikvisionPlayer(PLAYER_CONTAINER_ID, {
onError: (message) => {
errorMessage.value = message
playing.value = false
},
onPerformanceLack: () => Message.warning('当前浏览器性能不足,建议关闭其他视频窗口或使用子码流'),
})
if (sequence !== playbackSequence || !props.visible) return
initializing.value = false
observePlayerSize()
const config: HikvisionPreviewConfig = { ...connection }
await startHikvisionPreview(config)
if (sequence !== playbackSequence || !props.visible) {
await stopHikvisionPreview()
return
}
playing.value = true
resizePlayer()
} catch (error) {
if (sequence !== playbackSequence || !props.visible) return
playing.value = false
errorMessage.value = error instanceof Error ? error.message : '摄像头播放失败'
} finally {
initializing.value = false
starting.value = false
}
}
/** 停止当前摄像头预览。 */
async function stopPreview(): Promise<void> {
playbackSequence += 1
await stopHikvisionPreview()
playing.value = false
}
/** 根据浮层中的容器大小同步 SDK 画布。 */
function resizePlayer(): void {
const rect = playerWrapper.value?.getBoundingClientRect()
if (rect) resizeHikvisionPlayer(rect.width, rect.height)
}
/** 监听播放器容器尺寸变化。 */
function observePlayerSize(): void {
resizeObserver?.disconnect()
if (!playerWrapper.value) return
resizeObserver = new ResizeObserver(() => resizePlayer())
resizeObserver.observe(playerWrapper.value)
resizePlayer()
}
/** 从视频浮层切换到设备管理。 */
function openDeviceManagement(): void {
closePreviewLayer()
emit('manage')
}
/** 关闭视频浮层。 */
function closePreviewLayer(): void {
emit('update:visible', false)
}
/** 浮层关闭后清理播放会话和敏感字段。 */
function handleClosed(): void {
openSequence += 1
playbackSequence += 1
resizeObserver?.disconnect()
resizeObserver = null
void stopPreview()
connection.password = ''
connection.secretKey = undefined
errorMessage.value = ''
}
watch(
() => props.visible,
(visible) => {
if (visible) void openPreview()
else handleClosed()
},
{ immediate: true }
)
onMounted(() => {
window.addEventListener('resize', handleViewportResize)
clampFloatingLayerPosition()
})
onBeforeUnmount(() => {
openSequence += 1
playbackSequence += 1
stopDragging()
resizeObserver?.disconnect()
window.removeEventListener('resize', handleViewportResize)
void destroyHikvisionPlayer()
connection.password = ''
connection.secretKey = undefined
})
</script>
<style scoped lang="less">
.camera-preview-floating {
position: fixed;
z-index: 2001;
top: 0;
left: 0;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 200px;
height: 200px;
overflow: hidden;
border: 1px solid rgba(86, 151, 211, 0.72);
border-radius: 8px;
background: #030811;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.48);
user-select: none;
will-change: transform;
&__header {
display: flex;
flex: 0 0 32px;
align-items: center;
gap: 4px;
padding: 0 4px 0 7px;
color: rgba(255, 255, 255, 0.9);
background: linear-gradient(90deg, #123f68, #0a223a);
cursor: grab;
touch-action: none;
}
&.is-dragging &__header {
cursor: grabbing;
}
&__drag-mark {
color: rgba(255, 255, 255, 0.52);
font-size: 14px;
}
&__title {
min-width: 0;
flex: 1;
overflow: hidden;
font-size: 12px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
flex: none;
align-items: center;
gap: 1px;
}
&__action {
display: inline-flex;
width: 22px;
height: 22px;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 4px;
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
line-height: 1;
background: transparent;
cursor: pointer;
&:hover:not(:disabled) {
color: #fff;
background: rgba(255, 255, 255, 0.16);
}
&:disabled {
color: rgba(255, 255, 255, 0.28);
cursor: not-allowed;
}
}
&__player {
position: relative;
min-height: 0;
flex: 1;
overflow: hidden;
background: #030811;
}
&__message {
position: absolute;
z-index: 2;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px;
color: rgba(255, 255, 255, 0.78);
font-size: 12px;
line-height: 1.5;
text-align: center;
background: radial-gradient(circle, rgba(19, 62, 99, 0.36), rgba(3, 8, 17, 0.82));
pointer-events: none;
&.is-error {
color: #ffb3b3;
}
}
&__live {
position: absolute;
z-index: 2;
top: 6px;
right: 7px;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 5px;
border-radius: 8px;
color: rgba(255, 255, 255, 0.9);
font-size: 10px;
background: rgba(0, 0, 0, 0.46);
pointer-events: none;
i {
width: 5px;
height: 5px;
border-radius: 50%;
background: #38d996;
box-shadow: 0 0 6px #38d996;
}
}
}
.camera-preview__canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,382 @@
<template>
<a-modal v-model:visible="dialogVisible" :title="deviceDisplayName" :width="820" :footer="false" :mask-closable="false">
<a-spin :loading="detailsLoading" style="width: 100%">
<a-descriptions :column="3" bordered size="small">
<a-descriptions-item label="资产编码">{{ device?.asset_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="设备分类">{{ device?.category_name || device?.category_code || '-' }}</a-descriptions-item>
<a-descriptions-item label="放置类型">{{ placementTypeText }}</a-descriptions-item>
<a-descriptions-item v-if="device?.placement_type === 'rack'" label="机柜 ID">{{ device.rack_id || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="device?.placement_type === 'rack'" label="U 位">{{ unitRange }}</a-descriptions-item>
<a-descriptions-item label="功耗">{{ device?.power_consumption || 0 }} W</a-descriptions-item>
</a-descriptions>
<section class="management-section">
<div class="section-header">
<strong>实时状态与告警</strong>
<a-button size="mini" :loading="observabilityLoading" @click="loadObservability">刷新</a-button>
</div>
<a-alert v-if="observabilityError" type="warning" show-icon>{{ observabilityError }}</a-alert>
<div v-else class="observability-summary">
<a-statistic title="监控资源" :value="observabilityResources.length" />
<a-statistic title="告警分组" :value="observabilityAlerts.length" />
<span v-if="!observabilityResources.length" class="empty-tip">未绑定监控资源或暂无运行数据</span>
</div>
<div v-if="observabilityResources.length" class="runtime-list">
<div v-for="resource in observabilityResources" :key="resource.resource_uid" class="runtime-item">
<div class="runtime-item__header">
<strong>{{ resource.display_name || resource.resource_uid }}</strong>
<a-tag :color="getRuntimeStatusColor(resource.status)">{{ resource.status || 'unknown' }}</a-tag>
</div>
<div v-if="resource.metrics?.length" class="metric-list">
<span v-for="metric in resource.metrics" :key="`${resource.resource_uid}-${metric.name}`">
{{ metric.name }}{{ formatMetricValue(metric.value, metric.unit) }}
</span>
</div>
<span v-else class="empty-tip">暂无指标数据</span>
</div>
</div>
</section>
<section class="management-section">
<div class="section-header">
<strong>监控资源绑定</strong>
<a-button size="mini" :loading="bindingsLoading" @click="loadBindings">刷新</a-button>
</div>
<div class="binding-form">
<a-select v-model="selectedResourceUid" allow-search placeholder="请选择可绑定监控资源" :loading="resourceOptionsLoading">
<a-option v-for="option in resourceOptions" :key="option.value" :value="option.value">{{ option.label }}</a-option>
</a-select>
<a-input v-model="bindingDisplayName" placeholder="展示名称(可选)" />
<a-button type="primary" :loading="bindingSaving" :disabled="!selectedResourceUid" @click="bindResource">绑定</a-button>
</div>
<div v-if="bindings.length" class="binding-list">
<div v-for="binding in bindings" :key="binding.resource_uid" class="binding-item">
<div>
<strong>{{ binding.display_name || binding.resource_uid }}</strong>
<span>{{ binding.resource_uid }}</span>
</div>
<a-button size="mini" status="danger" @click="unbindResource(binding)">解除绑定</a-button>
</div>
</div>
<a-empty v-else description="暂未绑定监控资源" />
</section>
<div class="dialog-footer">
<a-button @click="dialogVisible = false">关闭</a-button>
<a-button status="danger" @click="confirmRemovePlacement">解除设备位置</a-button>
</div>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { fetchAssetResourceBindings, linkAssetResource, unlinkAssetResource } from '@/api/ops/asset'
import { fetchControlResourceOptions, type OptionItem } from '@/api/ops/dcControl'
import {
fetchThreeMachineRoomDeviceObservability,
removeThreeMachineRoomDevicePlacement,
type ThreeMachineRoomDevice,
type ThreeMachineRoomObservability,
} from '@/api/ops/three-machine-room'
/** 资产监控资源绑定。 */
interface AssetResourceBinding {
resource_uid: string
display_name?: string
resource_category?: string
service_identity?: string
}
/** 设备管理弹窗属性。 */
interface Props {
visible: boolean
roomId: number
device: ThreeMachineRoomDevice | null
layoutVersion: number
}
/** 设备管理弹窗事件。 */
interface Emits {
(event: 'update:visible', value: boolean): void
(event: 'placement-removed'): void
(event: 'observability-loaded', assetId: number, details: ThreeMachineRoomObservability): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const detailsLoading = ref(false)
const observabilityLoading = ref(false)
const bindingsLoading = ref(false)
const resourceOptionsLoading = ref(false)
const bindingSaving = ref(false)
const observability = ref<ThreeMachineRoomObservability | null>(null)
const observabilityError = ref('')
const bindings = ref<AssetResourceBinding[]>([])
const resourceOptions = ref<OptionItem[]>([])
const selectedResourceUid = ref<string>()
const bindingDisplayName = ref('')
const deviceDisplayName = computed(() => props.device?.asset_name || props.device?.asset_code || `设备 ${props.device?.asset_id || ''}`)
const placementTypeText = computed(() => (props.device?.placement_type === 'room' ? '机房独立设备' : '机柜设备'))
const unitRange = computed(() => {
const start = Number(props.device?.unit_start) || 0
const end = Number(props.device?.unit_end) || start + (Number(props.device?.occupied_units) || 1) - 1
return start > 0 ? `U${start}${end > start ? ` - U${end}` : ''}` : '-'
})
const observabilityResources = computed(() => (Array.isArray(observability.value?.resources) ? observability.value.resources : []))
const observabilityAlerts = computed(() => (Array.isArray(observability.value?.alerts) ? observability.value.alerts : []))
/** 获取资源运行状态标签颜色。 */
function getRuntimeStatusColor(status?: string): string {
if (['online', 'up', 'healthy', 'normal', 'running', 'success'].includes(status || '')) return 'green'
if (['warning', 'degraded'].includes(status || '')) return 'orange'
if (['offline', 'down', 'error', 'critical', 'unhealthy', 'failed'].includes(status || '')) return 'red'
return 'gray'
}
/** 格式化监控指标值。 */
function formatMetricValue(value?: string | number | null, unit?: string): string {
return value === null || value === undefined ? '--' : `${value}${unit || ''}`
}
/** 获取设备可观测详情。 */
async function loadObservability(): Promise<void> {
if (!props.device?.asset_id) return
observabilityLoading.value = true
observabilityError.value = ''
try {
const details = await fetchThreeMachineRoomDeviceObservability(props.device.asset_id)
observability.value = details
emit('observability-loaded', props.device.asset_id, details)
} catch (error) {
observability.value = null
observabilityError.value = error instanceof Error ? error.message : '设备实时信息获取失败'
} finally {
observabilityLoading.value = false
}
}
/** 获取资产当前的资源绑定。 */
async function loadBindings(): Promise<void> {
if (!props.device?.asset_id) return
bindingsLoading.value = true
try {
const response: any = await fetchAssetResourceBindings(props.device.asset_id)
if (response.code !== 0) throw new Error(response.message || '资源绑定获取失败')
bindings.value = Array.isArray(response.details) ? response.details : []
} catch (error) {
bindings.value = []
Message.error(error instanceof Error ? error.message : '资源绑定获取失败')
} finally {
bindingsLoading.value = false
}
}
/** 获取可绑定监控资源选项。 */
async function loadResourceOptions(): Promise<void> {
resourceOptionsLoading.value = true
try {
const response = await fetchControlResourceOptions(
props.device?.placement_type === 'room' ? { resource_category: 'room_device' } : undefined
)
if (response.code !== 0) throw new Error(response.message || '监控资源获取失败')
resourceOptions.value = response.details?.list || []
} catch (error) {
resourceOptions.value = []
Message.error(error instanceof Error ? error.message : '监控资源获取失败')
} finally {
resourceOptionsLoading.value = false
}
}
/** 绑定所选监控资源。 */
async function bindResource(): Promise<void> {
if (!props.device?.asset_id || !selectedResourceUid.value) return
bindingSaving.value = true
try {
const response: any = await linkAssetResource({
asset_id: props.device.asset_id,
resource_uid: selectedResourceUid.value,
display_name: bindingDisplayName.value.trim() || undefined,
})
if (response.code !== 0) throw new Error(response.message || '资源绑定失败')
selectedResourceUid.value = undefined
bindingDisplayName.value = ''
Message.success('监控资源绑定成功')
await Promise.all([loadBindings(), loadObservability()])
} catch (error) {
Message.error(error instanceof Error ? error.message : '资源绑定失败')
} finally {
bindingSaving.value = false
}
}
/** 解除指定监控资源绑定。 */
async function unbindResource(binding: AssetResourceBinding): Promise<void> {
if (!props.device?.asset_id) return
try {
const response: any = await unlinkAssetResource(props.device.asset_id, binding.resource_uid)
if (response.code !== 0) throw new Error(response.message || '解除绑定失败')
Message.success('监控资源已解除')
await Promise.all([loadBindings(), loadObservability()])
} catch (error) {
Message.error(error instanceof Error ? error.message : '解除绑定失败')
}
}
/** 确认并解除设备当前位置。 */
function confirmRemovePlacement(): void {
if (!props.device?.asset_id) return
Modal.confirm({
title: '确认解除设备位置',
content: `解除后将释放 ${placementTypeText.value === '机柜设备' ? '占用的 U 位和机柜功耗' : '机房空间位置'}`,
onBeforeOk: async () => {
try {
await removeThreeMachineRoomDevicePlacement(props.roomId, props.device!.asset_id, props.layoutVersion)
Message.success('设备位置已解除')
dialogVisible.value = false
emit('placement-removed')
return true
} catch (error) {
Message.error(error instanceof Error ? error.message : '设备位置解除失败')
return false
}
},
})
}
/** 打开弹窗时加载设备详情和资源数据。 */
async function loadDialogData(): Promise<void> {
detailsLoading.value = true
try {
await Promise.all([loadObservability(), loadBindings(), loadResourceOptions()])
} finally {
detailsLoading.value = false
}
}
watch(
() => props.visible,
(visible) => {
if (visible) {
void loadDialogData()
return
}
observability.value = null
observabilityError.value = ''
bindings.value = []
resourceOptions.value = []
selectedResourceUid.value = undefined
bindingDisplayName.value = ''
}
)
</script>
<style scoped lang="less">
.management-section {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--color-border-2);
}
.section-header,
.dialog-footer,
.binding-item,
.binding-form,
.observability-summary {
display: flex;
align-items: center;
}
.section-header {
justify-content: space-between;
margin-bottom: 12px;
}
.observability-summary {
gap: 36px;
}
.runtime-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 12px;
}
.runtime-item {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.metric-list {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 8px;
color: var(--color-text-2);
font-size: 12px;
}
.empty-tip,
.binding-item span {
color: var(--color-text-3);
font-size: 12px;
}
.binding-form {
gap: 10px;
> :first-child {
flex: 2;
}
> :nth-child(2) {
flex: 1;
}
}
.binding-list {
margin-top: 12px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
}
.binding-item {
justify-content: space-between;
gap: 12px;
padding: 9px 12px;
border-bottom: 1px solid var(--color-border-1);
&:last-child {
border-bottom: 0;
}
> div {
display: flex;
flex-direction: column;
gap: 3px;
}
}
.dialog-footer {
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
</style>

View File

@@ -0,0 +1,117 @@
<template>
<a-modal
v-model:visible="dialogVisible"
:title="`${rackDisplayName} · 调整布局`"
:width="620"
:mask-closable="false"
:on-before-ok="handleBeforeOk"
>
<a-alert type="info" show-icon>保存时后端会同时校验机柜边界机柜重叠及独立设备重叠</a-alert>
<a-form :model="formData" layout="vertical" class="rack-layout-form">
<a-grid :cols="2" :col-gap="16">
<a-grid-item>
<a-form-item label="业务行号"><a-input-number v-model="formData.row" :min="0" :precision="0" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="业务列号"><a-input-number v-model="formData.column" :min="0" :precision="0" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="X 坐标(米)" required><a-input-number v-model="formData.positionX" :min="0" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="Y 坐标(米)" required><a-input-number v-model="formData.positionY" :min="0" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="Z 坐标(米)" required><a-input-number v-model="formData.positionZ" :min="0" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="绕 Y 轴旋转(度)" required>
<a-input-number v-model="formData.rotationY" :min="0" :max="359.99" :precision="2" />
</a-form-item>
</a-grid-item>
</a-grid>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { saveThreeMachineRoomRackLayout, type ThreeMachineRoomRack } from '@/api/ops/three-machine-room'
/** 机柜布局弹窗属性。 */
interface Props {
visible: boolean
roomId: number
rack: ThreeMachineRoomRack | null
layoutVersion: number
}
/** 机柜布局弹窗事件。 */
interface Emits {
(event: 'update:visible', value: boolean): void
(event: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const rackDisplayName = computed(() => props.rack?.name || props.rack?.code || `机柜 ${props.rack?.id || ''}`)
const formData = reactive({ row: 0, column: 0, positionX: 0, positionY: 0, positionZ: 0, rotationY: 0 })
/** 使用当前机柜数据重置布局表单。 */
function resetForm(): void {
formData.row = Number(props.rack?.row) || 0
formData.column = Number(props.rack?.column) || 0
formData.positionX = Number(props.rack?.transform?.position_x) || 0
formData.positionY = Number(props.rack?.transform?.position_y) || 0
formData.positionZ = Number(props.rack?.transform?.position_z) || 0
formData.rotationY = Number(props.rack?.transform?.rotation_y) || 0
}
/** 校验并保存当前机柜布局。 */
async function handleBeforeOk(): Promise<boolean> {
if (!props.rack?.id || props.layoutVersion <= 0) {
Message.error('机柜或布局版本无效,请刷新场景后重试')
return false
}
try {
await saveThreeMachineRoomRackLayout(props.roomId, props.layoutVersion, [
{
rack_id: props.rack.id,
row: formData.row,
column: formData.column,
position_x: formData.positionX,
position_y: formData.positionY,
position_z: formData.positionZ,
rotation_y: formData.rotationY,
},
])
emit('success')
return true
} catch (error) {
Message.error(error instanceof Error ? error.message : '机柜布局保存失败')
return false
}
}
watch(
() => props.visible,
(visible) => {
if (visible) resetForm()
}
)
</script>
<style scoped lang="less">
.rack-layout-form {
margin-top: 18px;
:deep(.arco-input-number) {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,461 @@
<template>
<a-modal
v-model:visible="dialogVisible"
:title="`${rackDisplayName} · 设备上架`"
:width="880"
:mask-closable="false"
:on-before-ok="handleBeforeOk"
@cancel="handleCancel"
>
<div class="dialog-actions">
<a-button size="small" @click="handleEditLayout">调整机柜坐标</a-button>
</div>
<a-spin :loading="unitsLoading" style="width: 100%">
<div class="placement-layout">
<section class="unit-panel">
<div class="panel-header">
<div>
<strong>选择起始 U </strong>
<span>U 位从下向上递增</span>
</div>
<a-space size="small">
<a-tag color="green">可用 {{ unitSummary.available }}</a-tag>
<a-tag color="blue">占用 {{ unitSummary.occupied }}</a-tag>
<a-tag color="orange">预留 {{ unitSummary.reserved }}</a-tag>
<a-tag color="gray">禁用 {{ unitSummary.disabled }}</a-tag>
</a-space>
</div>
<div v-if="sortedUnits.length" class="unit-grid">
<button
v-for="unit in sortedUnits"
:key="unit.unit_number"
type="button"
class="unit-tile"
:class="[unit.status, { selected: selectedUnitNumbers.has(unit.unit_number) }]"
:disabled="unit.status !== 'available'"
:title="getUnitTitle(unit)"
@click="selectStartUnit(unit)"
>
<strong>U{{ unit.unit_number }}</strong>
<span>{{ getUnitStatusText(unit) }}</span>
</button>
</div>
<a-empty v-else description="该机柜暂无 U 位数据" />
</section>
<a-form ref="formRef" class="placement-form" :model="formData" :rules="rules" layout="vertical">
<a-form-item field="assetId" label="选择设备" required>
<a-select
v-model="formData.assetId"
allow-search
:filter-option="false"
:loading="assetsLoading"
placeholder="请选择未放置设备"
@search="handleAssetSearch"
@change="handleAssetChange"
>
<a-option v-for="asset in assetList" :key="asset.id" :value="asset.id">
{{ asset.assetName }}{{ asset.assetCode }}
</a-option>
</a-select>
</a-form-item>
<a-form-item field="startUnit" label="起始 U 位" required>
<a-input-number v-model="formData.startUnit" :min="1" :max="rackHeight" placeholder="请从左侧选择" style="width: 100%" />
</a-form-item>
<a-form-item field="occupiedUnits" label="占用 U 位数" required>
<a-input-number
v-model="formData.occupiedUnits"
:min="1"
:max="maxOccupiedUnits"
placeholder="请输入连续占用数量"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="目标区间">
<a-alert :type="rangeAvailable ? 'success' : 'error'">
U{{ formData.startUnit }} - U{{ selectedEndUnit }}
{{ rangeAvailable ? ',区间可用' : ',包含不可用 U 位' }}
</a-alert>
</a-form-item>
<a-form-item field="powerConsumption" label="功耗W">
<a-input-number v-model="formData.powerConsumption" :min="0" placeholder="请输入功耗" style="width: 100%" />
</a-form-item>
</a-form>
</div>
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchAssetAll } from '@/api/ops/asset'
import { saveThreeMachineRoomRackPlacement, type ThreeMachineRoomRack } from '@/api/ops/three-machine-room'
import { fetchRackUnits, type RackUnitItem, type RackUnitStatus } from '@/api/ops/unit'
/** 待上架资产。 */
interface PlacementAsset {
id: number
assetCode: string
assetName: string
occupiedUnits: number
powerConsumption: number
}
/** 设备上架弹窗属性。 */
interface Props {
visible: boolean
roomId: number
rack: ThreeMachineRoomRack | null
layoutVersion: number
}
/** 设备上架弹窗事件。 */
interface Emits {
(event: 'update:visible', value: boolean): void
(event: 'success'): void
(event: 'edit-layout'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const formRef = ref()
const unitsLoading = ref(false)
const assetsLoading = ref(false)
const units = ref<RackUnitItem[]>([])
const assetList = ref<PlacementAsset[]>([])
let assetSearchTimer: number | undefined
const formData = reactive({
assetId: undefined as number | undefined,
startUnit: 1,
occupiedUnits: 1,
powerConsumption: 0,
})
const rules = {
assetId: [{ required: true, message: '请选择设备' }],
startUnit: [{ required: true, message: '请选择起始 U 位' }],
occupiedUnits: [{ required: true, message: '请输入占用 U 位数' }],
}
const rackHeight = computed(() => Math.max(1, Number(props.rack?.height) || 42))
const rackDisplayName = computed(() => props.rack?.name || props.rack?.code || `机柜 ${props.rack?.id || ''}`)
const sortedUnits = computed(() => [...units.value].sort((left, right) => right.unit_number - left.unit_number))
const unitMap = computed(() => new Map(units.value.map((unit) => [unit.unit_number, unit])))
const maxOccupiedUnits = computed(() => Math.max(1, rackHeight.value - formData.startUnit + 1))
const selectedEndUnit = computed(() => formData.startUnit + formData.occupiedUnits - 1)
const selectedUnitNumbers = computed(() => {
const result = new Set<number>()
for (let unitNumber = formData.startUnit; unitNumber <= selectedEndUnit.value; unitNumber += 1) {
result.add(unitNumber)
}
return result
})
const rangeAvailable = computed(() => {
if (!units.value.length || selectedEndUnit.value > rackHeight.value) return false
return [...selectedUnitNumbers.value].every((unitNumber) => unitMap.value.get(unitNumber)?.status === 'available')
})
const unitSummary = computed(() =>
units.value.reduce(
(summary, unit) => {
summary[unit.status] += 1
return summary
},
{ available: 0, occupied: 0, reserved: 0, disabled: 0 } as Record<RackUnitStatus, number>
)
)
/** 获取 U 位状态文案。 */
function getUnitStatusText(unit: RackUnitItem): string {
const statusText: Record<RackUnitStatus, string> = {
available: '可用',
occupied: '占用',
reserved: '预留',
disabled: '禁用',
}
return statusText[unit.status]
}
/** 获取 U 位悬浮提示。 */
function getUnitTitle(unit: RackUnitItem): string {
if (unit.status === 'occupied') return `U${unit.unit_number} · ${unit.asset_name || unit.asset_code}`
if (unit.status === 'reserved') return `U${unit.unit_number} · ${unit.reserved_for}`
return `U${unit.unit_number} · ${getUnitStatusText(unit)}`
}
/** 选择设备并回填默认参数。 */
function handleAssetChange(value: unknown): void {
const asset = assetList.value.find((item) => String(item.id) === String(value))
if (!asset) return
formData.assetId = asset.id
formData.occupiedUnits = Math.min(Math.max(1, asset.occupiedUnits), maxOccupiedUnits.value)
formData.powerConsumption = asset.powerConsumption
}
/** 选择连续 U 位的起点。 */
function selectStartUnit(unit: RackUnitItem): void {
if (unit.status !== 'available') return
formData.startUnit = unit.unit_number
formData.occupiedUnits = Math.min(formData.occupiedUnits, maxOccupiedUnits.value)
}
/** 获取机柜最新 U 位状态。 */
async function loadRackUnits(): Promise<void> {
if (!props.rack?.id) return
unitsLoading.value = true
try {
const response = await fetchRackUnits(props.rack.id)
if (response.code !== 0) throw new Error(response.message || 'U 位获取失败')
units.value = response.details?.units || []
const firstAvailable = units.value
.filter((unit) => unit.status === 'available')
.sort((left, right) => left.unit_number - right.unit_number)[0]
formData.startUnit = firstAvailable?.unit_number || 1
} catch (error) {
units.value = []
Message.error(error instanceof Error ? error.message : 'U 位获取失败')
} finally {
unitsLoading.value = false
}
}
/** 获取未放置资产列表。 */
async function loadAssetList(keyword?: string): Promise<void> {
assetsLoading.value = true
try {
const response: any = await fetchAssetAll({ keyword: keyword || undefined })
if (response.code !== 0) throw new Error(response.message || '设备列表获取失败')
assetList.value = (Array.isArray(response.details) ? response.details : [])
.filter((item: any) => item.placement_type === 'unplaced')
.map((item: any) => ({
id: item.id,
assetCode: item.asset_code || String(item.id),
assetName: item.asset_name || item.name || `设备 ${item.id}`,
occupiedUnits: Number(item.occupied_units) || 1,
powerConsumption: Number(item.power_consumption) || 0,
}))
} catch (error) {
assetList.value = []
Message.error(error instanceof Error ? error.message : '设备列表获取失败')
} finally {
assetsLoading.value = false
}
}
/** 延迟搜索待上架设备。 */
function handleAssetSearch(keyword: string): void {
if (assetSearchTimer !== undefined) window.clearTimeout(assetSearchTimer)
assetSearchTimer = window.setTimeout(() => {
void loadAssetList(keyword.trim() || undefined)
}, 300)
}
/** 校验并保存设备机柜及 U 位。 */
async function handleBeforeOk(): Promise<boolean> {
try {
await formRef.value?.validate()
} catch {
return false
}
if (!props.rack?.id || !formData.assetId) {
Message.error('机柜或设备信息不完整')
return false
}
if (props.layoutVersion <= 0) {
Message.error('机房布局版本无效,请刷新场景后重试')
return false
}
if (!rangeAvailable.value) {
Message.error('请选择连续且可用的 U 位')
return false
}
try {
await saveThreeMachineRoomRackPlacement(props.roomId, formData.assetId, {
expectedVersion: props.layoutVersion,
rackId: props.rack.id,
startUnit: formData.startUnit,
occupiedUnits: formData.occupiedUnits,
powerConsumption: formData.powerConsumption,
})
emit('success')
return true
} catch (error) {
Message.error(error instanceof Error ? error.message : '设备上架失败')
return false
}
}
/** 重置弹窗数据。 */
function resetDialog(): void {
units.value = []
assetList.value = []
formData.assetId = undefined
formData.startUnit = 1
formData.occupiedUnits = 1
formData.powerConsumption = 0
formRef.value?.resetFields()
}
/** 取消设备上架。 */
function handleCancel(): void {
resetDialog()
}
/** 切换到机柜布局编辑弹窗。 */
function handleEditLayout(): void {
emit('update:visible', false)
emit('edit-layout')
}
watch(
() => props.visible,
(visible) => {
if (visible) {
void Promise.all([loadRackUnits(), loadAssetList()])
} else {
resetDialog()
}
}
)
watch(
() => formData.startUnit,
() => {
formData.occupiedUnits = Math.min(formData.occupiedUnits, maxOccupiedUnits.value)
}
)
onBeforeUnmount(() => {
if (assetSearchTimer !== undefined) window.clearTimeout(assetSearchTimer)
})
</script>
<style scoped lang="less">
.dialog-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.placement-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 20px;
min-height: 480px;
}
.unit-panel {
min-width: 0;
padding-right: 20px;
border-right: 1px solid var(--color-border-2);
}
.panel-header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
> div:first-child {
display: flex;
flex-direction: column;
gap: 3px;
}
span {
color: var(--color-text-3);
font-size: 12px;
}
}
.unit-grid {
display: grid;
grid-template-columns: repeat(6, minmax(56px, 1fr));
gap: 8px;
max-height: 430px;
overflow-y: auto;
padding: 3px;
}
.unit-tile {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
padding: 7px 6px;
border: 1px solid transparent;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.available {
color: #006d22;
border-color: #7be188;
background: #e8ffea;
}
&.occupied {
color: #0e42d2;
border-color: #94bfff;
background: #e8f3ff;
}
&.reserved {
color: #b54708;
border-color: #ffb65d;
background: #fff7e8;
}
&.disabled {
color: #4e5969;
border-color: #c9cdd4;
background: #f2f3f5;
}
&.selected {
outline: 3px solid rgba(114, 46, 209, 0.72);
outline-offset: 1px;
}
&:disabled {
cursor: not-allowed;
}
}
.placement-form {
min-width: 0;
}
@media (max-width: 900px) {
.placement-layout {
grid-template-columns: 1fr;
}
.unit-panel {
padding-right: 0;
padding-bottom: 16px;
border-right: 0;
border-bottom: 1px solid var(--color-border-2);
}
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<a-modal
v-model:visible="dialogVisible"
title="放置机房独立设备"
:width="720"
:mask-closable="false"
:on-before-ok="handleBeforeOk"
@cancel="resetForm"
>
<a-alert type="info" show-icon>适用于空调摄像头智能插座等不占用机柜 U 位的设备</a-alert>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical" class="device-placement-form">
<a-form-item field="assetId" label="选择设备" required>
<a-select
v-model="formData.assetId"
allow-search
:filter-option="false"
:loading="assetsLoading"
placeholder="请选择未放置设备"
@search="handleAssetSearch"
>
<a-option v-for="asset in assetList" :key="asset.id" :value="asset.id">{{ asset.name }}{{ asset.code }}</a-option>
</a-select>
</a-form-item>
<strong class="section-title">底面中心坐标</strong>
<a-grid :cols="3" :col-gap="16">
<a-grid-item>
<a-form-item label="X" required><a-input-number v-model="formData.positionX" :min="0" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="Y" required><a-input-number v-model="formData.positionY" :min="0" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="Z" required><a-input-number v-model="formData.positionZ" :min="0" :precision="2" /></a-form-item>
</a-grid-item>
</a-grid>
<strong class="section-title">旋转角度</strong>
<a-grid :cols="3" :col-gap="16">
<a-grid-item>
<a-form-item label="X"><a-input-number v-model="formData.rotationX" :min="0" :max="359.99" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="Y"><a-input-number v-model="formData.rotationY" :min="0" :max="359.99" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="Z"><a-input-number v-model="formData.rotationZ" :min="0" :max="359.99" :precision="2" /></a-form-item>
</a-grid-item>
</a-grid>
<strong class="section-title">设备尺寸</strong>
<a-grid :cols="3" :col-gap="16">
<a-grid-item>
<a-form-item label="宽度" required><a-input-number v-model="formData.sceneWidth" :min="0.01" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="高度" required><a-input-number v-model="formData.sceneHeight" :min="0.01" :precision="2" /></a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="深度" required><a-input-number v-model="formData.sceneDepth" :min="0.01" :precision="2" /></a-form-item>
</a-grid-item>
</a-grid>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { fetchAssetAll } from '@/api/ops/asset'
import { saveThreeMachineRoomDevicePlacement, type ThreeMachineRoomScene } from '@/api/ops/three-machine-room'
/** 待放置独立设备。 */
interface UnplacedAsset {
id: number
name: string
code: string
}
/** 独立设备放置弹窗属性。 */
interface Props {
visible: boolean
roomId: number
scene: ThreeMachineRoomScene | null
}
/** 独立设备放置弹窗事件。 */
interface Emits {
(event: 'update:visible', value: boolean): void
(event: 'success'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const formRef = ref()
const assetsLoading = ref(false)
const assetList = ref<UnplacedAsset[]>([])
let assetSearchTimer: number | undefined
const formData = reactive({
assetId: undefined as number | undefined,
positionX: 0,
positionY: 0,
positionZ: 0,
rotationX: 0,
rotationY: 0,
rotationZ: 0,
sceneWidth: 1,
sceneHeight: 1,
sceneDepth: 1,
})
const rules = { assetId: [{ required: true, message: '请选择设备' }] }
/** 获取未放置资产列表。 */
async function loadAssetList(keyword?: string): Promise<void> {
assetsLoading.value = true
try {
const response: any = await fetchAssetAll({ keyword: keyword || undefined })
if (response.code !== 0) throw new Error(response.message || '设备列表获取失败')
assetList.value = (Array.isArray(response.details) ? response.details : [])
.filter((item: any) => item.placement_type === 'unplaced')
.map((item: any) => ({
id: item.id,
name: item.asset_name || item.name || `设备 ${item.id}`,
code: item.asset_code || String(item.id),
}))
} catch (error) {
assetList.value = []
Message.error(error instanceof Error ? error.message : '设备列表获取失败')
} finally {
assetsLoading.value = false
}
}
/** 延迟搜索未放置设备。 */
function handleAssetSearch(keyword: string): void {
if (assetSearchTimer !== undefined) window.clearTimeout(assetSearchTimer)
assetSearchTimer = window.setTimeout(() => void loadAssetList(keyword.trim() || undefined), 300)
}
/** 使用机房尺寸重置放置参数。 */
function resetForm(): void {
formData.assetId = undefined
formData.positionX = (Number(props.scene?.room?.scene_length) || 0) / 2
formData.positionY = 0
formData.positionZ = (Number(props.scene?.room?.scene_width) || 0) / 2
formData.rotationX = 0
formData.rotationY = 0
formData.rotationZ = 0
formData.sceneWidth = 1
formData.sceneHeight = 1
formData.sceneDepth = 1
formRef.value?.resetFields()
}
/** 校验并保存独立设备位置。 */
async function handleBeforeOk(): Promise<boolean> {
try {
await formRef.value?.validate()
} catch {
return false
}
const layoutVersion = Number(props.scene?.room?.layout_version) || 0
if (!formData.assetId || layoutVersion <= 0) {
Message.error('设备或布局版本无效,请刷新场景后重试')
return false
}
if (formData.sceneWidth <= 0 || formData.sceneHeight <= 0 || formData.sceneDepth <= 0) {
Message.error('设备尺寸必须大于 0')
return false
}
try {
await saveThreeMachineRoomDevicePlacement(props.roomId, formData.assetId, {
expectedVersion: layoutVersion,
positionX: formData.positionX,
positionY: formData.positionY,
positionZ: formData.positionZ,
rotationX: formData.rotationX,
rotationY: formData.rotationY,
rotationZ: formData.rotationZ,
sceneWidth: formData.sceneWidth,
sceneHeight: formData.sceneHeight,
sceneDepth: formData.sceneDepth,
})
emit('success')
return true
} catch (error) {
Message.error(error instanceof Error ? error.message : '独立设备放置失败')
return false
}
}
watch(
() => props.visible,
(visible) => {
if (!visible) return
resetForm()
void loadAssetList()
}
)
onBeforeUnmount(() => {
if (assetSearchTimer !== undefined) window.clearTimeout(assetSearchTimer)
})
</script>
<style scoped lang="less">
.device-placement-form {
margin-top: 18px;
:deep(.arco-input-number) {
width: 100%;
}
}
.section-title {
display: block;
margin: 4px 0 10px;
}
</style>

View File

@@ -0,0 +1,217 @@
<template>
<a-modal
v-model:visible="dialogVisible"
title="3D 场景配置"
:width="960"
:mask-closable="false"
:on-before-ok="handleBeforeOk"
@cancel="resetForm"
>
<a-alert type="info" show-icon>首次配置会提交当前机房的全部机柜位置后端将统一校验边界和空间重叠</a-alert>
<a-form :model="formData" layout="vertical" class="scene-config-form">
<a-grid :cols="3" :col-gap="16">
<a-grid-item>
<a-form-item label="机房长度(米)" required>
<a-input-number v-model="formData.sceneLength" :min="0.01" :precision="2" style="width: 100%" />
</a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="机房宽度(米)" required>
<a-input-number v-model="formData.sceneWidth" :min="0.01" :precision="2" style="width: 100%" />
</a-form-item>
</a-grid-item>
<a-grid-item>
<a-form-item label="机房高度(米)" required>
<a-input-number v-model="formData.sceneHeight" :min="0.01" :precision="2" style="width: 100%" />
</a-form-item>
</a-grid-item>
</a-grid>
</a-form>
<div class="rack-layout-header">
<strong>机柜位置{{ rackLayouts.length }}</strong>
<span>坐标为机柜底面中心机柜仅支持绕 Y 轴旋转</span>
</div>
<div class="rack-layout-list">
<div v-for="rack in rackLayouts" :key="rack.rack_id" class="rack-layout-row">
<div class="rack-name">
<strong>{{ rack.name }}</strong>
<span>{{ rack.code }}</span>
</div>
<label>
<a-input-number v-model="rack.row" :min="0" :precision="0" />
</label>
<label>
<a-input-number v-model="rack.column" :min="0" :precision="0" />
</label>
<label>
X
<a-input-number v-model="rack.position_x" :min="0" :precision="2" />
</label>
<label>
Y
<a-input-number v-model="rack.position_y" :min="0" :precision="2" />
</label>
<label>
Z
<a-input-number v-model="rack.position_z" :min="0" :precision="2" />
</label>
<label>
旋转
<a-input-number v-model="rack.rotation_y" :min="0" :max="359.99" :precision="2" />
</label>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, reactive, ref, watch } from 'vue'
import { Message } from '@arco-design/web-vue'
import { saveThreeMachineRoomConfig, type ThreeMachineRoomRackLayout, type ThreeMachineRoomScene } from '@/api/ops/three-machine-room'
/** 场景配置弹窗属性。 */
interface Props {
visible: boolean
roomId: number
scene: ThreeMachineRoomScene | null
}
/** 场景配置弹窗事件。 */
interface Emits {
(event: 'update:visible', value: boolean): void
(event: 'success'): void
}
/** 带展示字段的机柜布局项。 */
interface EditableRackLayout extends ThreeMachineRoomRackLayout {
name: string
code: string
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
const formData = reactive({
sceneLength: 0,
sceneWidth: 0,
sceneHeight: 0,
})
const rackLayouts = ref<EditableRackLayout[]>([])
/** 使用当前场景重置配置表单。 */
function resetForm(): void {
const room = props.scene?.room
formData.sceneLength = Number(room?.scene_length) || 0
formData.sceneWidth = Number(room?.scene_width) || 0
formData.sceneHeight = Number(room?.scene_height) || 0
rackLayouts.value = (props.scene?.racks || []).map((rack) => ({
rack_id: rack.id,
name: rack.name || `机柜 ${rack.id}`,
code: rack.code || '',
row: Number(rack.row) || 0,
column: Number(rack.column) || 0,
position_x: Number(rack.transform?.position_x) || 0,
position_y: Number(rack.transform?.position_y) || 0,
position_z: Number(rack.transform?.position_z) || 0,
rotation_y: Number(rack.transform?.rotation_y) || 0,
}))
}
/** 校验并保存完整场景配置。 */
async function handleBeforeOk(): Promise<boolean> {
const layoutVersion = Number(props.scene?.room?.layout_version) || 0
if (layoutVersion <= 0) {
Message.error('机房布局版本无效,请刷新场景后重试')
return false
}
if (formData.sceneLength <= 0 || formData.sceneWidth <= 0 || formData.sceneHeight <= 0) {
Message.error('机房长、宽、高必须大于 0')
return false
}
try {
await saveThreeMachineRoomConfig(props.roomId, {
expectedVersion: layoutVersion,
sceneLength: formData.sceneLength,
sceneWidth: formData.sceneWidth,
sceneHeight: formData.sceneHeight,
racks: rackLayouts.value.map(({ name, code, ...rack }) => rack),
})
emit('success')
return true
} catch (error) {
Message.error(error instanceof Error ? error.message : '场景配置保存失败')
return false
}
}
watch(
() => props.visible,
(visible) => {
if (visible) resetForm()
}
)
</script>
<style scoped lang="less">
.scene-config-form {
margin-top: 18px;
}
.rack-layout-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0 12px;
span {
color: var(--color-text-3);
font-size: 12px;
}
}
.rack-layout-list {
max-height: 420px;
overflow-y: auto;
border: 1px solid var(--color-border-2);
border-radius: 4px;
}
.rack-layout-row {
display: grid;
grid-template-columns: minmax(120px, 1.5fr) repeat(6, minmax(82px, 1fr));
align-items: end;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-1);
&:last-child {
border-bottom: 0;
}
label,
.rack-name {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--color-text-3);
font-size: 12px;
}
.rack-name strong {
overflow: hidden;
color: var(--color-text-1);
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>

View File

@@ -0,0 +1,761 @@
<template>
<div
id="three-machine-room-canvas"
class="three-machine-room-page"
:style="sceneStyle"
:data-room-id="roomId"
:data-mapped-rack-count="sceneSummary.mappedRackCount"
:data-rack-device-count="sceneSummary.equipmentCount"
:data-active-alert-count="signalSummary.activeAlertCount"
>
<div class="scene-toolbar">
<a-space wrap :size="8">
<a-button size="small" @click="goBack">返回机房列表</a-button>
<a-button size="small" :loading="sceneLoading" @click="refreshScene">刷新数据</a-button>
<a-button size="small" :disabled="usingMockData || !roomScene" @click="sceneConfigVisible = true">场景配置</a-button>
<a-button size="small" :disabled="usingMockData || !roomScene" @click="roomDevicePlacementVisible = true">放置独立设备</a-button>
<a-button size="small" :loading="exportLoading" @click="exportScenes">导出场景</a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleWalls">
{{ wallTransparent ? '恢复实体外墙' : '外墙透明' }}
</a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleExteriorDoors">
{{ exteriorDoorsOpen ? '关闭外墙门' : '打开外墙门' }}
</a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleCabinetDoors">
{{ cabinetDoorsOpen ? '关闭全部机柜门' : '打开全部机柜门' }}
</a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleServers">
{{ serversVisible ? '隐藏服务器设备' : '显示服务器设备' }}
</a-button>
<a-button size="small" :disabled="!modelReady" @click="toggleAisleView">
{{ insideAisle ? '返回总览' : '进入右侧过道' }}
</a-button>
</a-space>
<div class="scene-toolbar__meta">
<span class="room-name">{{ roomName }}</span>
<span
class="signal-summary"
:class="{
'has-warning': signalSummary.warningCount > 0,
'has-abnormal': signalSummary.abnormalCount > 0,
}"
>
<i class="signal-summary__dot"></i>
活动告警 {{ signalSummary.activeAlertCount }}
</span>
<span class="operation-tip">
{{ insideAisle ? '滚轮前后移动,拖拽转向;点击设备查看实时信息' : '点击机柜上架设备;双击门板开关' }}
</span>
</div>
</div>
<div v-if="usingMockData" class="mock-data-notice">接口数据暂不可用当前展示演示机房数据</div>
<div v-if="sceneLoading || modelLoading" class="scene-loading">
<a-spin :size="36" />
<strong>{{ loadingText }}</strong>
<a-progress v-if="modelLoading && modelProgress > 0" class="scene-loading__progress" :percent="modelProgress / 100" />
</div>
<rack-placement-dialog
v-model:visible="rackPlacementVisible"
:room-id="roomId"
:rack="selectedRack"
:layout-version="layoutVersion"
@success="handleRackPlacementSuccess"
@edit-layout="handleOpenRackLayout"
/>
<rack-layout-dialog
v-model:visible="rackLayoutVisible"
:room-id="roomId"
:rack="selectedRack"
:layout-version="layoutVersion"
@success="handleRackLayoutSuccess"
/>
<scene-config-dialog v-model:visible="sceneConfigVisible" :room-id="roomId" :scene="roomScene" @success="handleSceneConfigSuccess" />
<room-device-placement-dialog
v-model:visible="roomDevicePlacementVisible"
:room-id="roomId"
:scene="roomScene"
@success="handleRoomDevicePlacementSuccess"
/>
<camera-preview-dialog v-model:visible="cameraPreviewVisible" :device="selectedDevice" @manage="handleOpenDeviceManagement" />
<device-management-dialog
v-model:visible="deviceManagementVisible"
:room-id="roomId"
:device="selectedDevice"
:layout-version="layoutVersion"
@observability-loaded="handleObservabilityLoaded"
@placement-removed="handlePlacementRemoved"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { Message } from '@arco-design/web-vue'
import { useRoute, useRouter } from 'vue-router'
import CameraPreviewDialog from './components/CameraPreviewDialog.vue'
import DeviceManagementDialog from './components/DeviceManagementDialog.vue'
import RackLayoutDialog from './components/RackLayoutDialog.vue'
import RackPlacementDialog from './components/RackPlacementDialog.vue'
import RoomDevicePlacementDialog from './components/RoomDevicePlacementDialog.vue'
import SceneConfigDialog from './components/SceneConfigDialog.vue'
import {
exportThreeMachineRoomScenes,
fetchThreeMachineRoomDeviceObservability,
fetchThreeMachineRoomScene,
fetchThreeMachineRoomSignals,
type ThreeMachineRoomDevice,
type ThreeMachineRoomObservability,
type ThreeMachineRoomRack,
type ThreeMachineRoomScene,
} from '@/api/ops/three-machine-room'
import { getMockDeviceObservability, MOCK_ROOM_SCENE, MOCK_ROOM_SIGNALS } from './scene/MockThreeMachineRoom'
import ThreeMap from './scene/ThreeMap'
import { ThreeData } from './scene/ThreeData'
/** 场景渲染摘要。 */
interface SceneSummary {
rackCount: number
mappedRackCount: number
equipmentCount: number
}
/** 设备信号摘要。 */
interface SignalSummary {
signalCount: number
activeAlertCount: number
warningCount: number
abnormalCount: number
}
const route = useRoute()
const router = useRouter()
const assetBasePath = `${import.meta.env.BASE_URL}three-machine-room/`
let machineRoomMap: any = null
let signalTimer: number | undefined
const sceneLoading = ref(false)
const modelLoading = ref(true)
const modelReady = ref(false)
const modelProgress = ref(0)
const signalsLoading = ref(false)
const usingMockData = ref(false)
const wallTransparent = ref(true)
const exteriorDoorsOpen = 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 rackPlacementVisible = ref(false)
const rackLayoutVisible = ref(false)
const sceneConfigVisible = ref(false)
const roomDevicePlacementVisible = ref(false)
const cameraPreviewVisible = ref(true)
const deviceManagementVisible = ref(false)
const exportLoading = ref(false)
const sceneSummary = reactive<SceneSummary>({
rackCount: 0,
mappedRackCount: 0,
equipmentCount: 0,
})
const signalSummary = reactive<SignalSummary>({
signalCount: 0,
activeAlertCount: 0,
warningCount: 0,
abnormalCount: 0,
})
/** 将路由参数转换为合法机房 ID。 */
function resolveRoomId(): number {
const routeValue = route.params.room_id || route.query.room_id || route.query.roomId || 1
const value = Array.isArray(routeValue) ? routeValue[0] : routeValue
const parsedId = Number(value)
return Number.isInteger(parsedId) && parsedId > 0 ? parsedId : 1
}
const roomId = resolveRoomId()
const roomName = computed(() => roomScene.value?.room?.name || `机房 ${roomId}`)
const layoutVersion = computed(() => Number(roomScene.value?.room?.layout_version) || 0)
const loadingText = computed(() => {
if (modelLoading.value) {
return modelProgress.value > 0 ? `3D 模型加载中 ${modelProgress.value}%` : '3D 模型加载中'
}
return '机房数据加载中'
})
const sceneStyle = computed(() => ({
backgroundImage: `radial-gradient(circle at center, rgba(0, 63, 112, 0.35), rgba(0, 12, 63, 0.92)), url("${assetBasePath}rack/homebg.png")`,
}))
/** 从未知异常中提取可展示文案。 */
function getErrorMessage(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback
}
/** 停止设备状态轮询。 */
function stopSignalPolling(): void {
if (signalTimer !== undefined) {
window.clearInterval(signalTimer)
signalTimer = undefined
}
}
/** 启动设备状态轮询。 */
function startSignalPolling(): void {
stopSignalPolling()
signalTimer = window.setInterval(() => {
void loadRoomSignals(true)
}, 30000)
}
/** 拉取并应用机房设备状态。 */
async function loadRoomSignals(silent = false): Promise<void> {
if (!machineRoomMap || signalsLoading.value) return
if (usingMockData.value) {
Object.assign(signalSummary, machineRoomMap.setRoomSignals(MOCK_ROOM_SIGNALS))
return
}
signalsLoading.value = true
try {
const details = await fetchThreeMachineRoomSignals(roomId)
Object.assign(signalSummary, machineRoomMap.setRoomSignals(details))
} catch (error) {
if (!silent) {
Message.warning(getErrorMessage(error, '设备状态及告警获取失败'))
} else {
console.warn('设备状态及告警获取失败:', error)
}
} finally {
signalsLoading.value = false
}
}
/** 拉取场景数据,失败时回退到源项目的演示数据。 */
async function loadRoomScene(): Promise<boolean> {
sceneLoading.value = true
try {
const scene = await fetchThreeMachineRoomScene(roomId)
if (!scene?.room) {
throw new Error('机房详情缺少 room 数据')
}
usingMockData.value = false
roomScene.value = scene
machineRoomMap?.setRoomScene(scene)
const roomConfigured = [scene.room.scene_length, scene.room.scene_width, scene.room.scene_height].every((value) => Number(value) > 0)
if (!roomConfigured) {
sceneConfigVisible.value = true
Message.info('该机房尚未完成 3D 初始化,请先配置机房尺寸和机柜位置')
}
await loadRoomSignals(true)
return true
} catch (error) {
usingMockData.value = true
roomScene.value = MOCK_ROOM_SCENE as ThreeMachineRoomScene
machineRoomMap?.setRoomScene(MOCK_ROOM_SCENE)
if (machineRoomMap) {
Object.assign(signalSummary, machineRoomMap.setRoomSignals(MOCK_ROOM_SIGNALS))
}
Message.warning(getErrorMessage(error, '接口数据暂不可用,当前展示演示机房数据'))
return false
} finally {
sceneLoading.value = false
}
}
/** 加载所选设备的实时指标。 */
async function loadDeviceObservability(device: ThreeMachineRoomDevice): Promise<void> {
const assetId = Number(device?.asset_id)
if (!Number.isInteger(assetId) || assetId <= 0 || !machineRoomMap) return
const mockDetails = usingMockData.value ? getMockDeviceObservability(assetId) : null
if (mockDetails) {
machineRoomMap.setDeviceObservability(assetId, mockDetails)
return
}
machineRoomMap.setDeviceObservabilityLoading(assetId)
try {
const details = await fetchThreeMachineRoomDeviceObservability(assetId)
machineRoomMap?.setDeviceObservability(assetId, details)
} catch (error) {
machineRoomMap?.setDeviceObservabilityError(assetId, error)
Message.warning(getErrorMessage(error, '设备实时信息获取失败'))
}
}
/** 打开所选机柜的设备上架弹窗。 */
function handleRackSelected(rack: ThreeMachineRoomRack): void {
if (usingMockData.value) {
Message.warning('演示数据不支持设备上架')
return
}
selectedRack.value = rack
rackPlacementVisible.value = true
}
/** 保存上架结果后重新加载 3D 场景。 */
async function handleRackPlacementSuccess(): Promise<void> {
rackPlacementVisible.value = false
Message.success('设备上架成功,正在重新加载 3D 场景')
await refreshScene()
selectedRack.value = null
}
/** 从设备上架弹窗切换到机柜布局弹窗。 */
function handleOpenRackLayout(): void {
rackPlacementVisible.value = false
rackLayoutVisible.value = true
}
/** 保存机柜布局后重新加载 3D 场景。 */
async function handleRackLayoutSuccess(): Promise<void> {
rackLayoutVisible.value = false
Message.success('机柜布局保存成功,正在重新加载 3D 场景')
await refreshScene()
selectedRack.value = null
}
/** 保存场景配置后重新加载 3D 场景。 */
async function handleSceneConfigSuccess(): Promise<void> {
sceneConfigVisible.value = false
Message.success('场景配置保存成功,正在重新加载 3D 场景')
await refreshScene()
}
/** 保存独立设备位置后重新加载 3D 场景。 */
async function handleRoomDevicePlacementSuccess(): Promise<void> {
roomDevicePlacementVisible.value = false
Message.success('独立设备放置成功,正在重新加载 3D 场景')
await refreshScene()
}
/** 判断 3D 设备是否为摄像头。 */
function isCameraDevice(device: ThreeMachineRoomDevice): boolean {
const categoryCode = String(device.category_code || '').toLowerCase()
const searchableText = [categoryCode, device.category_name, device.asset_name, device.asset_code].filter(Boolean).join(' ').toLowerCase()
return ['camera', 'cctv', '摄像', '监控'].some((keyword) => searchableText.includes(keyword)) || categoryCode === 'ipc'
}
/** 点击摄像头时打开预览,其他设备进入详情管理。 */
function handleDeviceSelected(device: ThreeMachineRoomDevice): void {
selectedDevice.value = device
if (isCameraDevice(device)) {
cameraPreviewVisible.value = true
return
}
if (usingMockData.value) {
void loadDeviceObservability(device)
Message.warning('演示数据不支持资源绑定和位置管理')
return
}
deviceManagementVisible.value = true
}
/** 从摄像头预览切换到设备详情管理。 */
function handleOpenDeviceManagement(): void {
cameraPreviewVisible.value = false
deviceManagementVisible.value = true
}
/** 将弹窗加载的可观测数据同步到 3D 设备标牌。 */
function handleObservabilityLoaded(assetId: number, details: ThreeMachineRoomObservability): void {
machineRoomMap?.setDeviceObservability(assetId, details)
}
/** 解除设备位置后重新加载 3D 场景。 */
async function handlePlacementRemoved(): Promise<void> {
deviceManagementVisible.value = false
await refreshScene()
selectedDevice.value = null
}
/** 导出当前权限范围内的全部 3D 场景 JSON。 */
async function exportScenes(): Promise<void> {
exportLoading.value = true
try {
const scenes = await exportThreeMachineRoomScenes()
const blob = new Blob([JSON.stringify(scenes, null, 2)], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `three-machine-room-scenes-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
Message.success('3D 场景导出成功')
} catch (error) {
Message.error(getErrorMessage(error, '3D 场景导出失败'))
} finally {
exportLoading.value = false
}
}
/** 初始化 Three.js 场景。 */
async function initializeMachineRoom(): Promise<void> {
await nextTick()
machineRoomMap = new ThreeMap(
{
domID: 'three-machine-room-canvas',
assetBasePath,
onRoomSceneApplied: (summary: SceneSummary) => Object.assign(sceneSummary, summary),
onDeviceSelected: (device: ThreeMachineRoomDevice) => handleDeviceSelected(device),
onRackSelected: (rack: ThreeMachineRoomRack) => handleRackSelected(rack),
onModelLoading: (loading: boolean) => {
modelLoading.value = loading
},
onModelProgress: (progress: number) => {
modelProgress.value = progress
},
onModelReady: () => {
modelReady.value = true
modelLoading.value = false
},
onModelError: () => {
modelReady.value = false
Message.error('3D 机房模型加载失败')
},
},
ThreeData
)
machineRoomMap.init()
const connected = await loadRoomScene()
if (connected) startSignalPolling()
}
/** 刷新机房场景与状态数据。 */
async function refreshScene(): Promise<void> {
const connected = await loadRoomScene()
if (connected) startSignalPolling()
else stopSignalPolling()
}
/** 返回机房管理页面。 */
function goBack(): void {
void router.push('/datacenter/room')
}
/** 切换外墙透明状态。 */
function toggleWalls(): void {
if (machineRoomMap) wallTransparent.value = machineRoomMap.setWallsTransparent(!wallTransparent.value)
}
/** 切换全部外墙门。 */
function toggleExteriorDoors(): void {
if (machineRoomMap) exteriorDoorsOpen.value = machineRoomMap.toggleExteriorDoors()
}
/** 切换全部机柜门。 */
function toggleCabinetDoors(): void {
if (machineRoomMap) cabinetDoorsOpen.value = machineRoomMap.toggleCabinetDoors()
}
/** 切换服务器设备可见性。 */
function toggleServers(): void {
if (machineRoomMap) serversVisible.value = machineRoomMap.setServerEquipmentVisible(!serversVisible.value)
}
/** 切换总览与过道视角。 */
function toggleAisleView(): void {
if (!machineRoomMap) return
const changed = insideAisle.value ? machineRoomMap.setOverviewView() : machineRoomMap.setAisleView()
if (changed) {
insideAisle.value = !insideAisle.value
} else {
Message.warning('模型仍在加载,请稍后再试')
}
}
onMounted(() => {
void initializeMachineRoom()
})
onBeforeUnmount(() => {
stopSignalPolling()
machineRoomMap?.destroy()
machineRoomMap = null
})
</script>
<script lang="ts">
export default {
name: 'ThreeMachineRoom',
}
</script>
<style lang="less">
.three-machine-room-page {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
background-color: #000c3f;
background-position: center;
background-repeat: no-repeat;
background-size: cover;
color: #d9f1ff;
> canvas {
display: block;
}
.scene-toolbar {
position: absolute;
z-index: 10;
top: 18px;
right: 18px;
left: 18px;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid rgba(90, 216, 255, 0.28);
border-radius: 8px;
background: rgba(4, 28, 58, 0.82);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(8px);
}
.scene-toolbar__meta {
display: flex;
align-items: center;
gap: 10px;
}
.room-name {
color: #e9f9ff;
font-weight: 600;
}
.operation-tip {
color: #b7d8e8;
font-size: 12px;
white-space: nowrap;
}
.signal-summary {
display: inline-flex;
align-items: center;
height: 26px;
padding: 0 9px;
border: 1px solid rgba(74, 211, 255, 0.35);
border-radius: 14px;
color: #a9d9ee;
background: rgba(2, 35, 65, 0.78);
font-size: 12px;
white-space: nowrap;
&__dot {
width: 7px;
height: 7px;
margin-right: 6px;
border-radius: 50%;
background: #20e6b2;
box-shadow: 0 0 8px #20e6b2;
}
&.has-warning {
border-color: rgba(255, 180, 41, 0.55);
color: #ffd27a;
.signal-summary__dot {
background: #ffb429;
box-shadow: 0 0 9px #ffb429;
}
}
&.has-abnormal {
border-color: rgba(255, 77, 103, 0.62);
color: #ff9aa9;
.signal-summary__dot {
background: #ff4d67;
box-shadow: 0 0 10px #ff4d67;
}
}
}
.mock-data-notice {
position: absolute;
z-index: 9;
right: 20px;
bottom: 20px;
padding: 8px 12px;
border: 1px solid rgba(255, 180, 41, 0.5);
border-radius: 4px;
background: rgba(92, 54, 2, 0.82);
color: #ffd27a;
font-size: 12px;
}
.scene-loading {
position: absolute;
z-index: 20;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
background: rgba(0, 10, 38, 0.72);
&__progress {
width: 280px;
}
}
}
.rack-device-callout-layer {
position: absolute;
z-index: 6;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.rack-device-callout {
--device-status-color: #62a7c8;
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
color: #d9f5ff;
pointer-events: none;
&__card {
position: absolute;
bottom: 64px;
left: 0;
width: 270px;
transform: translateX(-50%);
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--device-status-color) 62%, #1bd7ff);
border-radius: 3px;
background: linear-gradient(100deg, rgba(2, 73, 102, 0.96), rgba(4, 22, 49, 0.96) 58%, rgba(11, 30, 62, 0.96));
box-shadow:
0 8px 26px rgba(0, 7, 24, 0.55),
0 0 18px color-mix(in srgb, var(--device-status-color) 22%, transparent);
cursor: pointer;
pointer-events: auto;
}
&__header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding: 0 13px;
border-bottom: 1px solid rgba(62, 210, 255, 0.16);
background: linear-gradient(90deg, rgba(2, 104, 137, 0.68), rgba(14, 45, 79, 0.3));
}
&__title {
max-width: 155px;
overflow: hidden;
color: #def8ff;
font-size: 15px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
&__status {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
&__body {
padding: 8px 13px 9px;
}
&__row {
display: grid;
grid-template-columns: 8px minmax(76px, 1fr) auto;
align-items: center;
min-height: 25px;
column-gap: 7px;
font-size: 12px;
}
&__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: #18dcff;
box-shadow: 0 0 7px #18dcff;
}
&__label {
overflow: hidden;
color: #a9c8d8;
text-overflow: ellipsis;
white-space: nowrap;
}
&__value {
max-width: 105px;
overflow: hidden;
color: #eefaff;
font-weight: 600;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
&.is-warning {
color: var(--device-status-color);
}
}
&__leader {
position: absolute;
bottom: 0;
left: -1px;
width: 2px;
height: 64px;
background: linear-gradient(to bottom, rgba(31, 221, 255, 0.6), var(--device-status-color));
box-shadow: 0 0 7px var(--device-status-color);
}
&__marker {
position: absolute;
top: -5px;
left: -5px;
width: 10px;
height: 10px;
border: 2px solid var(--device-status-color);
border-radius: 50%;
background: rgba(5, 25, 51, 0.92);
box-shadow: 0 0 10px var(--device-status-color);
}
&.is-selected &__card {
border-color: var(--device-status-color);
box-shadow:
0 10px 30px rgba(0, 7, 24, 0.62),
0 0 24px color-mix(in srgb, var(--device-status-color) 35%, transparent);
}
&.is-below &__card {
top: 64px;
bottom: auto;
}
&.is-below &__leader {
top: 0;
bottom: auto;
}
}
</style>

View File

@@ -0,0 +1,168 @@
const CRITICAL_SEVERITY = {
id: 1,
name: '严重',
color: '#FF4D67',
}
const WARNING_SEVERITY = {
id: 2,
name: '预警',
color: '#FFB429',
}
function createRack(index) {
const numberLabel = index < 10 ? '0' + index : String(index)
return {
id: 9000 + index,
code: 'R' + numberLabel,
name: '演示机柜-' + numberLabel,
row: Math.floor((index - 1) / 6) + 1,
column: ((index - 1) % 6) + 1,
height: 42,
devices: [],
}
}
const racks = Array.from({ length: 12 }, (item, index) => createRack(index + 1))
racks[9].devices.push({
asset_id: 990001,
asset_code: 'SRV-DEMO-01',
asset_name: '应用服务器-01',
category_code: 'server',
unit_start: 12,
unit_end: 13,
occupied_units: 2,
power_consumption: 680,
})
racks[11].devices.push({
asset_id: 990002,
asset_code: 'UPS-DEMO-01',
asset_name: 'UPS 电源-01',
category_code: 'ups',
unit_start: 20,
unit_end: 23,
occupied_units: 4,
power_consumption: 1250,
})
racks[0].devices.push({
asset_id: 990003,
asset_code: 'NET-DEMO-02',
asset_name: '核心交换机-02',
category_code: 'network_switch',
unit_start: 30,
unit_end: 31,
occupied_units: 2,
power_consumption: 420,
callout_position: 'below',
})
export const MOCK_ROOM_SCENE = {
room: {
id: 1,
name: '三维机房演示数据',
layout_version: 1,
},
racks: racks,
}
export const MOCK_ROOM_SIGNALS = {
signals: [
{
asset_id: 990001,
status: 'abnormal',
active_alert_count: 1,
highest_alert_severity: CRITICAL_SEVERITY,
alerts: [
{
alerts: [
{
alert_name: '机柜温度过高',
status: 'firing',
severity: CRITICAL_SEVERITY,
},
],
},
],
},
{
asset_id: 990002,
status: 'warning',
active_alert_count: 1,
highest_alert_severity: WARNING_SEVERITY,
alerts: [
{
alerts: [
{
alert_name: 'UPS 输入电压异常',
status: 'firing',
severity: WARNING_SEVERITY,
},
],
},
],
},
{
asset_id: 990003,
status: 'warning',
active_alert_count: 1,
highest_alert_severity: WARNING_SEVERITY,
alerts: [
{
alerts: [
{
alert_name: '端口流量超过阈值',
status: 'firing',
severity: WARNING_SEVERITY,
},
],
},
],
},
],
}
const MOCK_DEVICE_OBSERVABILITY = {
990001: {
resources: [
{
metrics: [
{ name: 'temperature', value: 38.6, unit: '°C' },
{ name: 'cpu_usage', value: 86.2, unit: '%' },
{ name: 'memory_usage', value: 78.4, unit: '%' },
],
},
],
alerts: MOCK_ROOM_SIGNALS.signals[0].alerts,
},
990002: {
resources: [
{
metrics: [
{ name: 'input_voltage', value: 185, unit: 'V' },
{ name: 'output_voltage', value: 220, unit: 'V' },
{ name: 'power', value: 1250, unit: 'W' },
],
},
],
alerts: MOCK_ROOM_SIGNALS.signals[1].alerts,
},
990003: {
resources: [
{
metrics: [
{ name: 'temperature', value: 31.8, unit: '°C' },
{ name: 'power', value: 420, unit: 'W' },
{ name: 'voltage', value: 12.1, unit: 'V' },
],
},
],
alerts: MOCK_ROOM_SIGNALS.signals[2].alerts,
},
}
export function getMockDeviceObservability(assetId) {
return MOCK_DEVICE_OBSERVABILITY[String(assetId)] || null
}

View File

@@ -0,0 +1,33 @@
/** 3D 机房模型场景配置。 */
export const ThreeData = {
objects: [
{
uuid: '',
name: 'importedModel',
objType: 'objModel',
filePath: 'qd/quding.obj',
mtlPath: 'qd/quding.mtl',
textEncoding: 'gb18030',
autoScale: true,
targetSize: 1800,
centerModel: true,
alignToFloor: true,
fitCamera: true,
cameraDirection: [1.1, 1.8, 2],
cameraPadding: 1.05,
aisleView: {
xRatio: 0.25,
startZRatio: 0.68,
targetZRatio: 0.28,
eyeHeightRatio: 0.46,
fov: 58,
},
isolateScene: true,
x: 0,
y: 45,
z: 0,
},
],
events: {},
btns: [],
}

File diff suppressed because it is too large Load Diff