fix(优化)
This commit is contained in:
370
src/api/ops/three-machine-room.ts
Normal file
370
src/api/ops/three-machine-room.ts
Normal 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)
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
282
src/services/hikvisionWebSdk.ts
Normal file
282
src/services/hikvisionWebSdk.ts
Normal 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
3
src/types/env.d.ts
vendored
@@ -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
55
src/types/hikvision-web-sdk.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
761
src/views/ops/pages/datacenter/three-machine-room/index.vue
Normal file
761
src/views/ops/pages/datacenter/three-machine-room/index.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: [],
|
||||
}
|
||||
2915
src/views/ops/pages/datacenter/three-machine-room/scene/ThreeMap.js
Normal file
2915
src/views/ops/pages/datacenter/three-machine-room/scene/ThreeMap.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user