|
@@ -1,96 +1,117 @@
|
|
|
<template>
|
|
<template>
|
|
|
|
|
+ <!-- 主内容容器 -->
|
|
|
<div class="content">
|
|
<div class="content">
|
|
|
|
|
+ <!-- CAD图纸查看区域 -->
|
|
|
<div class="viewer-area">
|
|
<div class="viewer-area">
|
|
|
- <div :id="containerId" ref="viewerRef" class="viewer-canvas"></div>
|
|
|
|
|
|
|
+ <!-- DWG图纸渲染画布,id用于Viewer2d初始化 -->
|
|
|
|
|
+ <div :id="containerId" class="viewer-canvas"></div>
|
|
|
|
|
|
|
|
|
|
+ <!-- 坐标状态栏:显示鼠标在世界坐标系中的位置 -->
|
|
|
<div v-if="viewerReady" class="coord-bar">
|
|
<div v-if="viewerReady" class="coord-bar">
|
|
|
<span>X: {{ currentCoord.x.toFixed(2) }}</span>
|
|
<span>X: {{ currentCoord.x.toFixed(2) }}</span>
|
|
|
<span>Y: {{ currentCoord.y.toFixed(2) }}</span>
|
|
<span>Y: {{ currentCoord.y.toFixed(2) }}</span>
|
|
|
- <span v-if="placingMode" class="placing-hint">| 放置: {{ placingDevice?.name }} (Esc取消)</span>
|
|
|
|
|
|
|
+ <!-- 布点模式下显示提示信息,Esc可取消 -->
|
|
|
|
|
+ <span v-if="placingMode" class="placing-hint">| 放置: {{ placingDevice?.strname }} (Esc取消)</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ <!-- 右侧设备列表面板,支持折叠展开 -->
|
|
|
<div class="device-panel" :class="{ collapsed: panelCollapsed }">
|
|
<div class="device-panel" :class="{ collapsed: panelCollapsed }">
|
|
|
|
|
+ <!-- 面板头部:点击切换展开/收起状态 -->
|
|
|
<div class="panel-header" @click="panelCollapsed = !panelCollapsed">
|
|
<div class="panel-header" @click="panelCollapsed = !panelCollapsed">
|
|
|
<span class="panel-title">设备列表</span>
|
|
<span class="panel-title">设备列表</span>
|
|
|
<span class="panel-toggle">{{ panelCollapsed ? '展开' : '收起' }}</span>
|
|
<span class="panel-toggle">{{ panelCollapsed ? '展开' : '收起' }}</span>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
+ <!-- 面板内容:设备表格,显示名称、状态、布点操作 -->
|
|
|
<div v-show="!panelCollapsed" class="panel-body">
|
|
<div v-show="!panelCollapsed" class="panel-body">
|
|
|
<a-table :columns="columns" :data-source="devices" :pagination="false" row-key="id" size="small" :scroll="{ y: 360 }">
|
|
<a-table :columns="columns" :data-source="devices" :pagination="false" row-key="id" size="small" :scroll="{ y: 360 }">
|
|
|
<template #bodyCell="{ column, record }">
|
|
<template #bodyCell="{ column, record }">
|
|
|
- <template v-if="column.key === 'index'">{{ devices.indexOf(record) + 1 }}</template>
|
|
|
|
|
|
|
+ <!-- 状态列:根据netStatus显示在线/离线标签 -->
|
|
|
<template v-if="column.key === 'netStatus'">
|
|
<template v-if="column.key === 'netStatus'">
|
|
|
<a-tag :color="statusColor(record.netStatus)">{{ statusText(record.netStatus) }}</a-tag>
|
|
<a-tag :color="statusColor(record.netStatus)">{{ statusText(record.netStatus) }}</a-tag>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
+ <!-- 操作列:未布点显示布点按钮,已布点显示已布点和撤销按钮 -->
|
|
|
<template v-if="column.key === 'action'">
|
|
<template v-if="column.key === 'action'">
|
|
|
- <a-button type="primary" size="small" :disabled="placingMode" @click="enterPlacingMode(record)">布点</a-button>
|
|
|
|
|
- </template>
|
|
|
|
|
- <template v-if="column.key === 'markerStatus'">
|
|
|
|
|
- <a-tag v-if="record.markerId" color="green">已布</a-tag>
|
|
|
|
|
- <span v-else style="color: rgba(255, 255, 255, 0.35); font-size: 12px">-</span>
|
|
|
|
|
|
|
+ <!-- <a-button
|
|
|
|
|
+ v-if="!isDevicePlaced(record)"
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ :disabled="placingMode"
|
|
|
|
|
+ @click="enterPlacingMode(record)"
|
|
|
|
|
+ >布点</a-button> -->
|
|
|
|
|
+ <a-button
|
|
|
|
|
+ v-if="!isDevicePlaced(record)"
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ size="small"
|
|
|
|
|
+ @click="enterPlacingMode(record)"
|
|
|
|
|
+ >布点</a-button>
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <!-- <div style="display: flex; align-items: center; justify-content: center;">
|
|
|
|
|
+ <a-button size="small" disabled style="margin-right: 4px">已布点</a-button>
|
|
|
|
|
+ <a-button type="link" size="small" danger :disabled="placingMode" @click="revokePlacement(record)">撤销</a-button>
|
|
|
|
|
+ </div> -->
|
|
|
|
|
+ <a-button type="link" size="small" primary :disabled="placingMode" @click="revokePlacement(record)">撤销</a-button>
|
|
|
|
|
+ </template>
|
|
|
</template>
|
|
</template>
|
|
|
</template>
|
|
</template>
|
|
|
</a-table>
|
|
</a-table>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
-
|
|
|
|
|
- <a-modal v-model:open="modalVisible" :title="(monitoringData?.deviceName || '') + ' - 监测数据'" :footer="null" width="420px">
|
|
|
|
|
- <div v-if="monitoringData" class="monitor-data">
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">时间戳</span><span>{{ monitoringData.timestamp }}</span></div
|
|
|
|
|
- >
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">风速</span><span>{{ monitoringData.windSpeed }} m/s</span></div
|
|
|
|
|
- >
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">瓦斯浓度</span><span>{{ monitoringData.gasConcentration }}%</span></div
|
|
|
|
|
- >
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">温度</span><span>{{ monitoringData.temperature }}°C</span></div
|
|
|
|
|
- >
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">压力</span><span>{{ monitoringData.pressure }} Pa</span></div
|
|
|
|
|
- >
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">粉尘浓度</span><span>{{ monitoringData.dustDensity }} mg/m³</span></div
|
|
|
|
|
- >
|
|
|
|
|
- <div class="monitor-row"
|
|
|
|
|
- ><span class="monitor-key">运行状态</span
|
|
|
|
|
- ><a-tag :color="monitoringData.status === '正常' ? 'green' : 'red'">{{ monitoringData.status }}</a-tag></div
|
|
|
|
|
- >
|
|
|
|
|
- </div>
|
|
|
|
|
- </a-modal>
|
|
|
|
|
</div>
|
|
</div>
|
|
|
</template>
|
|
</template>
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
<script setup lang="ts">
|
|
|
- import { ref, onMounted, onUnmounted, reactive } from 'vue';
|
|
|
|
|
|
|
+ import { ref, onMounted, onUnmounted, reactive, toRef, watch } from 'vue';
|
|
|
import { Viewer2d, ViewerEvent, CoordinateUtils, THREE } from '@x-viewer/core';
|
|
import { Viewer2d, ViewerEvent, CoordinateUtils, THREE } from '@x-viewer/core';
|
|
|
- import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
|
|
|
|
|
import { useAppStoreWithOut } from '/@/store/modules/app';
|
|
import { useAppStoreWithOut } from '/@/store/modules/app';
|
|
|
- import dayjs from 'dayjs';
|
|
|
|
|
-
|
|
|
|
|
|
|
|
|
|
- let props=defineProps({
|
|
|
|
|
|
|
+ const props = defineProps({
|
|
|
devices: {
|
|
devices: {
|
|
|
type: Array,
|
|
type: Array,
|
|
|
default: () => [],
|
|
default: () => [],
|
|
|
},
|
|
},
|
|
|
- })
|
|
|
|
|
|
|
+ });
|
|
|
|
|
+ const deviceList = toRef(props, 'devices');
|
|
|
|
|
+
|
|
|
// ==================== 类型 ====================
|
|
// ==================== 类型 ====================
|
|
|
|
|
|
|
|
- interface DeviceInfo {
|
|
|
|
|
- id: string;
|
|
|
|
|
- name: string;
|
|
|
|
|
- type: string;
|
|
|
|
|
- status: 'online' | 'offline' | 'alarm';
|
|
|
|
|
- markerId: string | null;
|
|
|
|
|
|
|
+ interface DeviceRecord {
|
|
|
|
|
+ deviceID?: string;
|
|
|
|
|
+ id?: string;
|
|
|
|
|
+ strname?: string;
|
|
|
|
|
+ strinstallpos?: string;
|
|
|
|
|
+ netStatus?: number | string;
|
|
|
|
|
+ syswarnLevel?: number | string | boolean;
|
|
|
|
|
+ readData?: { temperature?: number | string };
|
|
|
|
|
+ markerId?: string | null;
|
|
|
|
|
+ [key: string]: any;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ interface MarkerInfoData {
|
|
|
|
|
+ deviceName: string;
|
|
|
|
|
+ installPos: string;
|
|
|
|
|
+ runStatus: string;
|
|
|
|
|
+ alarmStatus: string;
|
|
|
|
|
+ temperature: string;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 持久化的布点记录 */
|
|
|
|
|
+ interface PersistedMarker {
|
|
|
|
|
+ deviceId: string;
|
|
|
|
|
+ x: number;
|
|
|
|
|
+ y: number;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ==================== 常量 ====================
|
|
// ==================== 常量 ====================
|
|
|
|
|
|
|
|
const containerId = 'dwg-viewer-canvas';
|
|
const containerId = 'dwg-viewer-canvas';
|
|
|
const dwgSrc = '/dwg/2026年2月总平面布置图_无高程.dwg';
|
|
const dwgSrc = '/dwg/2026年2月总平面布置图_无高程.dwg';
|
|
|
|
|
+ /** 布点持久化存储 key(按图纸区分) */
|
|
|
|
|
+ const MARKER_STORAGE_KEY = 'dwg-viewer-markers:main-plan';
|
|
|
|
|
+ /** 正常状态布点颜色(绿色) */
|
|
|
|
|
+ const MARKER_COLOR_NORMAL = 0x52c41a;
|
|
|
|
|
+ /** 报警状态布点颜色(红色) */
|
|
|
|
|
+ const MARKER_COLOR_ALARM = 0xff4d4f;
|
|
|
const fontFiles = [
|
|
const fontFiles = [
|
|
|
'/dwg/simplex.shx',
|
|
'/dwg/simplex.shx',
|
|
|
'/dwg/hztxt.shx',
|
|
'/dwg/hztxt.shx',
|
|
@@ -102,64 +123,138 @@
|
|
|
|
|
|
|
|
// ==================== 表格 ====================
|
|
// ==================== 表格 ====================
|
|
|
|
|
|
|
|
- // 设备列表表格列
|
|
|
|
|
const columns = [
|
|
const columns = [
|
|
|
- { title: '#', key: 'index', width: 36 },
|
|
|
|
|
- { title: '设备名称', dataIndex: 'strname', ellipsis: true },
|
|
|
|
|
- { title: '类型', dataIndex: 'typeName', width: 56 },
|
|
|
|
|
- { title: '状态', key: 'netStatus', width: 54 },
|
|
|
|
|
- { title: '操作', key: 'action', width: 56 },
|
|
|
|
|
- { title: '布点', key: 'markerStatus', width: 48 },
|
|
|
|
|
|
|
+ { title: '设备名称', dataIndex: 'strname', ellipsis: true, align: 'center' },
|
|
|
|
|
+ { title: '状态', key: 'netStatus', width: 60, align: 'center' },
|
|
|
|
|
+ { title: '操作', key: 'action', width: 60, align: 'center' },
|
|
|
];
|
|
];
|
|
|
- // 设备列表数据
|
|
|
|
|
- // const devices = ref<DeviceInfo[]>([
|
|
|
|
|
- // // { id: 'd001', name: '1#瓦斯传感器', type: '传感器', status: 'online', markerId: null },
|
|
|
|
|
- // // { id: 'd002', name: '2#瓦斯传感器', type: '传感器', status: 'online', markerId: null },
|
|
|
|
|
- // // { id: 'd003', name: '主通风机', type: '风机', status: 'online', markerId: null },
|
|
|
|
|
- // // { id: 'd004', name: '局部通风机', type: '风机', status: 'offline', markerId: null },
|
|
|
|
|
- // // { id: 'd005', name: '风门A', type: '风门', status: 'online', markerId: null },
|
|
|
|
|
- // // { id: 'd006', name: '风速传感器-1', type: '传感器', status: 'alarm', markerId: null },
|
|
|
|
|
- // // { id: 'd007', name: '温度传感器-1', type: '传感器', status: 'online', markerId: null },
|
|
|
|
|
- // // { id: 'd008', name: '风窗B', type: '风窗', status: 'online', markerId: null },
|
|
|
|
|
- // ]);
|
|
|
|
|
- // 状态颜色映射
|
|
|
|
|
|
|
+
|
|
|
function statusColor(s: string) {
|
|
function statusColor(s: string) {
|
|
|
- // return { online: 'green', offline: 'gray', alarm: 'red' }[s] || 'default';
|
|
|
|
|
- return { 1: 'green', 0: 'gray', }[s] || 'default';
|
|
|
|
|
|
|
+ return { 1: 'green', 0: 'gray' }[s] || 'default';
|
|
|
}
|
|
}
|
|
|
- // 状态文本映射
|
|
|
|
|
|
|
+
|
|
|
function statusText(s: string) {
|
|
function statusText(s: string) {
|
|
|
- // return { online: '在线', offline: '离线', alarm: '报警' }[s] || s;
|
|
|
|
|
- return { 1: '在线', 0: '离线', }[s] || s;
|
|
|
|
|
|
|
+ return { 1: '在线', 0: '离线' }[s] || s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 获取设备唯一标识 */
|
|
|
|
|
+ function getDeviceId(device: DeviceRecord) {
|
|
|
|
|
+ return String(device.deviceID ?? device.id ?? '');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 已布点设备 ID 集合(保证列表按钮状态可响应更新) */
|
|
|
|
|
+ const placedDeviceIds = ref<Set<string>>(new Set());
|
|
|
|
|
+
|
|
|
|
|
+ /** 判断设备是否已完成布点 */
|
|
|
|
|
+ function isDevicePlaced(device: DeviceRecord) {
|
|
|
|
|
+ const id = getDeviceId(device);
|
|
|
|
|
+ return !!device.markerId || (id !== '' && placedDeviceIds.value.has(id));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 读取本地持久化的布点数据 */
|
|
|
|
|
+ function loadPersistedMarkers(): PersistedMarker[] {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const raw = localStorage.getItem(MARKER_STORAGE_KEY);
|
|
|
|
|
+ if (!raw) return [];
|
|
|
|
|
+ const data = JSON.parse(raw);
|
|
|
|
|
+ return Array.isArray(data)
|
|
|
|
|
+ ? data.filter((item) => item && item.deviceId != null && typeof item.x === 'number' && typeof item.y === 'number')
|
|
|
|
|
+ : [];
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ console.warn('[布点] 读取本地布点失败:', e);
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 页面加载时先恢复「已布点」按钮状态(无需等待图纸加载)
|
|
|
|
|
+ placedDeviceIds.value = new Set(loadPersistedMarkers().map((m) => String(m.deviceId)));
|
|
|
|
|
+
|
|
|
|
|
+ /** 将当前 markersMap 写入本地存储 */
|
|
|
|
|
+ function savePersistedMarkers() {
|
|
|
|
|
+ const list: PersistedMarker[] = [];
|
|
|
|
|
+ markersMap.forEach((m) => {
|
|
|
|
|
+ list.push({ deviceId: m.deviceId, x: m.worldPos.x, y: m.worldPos.y });
|
|
|
|
|
+ });
|
|
|
|
|
+ localStorage.setItem(MARKER_STORAGE_KEY, JSON.stringify(list));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 将 markersMap 中的布点状态同步到设备列表(列表刷新后仍显示「已布点」) */
|
|
|
|
|
+ function syncPlacedStateToDevices() {
|
|
|
|
|
+ const next = new Set<string>();
|
|
|
|
|
+ if (markersMap.size > 0) {
|
|
|
|
|
+ markersMap.forEach((m, markerId) => {
|
|
|
|
|
+ next.add(m.deviceId);
|
|
|
|
|
+ const dev = findDevice(m.deviceId);
|
|
|
|
|
+ if (dev) dev.markerId = markerId;
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ loadPersistedMarkers().forEach((item) => next.add(String(item.deviceId)));
|
|
|
|
|
+ }
|
|
|
|
|
+ placedDeviceIds.value = next;
|
|
|
|
|
+ updateAllMarkerColors();
|
|
|
}
|
|
}
|
|
|
- // 模拟监测数据
|
|
|
|
|
- function getMockMonitoring(deviceId: string) {
|
|
|
|
|
- const d = devices.value.find((x) => x.id === deviceId);
|
|
|
|
|
|
|
+
|
|
|
|
|
+ /** 根据 deviceId 查找设备记录 */
|
|
|
|
|
+ function findDevice(deviceId: string) {
|
|
|
|
|
+ return deviceList.value.find((item) => getDeviceId(item as DeviceRecord) === deviceId) as DeviceRecord | undefined;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 判断设备是否处于报警 */
|
|
|
|
|
+ function isDeviceAlarm(device?: DeviceRecord | null) {
|
|
|
|
|
+ return !!device?.syswarnLevel;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 根据报警状态获取布点颜色 */
|
|
|
|
|
+ function getMarkerColor(device?: DeviceRecord | null) {
|
|
|
|
|
+ return isDeviceAlarm(device) ? MARKER_COLOR_ALARM : MARKER_COLOR_NORMAL;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 应用布点颜色(圆点背景 + 图标着色) */
|
|
|
|
|
+ function applyMarkerColor(
|
|
|
|
|
+ marker: { mesh: THREE.Mesh; iconSprite: THREE.Sprite },
|
|
|
|
|
+ color: number
|
|
|
|
|
+ ) {
|
|
|
|
|
+ (marker.mesh.material as THREE.MeshBasicMaterial).color.setHex(color);
|
|
|
|
|
+ (marker.iconSprite.material as THREE.SpriteMaterial).color.setHex(color);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 根据最新设备数据刷新所有已布点颜色 */
|
|
|
|
|
+ function updateAllMarkerColors() {
|
|
|
|
|
+ markersMap.forEach((m) => {
|
|
|
|
|
+ applyMarkerColor(m, getMarkerColor(findDevice(m.deviceId)));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 获取信息框展示内容:设备名称、安装位置、运行状态、报警状态、实时温度 */
|
|
|
|
|
+ function getMarkerInfoData(deviceId: string): MarkerInfoData {
|
|
|
|
|
+ const device = findDevice(deviceId);
|
|
|
|
|
+ const temperature = device?.readData?.temperature ?? device?.temperature;
|
|
|
return {
|
|
return {
|
|
|
- deviceId,
|
|
|
|
|
- deviceName: d?.name || '未知',
|
|
|
|
|
- timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
|
|
|
|
- windSpeed: +(Math.random() * 5 + 1.5).toFixed(2),
|
|
|
|
|
- gasConcentration: +(Math.random() * 0.5 + 0.15).toFixed(3),
|
|
|
|
|
- temperature: +(Math.random() * 8 + 20).toFixed(1),
|
|
|
|
|
- pressure: +(Math.random() * 100 + 900).toFixed(0),
|
|
|
|
|
- dustDensity: +(Math.random() * 3 + 0.5).toFixed(2),
|
|
|
|
|
- status: d?.status === 'online' ? '正常' : d?.status === 'alarm' ? '异常' : '离线',
|
|
|
|
|
|
|
+ deviceName: device?.strname || '未知',
|
|
|
|
|
+ installPos: device?.strinstallpos || '-',
|
|
|
|
|
+ runStatus: device?.netStatus == 1 ? '在线' : device?.netStatus == 0 ? '离线' : '-',
|
|
|
|
|
+ alarmStatus: device?.syswarnLevel ? '报警' : '正常',
|
|
|
|
|
+ temperature: temperature === undefined || temperature === null || temperature === '' ? '-' : String(temperature),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ==================== 状态 ====================
|
|
// ==================== 状态 ====================
|
|
|
|
|
|
|
|
- const viewerRef = ref<HTMLElement | null>(null);
|
|
|
|
|
|
|
+ /** 2D查看器是否已完成初始化 */
|
|
|
const viewerReady = ref(false);
|
|
const viewerReady = ref(false);
|
|
|
|
|
+ /** Viewer2d实例,用于操作CAD图纸 */
|
|
|
let viewer: Viewer2d | null = null;
|
|
let viewer: Viewer2d | null = null;
|
|
|
|
|
|
|
|
|
|
+ /** 右侧设备面板是否折叠 */
|
|
|
const panelCollapsed = ref(true);
|
|
const panelCollapsed = ref(true);
|
|
|
|
|
+ /** 是否处于布点模式(点击图纸可放置设备标记) */
|
|
|
const placingMode = ref(false);
|
|
const placingMode = ref(false);
|
|
|
- const placingDevice = ref<DeviceInfo | null>(null);
|
|
|
|
|
|
|
+ /** 当前待布点的设备对象 */
|
|
|
|
|
+ const placingDevice = ref<DeviceRecord | null>(null);
|
|
|
|
|
+ /** 当前鼠标在世界坐标系中的位置(用于坐标栏显示) */
|
|
|
const currentCoord = reactive({ x: 0, y: 0, z: 0 });
|
|
const currentCoord = reactive({ x: 0, y: 0, z: 0 });
|
|
|
|
|
|
|
|
- // 存储 marker:markerId → { deviceId, worldPos, domEl }
|
|
|
|
|
|
|
+ // 存储 marker:markerId → { deviceId, worldPos, mesh, iconSprite }
|
|
|
const markersMap = new Map<
|
|
const markersMap = new Map<
|
|
|
string,
|
|
string,
|
|
|
{
|
|
{
|
|
@@ -167,20 +262,13 @@
|
|
|
worldPos: { x: number; y: number };
|
|
worldPos: { x: number; y: number };
|
|
|
mesh: THREE.Mesh;
|
|
mesh: THREE.Mesh;
|
|
|
iconSprite: THREE.Sprite;
|
|
iconSprite: THREE.Sprite;
|
|
|
- cssLabel: CSS2DObject;
|
|
|
|
|
}
|
|
}
|
|
|
>();
|
|
>();
|
|
|
- const allMeshes: THREE.Mesh[] = [];
|
|
|
|
|
- const allIconSprites: THREE.Sprite[] = [];
|
|
|
|
|
- const allCssLabels: CSS2DObject[] = [];
|
|
|
|
|
const textureLoader = new THREE.TextureLoader();
|
|
const textureLoader = new THREE.TextureLoader();
|
|
|
|
|
|
|
|
- const monitoringData = ref<ReturnType<typeof getMockMonitoring> | null>(null);
|
|
|
|
|
- const modalVisible = ref(false);
|
|
|
|
|
-
|
|
|
|
|
// ==================== 核心:坐标转换 ====================
|
|
// ==================== 核心:坐标转换 ====================
|
|
|
|
|
|
|
|
- // 参照 useEvent.ts: 画布容器有强制缩放 (widthScale/heightScale),需校准坐标
|
|
|
|
|
|
|
+ // 画布容器有强制缩放 (widthScale/heightScale),需校准坐标
|
|
|
function scaledScreenCoord(e: { clientX: number; clientY: number }, container: HTMLElement): THREE.Vector2 {
|
|
function scaledScreenCoord(e: { clientX: number; clientY: number }, container: HTMLElement): THREE.Vector2 {
|
|
|
const appStore = useAppStoreWithOut();
|
|
const appStore = useAppStoreWithOut();
|
|
|
const ws = appStore.getWidthScale;
|
|
const ws = appStore.getWidthScale;
|
|
@@ -201,15 +289,20 @@
|
|
|
|
|
|
|
|
// ==================== 放置模式 ====================
|
|
// ==================== 放置模式 ====================
|
|
|
|
|
|
|
|
|
|
+ /** 布点模式下的悬浮圆点DOM元素(跟随鼠标移动) */
|
|
|
let floatingDot: HTMLElement | null = null;
|
|
let floatingDot: HTMLElement | null = null;
|
|
|
|
|
|
|
|
- function enterPlacingMode(device: DeviceInfo) {
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 进入布点模式
|
|
|
|
|
+ * 创建悬浮圆点,清除设备原有标记,设置布点状态
|
|
|
|
|
+ * @param device 待布点的设备记录
|
|
|
|
|
+ */
|
|
|
|
|
+ function enterPlacingMode(device: DeviceRecord) {
|
|
|
if (!viewer) return;
|
|
if (!viewer) return;
|
|
|
if (device.markerId) removeMarker(device.markerId);
|
|
if (device.markerId) removeMarker(device.markerId);
|
|
|
placingMode.value = true;
|
|
placingMode.value = true;
|
|
|
placingDevice.value = device;
|
|
placingDevice.value = device;
|
|
|
|
|
|
|
|
- // 创建浮动跟随圆点
|
|
|
|
|
if (!floatingDot) {
|
|
if (!floatingDot) {
|
|
|
floatingDot = document.createElement('div');
|
|
floatingDot = document.createElement('div');
|
|
|
floatingDot.id = 'floating-dot';
|
|
floatingDot.id = 'floating-dot';
|
|
@@ -222,30 +315,34 @@
|
|
|
if (cont) cont.appendChild(floatingDot);
|
|
if (cont) cont.appendChild(floatingDot);
|
|
|
}
|
|
}
|
|
|
if (floatingDot) floatingDot.style.display = '';
|
|
if (floatingDot) floatingDot.style.display = '';
|
|
|
-
|
|
|
|
|
- console.log(`[布点] 进入放置模式: ${device.name}`);
|
|
|
|
|
}
|
|
}
|
|
|
- // 退出放置模式
|
|
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 退出布点模式
|
|
|
|
|
+ * 隐藏悬浮圆点,重置布点状态
|
|
|
|
|
+ */
|
|
|
function exitPlacingMode() {
|
|
function exitPlacingMode() {
|
|
|
placingMode.value = false;
|
|
placingMode.value = false;
|
|
|
placingDevice.value = null;
|
|
placingDevice.value = null;
|
|
|
if (floatingDot) floatingDot.style.display = 'none';
|
|
if (floatingDot) floatingDot.style.display = 'none';
|
|
|
}
|
|
}
|
|
|
- // 放置 marker
|
|
|
|
|
- function placeMarker(device: DeviceInfo, location: { x: number; y: number }) {
|
|
|
|
|
|
|
+
|
|
|
|
|
+ function placeMarker(device: DeviceRecord, location: { x: number; y: number }, options?: { showInfo?: boolean; persist?: boolean }) {
|
|
|
if (!viewer) return;
|
|
if (!viewer) return;
|
|
|
|
|
+ const showInfo = options?.showInfo !== false;
|
|
|
|
|
+ const persist = options?.persist !== false;
|
|
|
|
|
+
|
|
|
markerIdCounter++;
|
|
markerIdCounter++;
|
|
|
const markerId = `m${markerIdCounter}`;
|
|
const markerId = `m${markerIdCounter}`;
|
|
|
|
|
|
|
|
- // 动态半径:基于当前视口大小
|
|
|
|
|
const cam = viewer.camera as THREE.OrthographicCamera;
|
|
const cam = viewer.camera as THREE.OrthographicCamera;
|
|
|
const frustumH = cam.top - cam.bottom;
|
|
const frustumH = cam.top - cam.bottom;
|
|
|
- const radius = frustumH * 0.008;
|
|
|
|
|
|
|
+ const markerColor = getMarkerColor(device);
|
|
|
|
|
|
|
|
- // THREE.Mesh 实心圆点 — 绘制在场景中,随模型移动
|
|
|
|
|
|
|
+ // THREE.Mesh 实心圆点 — 作为报警状态背景色,并用于拾取点击
|
|
|
const geom = new THREE.CircleGeometry(100, 32);
|
|
const geom = new THREE.CircleGeometry(100, 32);
|
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
const mat = new THREE.MeshBasicMaterial({
|
|
|
- color: 0xff4d4f,
|
|
|
|
|
|
|
+ color: markerColor,
|
|
|
side: THREE.DoubleSide,
|
|
side: THREE.DoubleSide,
|
|
|
depthTest: false,
|
|
depthTest: false,
|
|
|
depthWrite: false,
|
|
depthWrite: false,
|
|
@@ -253,15 +350,21 @@
|
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
const mesh = new THREE.Mesh(geom, mat);
|
|
|
mesh.position.set(location.x, location.y, 0);
|
|
mesh.position.set(location.x, location.y, 0);
|
|
|
mesh.renderOrder = 999;
|
|
mesh.renderOrder = 999;
|
|
|
- mesh.matrixAutoUpdate = true; // 覆盖 scene.matrixAutoUpdate=false
|
|
|
|
|
- mesh.updateMatrixWorld(); // 立即计算世界矩阵
|
|
|
|
|
- mesh.userData = { markerId, deviceId: device.id };
|
|
|
|
|
|
|
+ mesh.matrixAutoUpdate = true;
|
|
|
|
|
+ mesh.updateMatrixWorld();
|
|
|
|
|
+ const deviceId = getDeviceId(device);
|
|
|
|
|
+ mesh.userData = { markerId, deviceId };
|
|
|
viewer.scene.add(mesh);
|
|
viewer.scene.add(mesh);
|
|
|
- allMeshes.push(mesh);
|
|
|
|
|
|
|
|
|
|
- // 精灵图标(safetymonitor3D.png)
|
|
|
|
|
|
|
+ // 精灵图标(按报警状态着色)
|
|
|
const tex = textureLoader.load('/texture/safetymonitor3D.png');
|
|
const tex = textureLoader.load('/texture/safetymonitor3D.png');
|
|
|
- const spriteMat = new THREE.SpriteMaterial({ map: tex, depthTest: false, depthWrite: false, transparent: true });
|
|
|
|
|
|
|
+ const spriteMat = new THREE.SpriteMaterial({
|
|
|
|
|
+ map: tex,
|
|
|
|
|
+ color: markerColor,
|
|
|
|
|
+ depthTest: false,
|
|
|
|
|
+ depthWrite: false,
|
|
|
|
|
+ transparent: true,
|
|
|
|
|
+ });
|
|
|
const icon = new THREE.Sprite(spriteMat);
|
|
const icon = new THREE.Sprite(spriteMat);
|
|
|
icon.position.set(location.x, location.y, 0);
|
|
icon.position.set(location.x, location.y, 0);
|
|
|
const iconSize = frustumH * 0.015;
|
|
const iconSize = frustumH * 0.015;
|
|
@@ -270,70 +373,140 @@
|
|
|
icon.matrixAutoUpdate = true;
|
|
icon.matrixAutoUpdate = true;
|
|
|
icon.updateMatrixWorld();
|
|
icon.updateMatrixWorld();
|
|
|
viewer.scene.add(icon);
|
|
viewer.scene.add(icon);
|
|
|
- allIconSprites.push(icon);
|
|
|
|
|
-
|
|
|
|
|
- // CSS2DObject 风速标签(图标上方)
|
|
|
|
|
- const labelDiv = document.createElement('div');
|
|
|
|
|
- labelDiv.textContent = `${+(Math.random() * 3 + 1.5).toFixed(1)} m/s`;
|
|
|
|
|
- labelDiv.style.cssText =
|
|
|
|
|
- 'color:#00ffcc;font-size:22px;font-weight:bold;background:rgba(0,0,0,0.7);padding:2px 6px;border-radius:3px;white-space:nowrap;';
|
|
|
|
|
- const cssLabel = new CSS2DObject(labelDiv);
|
|
|
|
|
- cssLabel.position.set(location.x, location.y + 150, 0.1);
|
|
|
|
|
- cssLabel.matrixAutoUpdate = true;
|
|
|
|
|
- cssLabel.updateMatrixWorld();
|
|
|
|
|
- viewer.scene.add(cssLabel);
|
|
|
|
|
- allCssLabels.push(cssLabel);
|
|
|
|
|
|
|
|
|
|
markersMap.set(markerId, {
|
|
markersMap.set(markerId, {
|
|
|
- deviceId: device.id,
|
|
|
|
|
|
|
+ deviceId,
|
|
|
worldPos: { x: location.x, y: location.y },
|
|
worldPos: { x: location.x, y: location.y },
|
|
|
mesh,
|
|
mesh,
|
|
|
iconSprite: icon,
|
|
iconSprite: icon,
|
|
|
- cssLabel,
|
|
|
|
|
});
|
|
});
|
|
|
device.markerId = markerId;
|
|
device.markerId = markerId;
|
|
|
- console.log(`[布点] 设备=${device.name} X=${location.x.toFixed(2)} Y=${location.y.toFixed(2)} r=${radius.toFixed(2)}`);
|
|
|
|
|
- exitPlacingMode();
|
|
|
|
|
|
|
+ const next = new Set(placedDeviceIds.value);
|
|
|
|
|
+ next.add(deviceId);
|
|
|
|
|
+ placedDeviceIds.value = next;
|
|
|
|
|
+ if (persist) savePersistedMarkers();
|
|
|
|
|
+ if (showInfo) {
|
|
|
|
|
+ exitPlacingMode();
|
|
|
|
|
+ showMarkerInfo(markerId);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
- // 移除 marker
|
|
|
|
|
|
|
+
|
|
|
|
|
+ /** 从本地存储恢复布点(刷新页面后还原) */
|
|
|
|
|
+ function restoreMarkers() {
|
|
|
|
|
+ if (!viewer) return;
|
|
|
|
|
+ const list = loadPersistedMarkers();
|
|
|
|
|
+ if (!list.length) return;
|
|
|
|
|
+ list.forEach((item) => {
|
|
|
|
|
+ const device = findDevice(item.deviceId) || ({ deviceID: item.deviceId, strname: '未知' } as DeviceRecord);
|
|
|
|
|
+ if (device.markerId && markersMap.has(device.markerId)) return;
|
|
|
|
|
+ if ([...markersMap.values()].some((m) => m.deviceId === item.deviceId)) return;
|
|
|
|
|
+ placeMarker(device, { x: item.x, y: item.y }, { showInfo: false, persist: false });
|
|
|
|
|
+ });
|
|
|
|
|
+ syncPlacedStateToDevices();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 移除指定标记点
|
|
|
|
|
+ * 从场景中移除mesh和图标精灵,释放THREE.js资源,更新状态并持久化
|
|
|
|
|
+ * @param markerId 标记点ID
|
|
|
|
|
+ */
|
|
|
function removeMarker(markerId: string) {
|
|
function removeMarker(markerId: string) {
|
|
|
if (!viewer) return;
|
|
if (!viewer) return;
|
|
|
const m = markersMap.get(markerId);
|
|
const m = markersMap.get(markerId);
|
|
|
if (!m) return;
|
|
if (!m) return;
|
|
|
- const dev = devices.value.find((d) => d.markerId === markerId);
|
|
|
|
|
|
|
+ const dev = deviceList.value.find((d) => (d as DeviceRecord).markerId === markerId) as DeviceRecord | undefined;
|
|
|
if (dev) dev.markerId = null;
|
|
if (dev) dev.markerId = null;
|
|
|
|
|
+ if (m.deviceId) {
|
|
|
|
|
+ const next = new Set(placedDeviceIds.value);
|
|
|
|
|
+ next.delete(m.deviceId);
|
|
|
|
|
+ placedDeviceIds.value = next;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 从场景移除并释放几何体和材质资源
|
|
|
viewer.scene.remove(m.mesh);
|
|
viewer.scene.remove(m.mesh);
|
|
|
m.mesh.geometry.dispose();
|
|
m.mesh.geometry.dispose();
|
|
|
(m.mesh.material as THREE.Material).dispose();
|
|
(m.mesh.material as THREE.Material).dispose();
|
|
|
viewer.scene.remove(m.iconSprite);
|
|
viewer.scene.remove(m.iconSprite);
|
|
|
(m.iconSprite.material as THREE.SpriteMaterial).map?.dispose();
|
|
(m.iconSprite.material as THREE.SpriteMaterial).map?.dispose();
|
|
|
m.iconSprite.material.dispose();
|
|
m.iconSprite.material.dispose();
|
|
|
- const ci = allCssLabels.indexOf(m.cssLabel);
|
|
|
|
|
- if (ci > -1) allCssLabels.splice(ci, 1);
|
|
|
|
|
-
|
|
|
|
|
- const idx = allMeshes.indexOf(m.mesh);
|
|
|
|
|
- if (idx > -1) allMeshes.splice(idx, 1);
|
|
|
|
|
- const si = allIconSprites.indexOf(m.iconSprite);
|
|
|
|
|
- if (si > -1) allIconSprites.splice(si, 1);
|
|
|
|
|
markersMap.delete(markerId);
|
|
markersMap.delete(markerId);
|
|
|
|
|
+ savePersistedMarkers();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 撤销指定设备的布点 */
|
|
|
|
|
+ function revokePlacement(device: DeviceRecord) {
|
|
|
|
|
+ const deviceId = getDeviceId(device);
|
|
|
|
|
+ let markerId = device.markerId || null;
|
|
|
|
|
+ if (!markerId) {
|
|
|
|
|
+ for (const [id, m] of markersMap.entries()) {
|
|
|
|
|
+ if (m.deviceId === deviceId) {
|
|
|
|
|
+ markerId = id;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!markerId) {
|
|
|
|
|
+ const next = new Set(placedDeviceIds.value);
|
|
|
|
|
+ next.delete(deviceId);
|
|
|
|
|
+ placedDeviceIds.value = next;
|
|
|
|
|
+ device.markerId = null;
|
|
|
|
|
+ savePersistedMarkers();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ hideInfoPanel();
|
|
|
|
|
+ removeMarker(markerId);
|
|
|
|
|
+ if (viewer) {
|
|
|
|
|
+ viewer.renderer.render(viewer.scene, viewer.camera);
|
|
|
|
|
+ viewer.getCssRender()?.render(viewer.scene, viewer.camera);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let infoPanel: HTMLElement | null = null;
|
|
let infoPanel: HTMLElement | null = null;
|
|
|
- // 显示 marker 信息面板
|
|
|
|
|
- function showMarkerInfo(markerId: string) {
|
|
|
|
|
|
|
+ /** 当前打开的信息框对应的 markerId,用于缩放/平移时更新位置 */
|
|
|
|
|
+ let activeInfoMarkerId: string | null = null;
|
|
|
|
|
+ let infoPanelCloseHandler: ((e: MouseEvent) => void) | null = null;
|
|
|
|
|
+
|
|
|
|
|
+ function hideInfoPanel() {
|
|
|
|
|
+ infoPanel?.remove();
|
|
|
|
|
+ infoPanel = null;
|
|
|
|
|
+ activeInfoMarkerId = null;
|
|
|
|
|
+ if (infoPanelCloseHandler) {
|
|
|
|
|
+ document.removeEventListener('click', infoPanelCloseHandler);
|
|
|
|
|
+ infoPanelCloseHandler = null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 根据 marker 世界坐标刷新信息框屏幕位置 */
|
|
|
|
|
+ function updateInfoPanelPosition() {
|
|
|
|
|
+ if (!infoPanel || !activeInfoMarkerId || !viewer) return;
|
|
|
|
|
+ const m = markersMap.get(activeInfoMarkerId);
|
|
|
|
|
+ if (!m) return;
|
|
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
|
|
+ if (!container) return;
|
|
|
|
|
+ const sp = CoordinateUtils.world2Screen(new THREE.Vector3(m.worldPos.x, m.worldPos.y, 0), viewer.camera, container);
|
|
|
|
|
+ if (!sp) return;
|
|
|
|
|
+ infoPanel.style.left = `${sp.x + 16}px`;
|
|
|
|
|
+ infoPanel.style.top = `${sp.y - 10}px`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 显示设备信息浮窗
|
|
|
|
|
+ * 在标记点附近创建信息面板,显示设备名称、安装位置、运行状态、报警状态、实时温度
|
|
|
|
|
+ * 点击面板外区域或关闭按钮可隐藏
|
|
|
|
|
+ * @param markerId 标记点ID
|
|
|
|
|
+ */
|
|
|
|
|
+ function showMarkerInfo(markerId: string) {
|
|
|
const m = markersMap.get(markerId);
|
|
const m = markersMap.get(markerId);
|
|
|
if (!m || !viewer) return;
|
|
if (!m || !viewer) return;
|
|
|
- const data = getMockMonitoring(m.deviceId);
|
|
|
|
|
|
|
+ const info = getMarkerInfoData(m.deviceId);
|
|
|
const container = document.getElementById(containerId);
|
|
const container = document.getElementById(containerId);
|
|
|
if (!container) return;
|
|
if (!container) return;
|
|
|
|
|
|
|
|
- // 移除旧 panel
|
|
|
|
|
- infoPanel?.remove();
|
|
|
|
|
|
|
+ hideInfoPanel();
|
|
|
|
|
|
|
|
- // Marker 世界坐标 → 屏幕坐标
|
|
|
|
|
const sp = CoordinateUtils.world2Screen(new THREE.Vector3(m.worldPos.x, m.worldPos.y, 0), viewer.camera, container);
|
|
const sp = CoordinateUtils.world2Screen(new THREE.Vector3(m.worldPos.x, m.worldPos.y, 0), viewer.camera, container);
|
|
|
|
|
|
|
|
infoPanel = document.createElement('div');
|
|
infoPanel = document.createElement('div');
|
|
|
|
|
+ activeInfoMarkerId = markerId;
|
|
|
infoPanel.style.cssText = `
|
|
infoPanel.style.cssText = `
|
|
|
position:absolute; left:${sp.x + 16}px; top:${sp.y - 10}px;
|
|
position:absolute; left:${sp.x + 16}px; top:${sp.y - 10}px;
|
|
|
background:rgba(0,0,0,0.88); color:#fff; font-size:12px;
|
|
background:rgba(0,0,0,0.88); color:#fff; font-size:12px;
|
|
@@ -343,37 +516,38 @@
|
|
|
`;
|
|
`;
|
|
|
infoPanel.innerHTML = `
|
|
infoPanel.innerHTML = `
|
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
|
|
|
- <b>${data.deviceName}</b>
|
|
|
|
|
|
|
+ <b>${info.deviceName}</b>
|
|
|
<span style="cursor:pointer;color:#aaa;margin-left:12px;" id="info-close">×</span>
|
|
<span style="cursor:pointer;color:#aaa;margin-left:12px;" id="info-close">×</span>
|
|
|
</div>
|
|
</div>
|
|
|
- <div>风速: ${data.windSpeed} m/s</div>
|
|
|
|
|
- <div>瓦斯浓度: ${data.gasConcentration}%</div>
|
|
|
|
|
- <div>温度: ${data.temperature}°C</div>
|
|
|
|
|
- <div>时间: ${data.timestamp}</div>
|
|
|
|
|
- <div>状态: <span style="color:${data.status === '正常' ? '#52c41a' : '#ff4d4f'}">${data.status}</span></div>
|
|
|
|
|
|
|
+ <div>安装位置: ${info.installPos}</div>
|
|
|
|
|
+ <div>运行状态: <span style="color:${info.runStatus === '在线' ? '#52c41a' : info.runStatus === '离线' ? '#ff4d4f' : '#faad14'}">${info.runStatus}</span></div>
|
|
|
|
|
+ <div>报警状态: <span style="color:${info.alarmStatus === '报警' ? '#ff4d4f' : '#52c41a'}">${info.alarmStatus}</span></div>
|
|
|
|
|
+ <div>实时温度: ${info.temperature}${info.temperature !== '-' ? '℃' : ''}</div>
|
|
|
`;
|
|
`;
|
|
|
container.appendChild(infoPanel);
|
|
container.appendChild(infoPanel);
|
|
|
- infoPanel.querySelector('#info-close')?.addEventListener('click', () => infoPanel?.remove());
|
|
|
|
|
- // 点击其他地方关闭
|
|
|
|
|
- const closeHandler = (e: MouseEvent) => {
|
|
|
|
|
|
|
+ infoPanel.querySelector('#info-close')?.addEventListener('click', (e) => {
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ hideInfoPanel();
|
|
|
|
|
+ });
|
|
|
|
|
+ infoPanelCloseHandler = (e: MouseEvent) => {
|
|
|
if (!infoPanel?.contains(e.target as Node)) {
|
|
if (!infoPanel?.contains(e.target as Node)) {
|
|
|
- infoPanel?.remove();
|
|
|
|
|
- infoPanel = null;
|
|
|
|
|
- document.removeEventListener('click', closeHandler);
|
|
|
|
|
|
|
+ hideInfoPanel();
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
- setTimeout(() => document.addEventListener('click', closeHandler), 100);
|
|
|
|
|
- }
|
|
|
|
|
- // 点击 marker 事件
|
|
|
|
|
- function onMarkerClick(markerId: string) {
|
|
|
|
|
- console.log(`[标记点击] marker=${markerId}`);
|
|
|
|
|
- showMarkerInfo(markerId);
|
|
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ if (infoPanelCloseHandler) document.addEventListener('click', infoPanelCloseHandler);
|
|
|
|
|
+ }, 100);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// ==================== 初始化 ====================
|
|
// ==================== 初始化 ====================
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 组件挂载时初始化
|
|
|
|
|
+ * 1. 创建Viewer2d实例加载DWG图纸
|
|
|
|
|
+ * 2. 注册鼠标点击/移动事件(坐标显示、布点、信息查看)
|
|
|
|
|
+ * 3. 监听相机变化更新信息框位置
|
|
|
|
|
+ */
|
|
|
onMounted(async () => {
|
|
onMounted(async () => {
|
|
|
- // 初始化 viewer
|
|
|
|
|
try {
|
|
try {
|
|
|
viewer = new Viewer2d({
|
|
viewer = new Viewer2d({
|
|
|
containerId,
|
|
containerId,
|
|
@@ -382,26 +556,18 @@
|
|
|
enableLayoutBar: true,
|
|
enableLayoutBar: true,
|
|
|
});
|
|
});
|
|
|
await viewer.setFont(fontFiles);
|
|
await viewer.setFont(fontFiles);
|
|
|
- await viewer.loadModel({ modelId: 'main-plan', name: '总平面布置图', src: dwgSrc }, (event: ProgressEvent) => {
|
|
|
|
|
- if (event.total > 0) console.log(`加载: ${Math.round((event.loaded / event.total) * 100)}%`);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ await viewer.loadModel({ modelId: 'main-plan', name: '总平面布置图', src: dwgSrc });
|
|
|
|
|
|
|
|
viewer.goToHomeView();
|
|
viewer.goToHomeView();
|
|
|
viewerReady.value = true;
|
|
viewerReady.value = true;
|
|
|
- // initCss();
|
|
|
|
|
-
|
|
|
|
|
- console.log('[DWG] Viewer2d 就绪');
|
|
|
|
|
|
|
|
|
|
- const appStore = useAppStoreWithOut();
|
|
|
|
|
|
|
+ restoreMarkers();
|
|
|
|
|
+ viewer.renderer.render(viewer.scene, viewer.camera);
|
|
|
|
|
+ viewer.getCssRender()?.render(viewer.scene, viewer.camera);
|
|
|
|
|
|
|
|
-
|
|
|
|
|
- // === 参照官方示例: ViewerEvent.MouseClick ===
|
|
|
|
|
viewer.addEventListener(ViewerEvent.MouseClick, (data: any) => {
|
|
viewer.addEventListener(ViewerEvent.MouseClick, (data: any) => {
|
|
|
if (!viewer) return;
|
|
if (!viewer) return;
|
|
|
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- // 获取 world 坐标用于实时显示
|
|
|
|
|
const container = document.getElementById(containerId);
|
|
const container = document.getElementById(containerId);
|
|
|
if (data?.event && container) {
|
|
if (data?.event && container) {
|
|
|
const sc = scaledScreenCoord(data.event, container);
|
|
const sc = scaledScreenCoord(data.event, container);
|
|
@@ -413,17 +579,15 @@
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 检测是否点击了已有 marker mesh
|
|
|
|
|
if (!placingMode.value && data?.event && container) {
|
|
if (!placingMode.value && data?.event && container) {
|
|
|
const sc2 = scaledScreenCoord(data.event, container);
|
|
const sc2 = scaledScreenCoord(data.event, container);
|
|
|
const hit = viewer.pickObject(sc2);
|
|
const hit = viewer.pickObject(sc2);
|
|
|
if (hit?.object?.userData?.markerId) {
|
|
if (hit?.object?.userData?.markerId) {
|
|
|
- onMarkerClick(hit.object.userData.markerId as string);
|
|
|
|
|
|
|
+ showMarkerInfo(hit.object.userData.markerId as string);
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 点击空白背景 → 放置 marker
|
|
|
|
|
if (!data?.entityData && !data?.markupData && !data?.measureData) {
|
|
if (!data?.entityData && !data?.markupData && !data?.measureData) {
|
|
|
if (placingMode.value && placingDevice.value) {
|
|
if (placingMode.value && placingDevice.value) {
|
|
|
const location = getHitResult(data.event);
|
|
const location = getHitResult(data.event);
|
|
@@ -432,129 +596,85 @@
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
viewer.renderer.render(viewer.scene, viewer.camera);
|
|
viewer.renderer.render(viewer.scene, viewer.camera);
|
|
|
- viewer.getCssRender().render(viewer.scene, viewer.camera);
|
|
|
|
|
- // css3DRenderer?.render(viewer.scene, viewer.camera);
|
|
|
|
|
|
|
+ viewer.getCssRender()?.render(viewer.scene, viewer.camera);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
- // === 鼠标移动:实时更新坐标 + 浮动圆点跟随 ===
|
|
|
|
|
const container = document.getElementById(containerId);
|
|
const container = document.getElementById(containerId);
|
|
|
if (container) {
|
|
if (container) {
|
|
|
container.addEventListener('mousemove', (e: MouseEvent) => {
|
|
container.addEventListener('mousemove', (e: MouseEvent) => {
|
|
|
if (!viewer) return;
|
|
if (!viewer) return;
|
|
|
- // const ws = appStore.getWidthScale;
|
|
|
|
|
- // const hs = appStore.getHeightScale;
|
|
|
|
|
- // 浮动圆点跟随鼠标(缩放校准后坐标,与 marker 放置位置一致)
|
|
|
|
|
const sc = scaledScreenCoord(e, container);
|
|
const sc = scaledScreenCoord(e, container);
|
|
|
if (floatingDot && placingMode.value) {
|
|
if (floatingDot && placingMode.value) {
|
|
|
floatingDot.style.left = sc.x + 'px';
|
|
floatingDot.style.left = sc.x + 'px';
|
|
|
- floatingDot.style.top = sc.y + 'px';
|
|
|
|
|
|
|
+ floatingDot.style.top = sc.y + 'px';
|
|
|
}
|
|
}
|
|
|
- // 世界坐标
|
|
|
|
|
const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
|
|
const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
|
|
|
if (wp) {
|
|
if (wp) {
|
|
|
currentCoord.x = wp.x;
|
|
currentCoord.x = wp.x;
|
|
|
currentCoord.y = wp.y;
|
|
currentCoord.y = wp.y;
|
|
|
currentCoord.z = wp.z;
|
|
currentCoord.z = wp.z;
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- // css3DRenderer?.render(viewer.scene, viewer.camera);
|
|
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // === Mesh 标记在场景坐标系中,随模型自动移动,无需 CameraChange 更新 ===
|
|
|
|
|
|
|
+ // 滚轮缩放 / 平移时,信息悬浮框跟随布点
|
|
|
|
|
+ viewer.addEventListener(ViewerEvent.CameraChange, () => {
|
|
|
|
|
+ updateInfoPanelPosition();
|
|
|
|
|
+ });
|
|
|
|
|
+ viewer.addEventListener(ViewerEvent.AfterRender, () => {
|
|
|
|
|
+ if (infoPanel && activeInfoMarkerId) updateInfoPanelPosition();
|
|
|
|
|
+ });
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
console.error('[DWG] 初始化失败:', err);
|
|
console.error('[DWG] 初始化失败:', err);
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
- /** 键盘事件处理:Esc 键退出放置模式 */
|
|
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 监听设备列表变化
|
|
|
|
|
+ * 当外部传入的设备数据更新时,同步布点状态到列表(保证按钮状态正确)
|
|
|
|
|
+ */
|
|
|
|
|
+ watch(
|
|
|
|
|
+ deviceList,
|
|
|
|
|
+ () => {
|
|
|
|
|
+ if (!viewerReady.value) return;
|
|
|
|
|
+ syncPlacedStateToDevices();
|
|
|
|
|
+ },
|
|
|
|
|
+ { deep: true }
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ /** 键盘事件处理:Esc键退出布点模式 */
|
|
|
const onKeydown = (e: KeyboardEvent) => {
|
|
const onKeydown = (e: KeyboardEvent) => {
|
|
|
if (e.key === 'Escape' && placingMode.value) exitPlacingMode();
|
|
if (e.key === 'Escape' && placingMode.value) exitPlacingMode();
|
|
|
};
|
|
};
|
|
|
- // 注册键盘事件监听
|
|
|
|
|
- onMounted(() => window.addEventListener('keydown', onKeydown));
|
|
|
|
|
|
|
+ onMounted(() => window.addEventListener('keydown', onKeydown));
|
|
|
|
|
|
|
|
- /** 组件卸载时清理:移除事件监听、销毁所有 marker、释放 viewer 资源 */
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 组件卸载时清理资源
|
|
|
|
|
+ * 移除事件监听、销毁Viewer实例、释放所有THREE.js资源
|
|
|
|
|
+ */
|
|
|
onUnmounted(() => {
|
|
onUnmounted(() => {
|
|
|
window.removeEventListener('keydown', onKeydown);
|
|
window.removeEventListener('keydown', onKeydown);
|
|
|
|
|
+ hideInfoPanel();
|
|
|
floatingDot?.remove();
|
|
floatingDot?.remove();
|
|
|
floatingDot = null;
|
|
floatingDot = null;
|
|
|
if (viewer) {
|
|
if (viewer) {
|
|
|
- allMeshes.forEach((m) => {
|
|
|
|
|
- viewer!.scene.remove(m);
|
|
|
|
|
- m.geometry.dispose();
|
|
|
|
|
- (m.material as THREE.Material).dispose();
|
|
|
|
|
- });
|
|
|
|
|
- allMeshes.length = 0;
|
|
|
|
|
- allIconSprites.forEach((s) => {
|
|
|
|
|
- viewer!.scene.remove(s);
|
|
|
|
|
- (s.material as THREE.SpriteMaterial).map?.dispose();
|
|
|
|
|
- s.material.dispose();
|
|
|
|
|
|
|
+ markersMap.forEach((m) => {
|
|
|
|
|
+ viewer!.scene.remove(m.mesh);
|
|
|
|
|
+ m.mesh.geometry.dispose();
|
|
|
|
|
+ (m.mesh.material as THREE.Material).dispose();
|
|
|
|
|
+ viewer!.scene.remove(m.iconSprite);
|
|
|
|
|
+ (m.iconSprite.material as THREE.SpriteMaterial).map?.dispose();
|
|
|
|
|
+ m.iconSprite.material.dispose();
|
|
|
});
|
|
});
|
|
|
- allIconSprites.length = 0;
|
|
|
|
|
- allCssLabels.forEach((l) => viewer!.scene?.remove(l));
|
|
|
|
|
- allCssLabels.length = 0;
|
|
|
|
|
- }
|
|
|
|
|
- markersMap.clear();
|
|
|
|
|
- if (viewer) {
|
|
|
|
|
|
|
+ markersMap.clear();
|
|
|
viewer.destroy?.();
|
|
viewer.destroy?.();
|
|
|
viewer = null;
|
|
viewer = null;
|
|
|
}
|
|
}
|
|
|
});
|
|
});
|
|
|
</script>
|
|
</script>
|
|
|
|
|
|
|
|
-<style lang="less">
|
|
|
|
|
- /* 参照官方示例 hotpoint 样式 */
|
|
|
|
|
- .hotpoint {
|
|
|
|
|
- opacity: 0.85;
|
|
|
|
|
- pointer-events: auto;
|
|
|
|
|
- }
|
|
|
|
|
- .hotpoint-dot {
|
|
|
|
|
- width: 16px;
|
|
|
|
|
- height: 16px;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- background: #ff4d4f;
|
|
|
|
|
- border: 2px solid #fff;
|
|
|
|
|
- border-radius: 50%;
|
|
|
|
|
- box-shadow: 0 0 10px rgba(255, 77, 79, 0.8);
|
|
|
|
|
- }
|
|
|
|
|
- .hotpoint-dot:hover {
|
|
|
|
|
- box-shadow: 0 0 16px rgba(255, 100, 100, 0.9);
|
|
|
|
|
- background: #ff6666;
|
|
|
|
|
- }
|
|
|
|
|
- .hotpoint-panel {
|
|
|
|
|
- position: absolute;
|
|
|
|
|
- top: 20px;
|
|
|
|
|
- left: -75px;
|
|
|
|
|
- min-width: 120px;
|
|
|
|
|
- background: rgba(0, 0, 0, 0.85);
|
|
|
|
|
- border: 1px solid rgba(255, 255, 255, 0.2);
|
|
|
|
|
- border-radius: 6px;
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- font-size: 12px;
|
|
|
|
|
- }
|
|
|
|
|
- .hotpoint-close {
|
|
|
|
|
- text-align: right;
|
|
|
|
|
- padding: 2px 6px;
|
|
|
|
|
- cursor: pointer;
|
|
|
|
|
- color: #aaa;
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- &:hover {
|
|
|
|
|
- color: #fff;
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
- .hotpoint-body {
|
|
|
|
|
- padding: 4px 8px 8px;
|
|
|
|
|
- min-height: 30px;
|
|
|
|
|
- white-space: nowrap;
|
|
|
|
|
- }
|
|
|
|
|
- .hide {
|
|
|
|
|
- display: none;
|
|
|
|
|
- }
|
|
|
|
|
-</style>
|
|
|
|
|
-
|
|
|
|
|
<style lang="less" scoped>
|
|
<style lang="less" scoped>
|
|
|
- @import '/@/design/theme.less';
|
|
|
|
|
-
|
|
|
|
|
|
|
+ /* 最外层容器:占满父级、隐藏溢出 */
|
|
|
.content {
|
|
.content {
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
height: 100%;
|
|
height: 100%;
|
|
@@ -562,17 +682,20 @@
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* 视图区域:canvas 的父容器 */
|
|
|
.viewer-area {
|
|
.viewer-area {
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
height: 100%;
|
|
height: 100%;
|
|
|
position: relative;
|
|
position: relative;
|
|
|
}
|
|
}
|
|
|
|
|
+ /* 图纸渲染 canvas 容器 */
|
|
|
.viewer-canvas {
|
|
.viewer-canvas {
|
|
|
width: 100%;
|
|
width: 100%;
|
|
|
height: 100%;
|
|
height: 100%;
|
|
|
position: relative;
|
|
position: relative;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* 底部坐标栏:显示当前鼠标在世界坐标中的位置 */
|
|
|
.coord-bar {
|
|
.coord-bar {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
bottom: 8px;
|
|
bottom: 8px;
|
|
@@ -585,36 +708,43 @@
|
|
|
font-size: 13px;
|
|
font-size: 13px;
|
|
|
font-family: Consolas, Monaco, monospace;
|
|
font-family: Consolas, Monaco, monospace;
|
|
|
z-index: 200;
|
|
z-index: 200;
|
|
|
- pointer-events: none;
|
|
|
|
|
|
|
+ pointer-events: none; /* 不阻挡鼠标事件,让事件穿透到 canvas */
|
|
|
white-space: nowrap;
|
|
white-space: nowrap;
|
|
|
> span {
|
|
> span {
|
|
|
margin: 0 4px;
|
|
margin: 0 4px;
|
|
|
}
|
|
}
|
|
|
.placing-hint {
|
|
.placing-hint {
|
|
|
- color: #faad14;
|
|
|
|
|
|
|
+ color: #faad14; /* 放置模式下的提示文字颜色 */
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /* 右侧设备列表面板(毛玻璃效果) */
|
|
|
.device-panel {
|
|
.device-panel {
|
|
|
position: absolute;
|
|
position: absolute;
|
|
|
top: 8px;
|
|
top: 8px;
|
|
|
right: 8px;
|
|
right: 8px;
|
|
|
width: 300px;
|
|
width: 300px;
|
|
|
z-index: 300;
|
|
z-index: 300;
|
|
|
- background: var(--vent-device-manager-box-bg, rgba(20, 20, 40, 0.94));
|
|
|
|
|
- border: 1px solid var(--vent-device-manager-box-border, rgba(255, 255, 255, 0.15));
|
|
|
|
|
- border-radius: 8px;
|
|
|
|
|
|
|
+ background: rgba(16, 28, 48, 0.45);
|
|
|
|
|
+ border: 1px solid rgba(255, 255, 255, 0.18);
|
|
|
|
|
+ border-radius: 10px;
|
|
|
overflow: hidden;
|
|
overflow: hidden;
|
|
|
|
|
+ backdrop-filter: blur(14px) saturate(1.35);
|
|
|
|
|
+ -webkit-backdrop-filter: blur(14px) saturate(1.35);
|
|
|
|
|
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
|
|
|
|
|
+ /* 折叠状态:宽度自适应 */
|
|
|
&.collapsed {
|
|
&.collapsed {
|
|
|
width: auto;
|
|
width: auto;
|
|
|
}
|
|
}
|
|
|
|
|
+ /* 面板头部:标题 + 展开/收起按钮 */
|
|
|
.panel-header {
|
|
.panel-header {
|
|
|
display: flex;
|
|
display: flex;
|
|
|
justify-content: space-between;
|
|
justify-content: space-between;
|
|
|
align-items: center;
|
|
align-items: center;
|
|
|
padding: 8px 12px;
|
|
padding: 8px 12px;
|
|
|
cursor: pointer;
|
|
cursor: pointer;
|
|
|
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
.panel-title {
|
|
.panel-title {
|
|
|
font-size: 14px;
|
|
font-size: 14px;
|
|
|
font-weight: 600;
|
|
font-weight: 600;
|
|
@@ -625,25 +755,26 @@
|
|
|
color: rgba(255, 255, 255, 0.5);
|
|
color: rgba(255, 255, 255, 0.5);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ /* 面板内容区:设备表格 */
|
|
|
.panel-body {
|
|
.panel-body {
|
|
|
padding: 6px;
|
|
padding: 6px;
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
|
|
|
- .monitor-data {
|
|
|
|
|
- .monitor-row {
|
|
|
|
|
- display: flex;
|
|
|
|
|
- padding: 8px 0;
|
|
|
|
|
- border-bottom: 1px solid #f0f0f0;
|
|
|
|
|
- font-size: 14px;
|
|
|
|
|
- }
|
|
|
|
|
- .monitor-row:last-child {
|
|
|
|
|
- border-bottom: none;
|
|
|
|
|
- }
|
|
|
|
|
- .monitor-key {
|
|
|
|
|
- width: 72px;
|
|
|
|
|
- color: #888;
|
|
|
|
|
- flex-shrink: 0;
|
|
|
|
|
|
|
+ :deep(.ant-table) {
|
|
|
|
|
+ background: transparent !important;
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(.ant-table-thead > tr > th) {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.06) !important;
|
|
|
|
|
+ border-bottom-color: rgba(255, 255, 255, 0.1) !important;
|
|
|
|
|
+ color: rgba(255, 255, 255, 0.85);
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(.ant-table-tbody > tr > td) {
|
|
|
|
|
+ background: transparent !important;
|
|
|
|
|
+ border-bottom-color: rgba(255, 255, 255, 0.06) !important;
|
|
|
|
|
+ }
|
|
|
|
|
+ :deep(.ant-table-tbody > tr:hover > td) {
|
|
|
|
|
+ background: rgba(255, 255, 255, 0.08) !important;
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
</style>
|
|
</style>
|