dwgViewer.vue 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636
  1. <template>
  2. <div class="content">
  3. <div class="viewer-area">
  4. <div :id="containerId" ref="viewerRef" class="viewer-canvas"></div>
  5. <div v-if="viewerReady" class="coord-bar">
  6. <span>X: {{ currentCoord.x.toFixed(2) }}</span>
  7. <span>Y: {{ currentCoord.y.toFixed(2) }}</span>
  8. <span v-if="placingMode" class="placing-hint">| 放置: {{ placingDevice?.name }} (Esc取消)</span>
  9. </div>
  10. </div>
  11. <div class="device-panel" :class="{ collapsed: panelCollapsed }">
  12. <div class="panel-header" @click="panelCollapsed = !panelCollapsed">
  13. <span class="panel-title">设备列表</span>
  14. <span class="panel-toggle">{{ panelCollapsed ? '展开' : '收起' }}</span>
  15. </div>
  16. <div v-show="!panelCollapsed" class="panel-body">
  17. <a-table :columns="columns" :data-source="devices" :pagination="false" row-key="id" size="small" :scroll="{ y: 360 }">
  18. <template #bodyCell="{ column, record }">
  19. <template v-if="column.key === 'index'">{{ devices.indexOf(record) + 1 }}</template>
  20. <template v-if="column.key === 'status'">
  21. <a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
  22. </template>
  23. <template v-if="column.key === 'action'">
  24. <a-button type="primary" size="small" :disabled="placingMode" @click="enterPlacingMode(record)">布点</a-button>
  25. </template>
  26. <template v-if="column.key === 'markerStatus'">
  27. <a-tag v-if="record.markerId" color="green">已布</a-tag>
  28. <span v-else style="color: rgba(255, 255, 255, 0.35); font-size: 12px">-</span>
  29. </template>
  30. </template>
  31. </a-table>
  32. </div>
  33. </div>
  34. <a-modal v-model:open="modalVisible" :title="(monitoringData?.deviceName || '') + ' - 监测数据'" :footer="null" width="420px">
  35. <div v-if="monitoringData" class="monitor-data">
  36. <div class="monitor-row"
  37. ><span class="monitor-key">时间戳</span><span>{{ monitoringData.timestamp }}</span></div
  38. >
  39. <div class="monitor-row"
  40. ><span class="monitor-key">风速</span><span>{{ monitoringData.windSpeed }} m/s</span></div
  41. >
  42. <div class="monitor-row"
  43. ><span class="monitor-key">瓦斯浓度</span><span>{{ monitoringData.gasConcentration }}%</span></div
  44. >
  45. <div class="monitor-row"
  46. ><span class="monitor-key">温度</span><span>{{ monitoringData.temperature }}°C</span></div
  47. >
  48. <div class="monitor-row"
  49. ><span class="monitor-key">压力</span><span>{{ monitoringData.pressure }} Pa</span></div
  50. >
  51. <div class="monitor-row"
  52. ><span class="monitor-key">粉尘浓度</span><span>{{ monitoringData.dustDensity }} mg/m³</span></div
  53. >
  54. <div class="monitor-row"
  55. ><span class="monitor-key">运行状态</span
  56. ><a-tag :color="monitoringData.status === '正常' ? 'green' : 'red'">{{ monitoringData.status }}</a-tag></div
  57. >
  58. </div>
  59. </a-modal>
  60. </div>
  61. </template>
  62. <script setup lang="ts">
  63. import { ref, onMounted, onUnmounted, reactive } from 'vue';
  64. import { Viewer2d, ViewerEvent, CoordinateUtils, THREE } from '@x-viewer/core';
  65. import { CSS3DRenderer, CSS3DObject } from 'three/examples/jsm/renderers/CSS3DRenderer.js';
  66. import { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
  67. import { useAppStoreWithOut } from '/@/store/modules/app';
  68. import dayjs from 'dayjs';
  69. // ==================== 类型 ====================
  70. interface DeviceInfo {
  71. id: string;
  72. name: string;
  73. type: string;
  74. status: 'online' | 'offline' | 'alarm';
  75. markerId: string | null;
  76. }
  77. // ==================== 常量 ====================
  78. const containerId = 'dwg-viewer-canvas';
  79. const dwgSrc = '/dwg/2026年2月总平面布置图_无高程.dwg';
  80. const fontFiles = [
  81. '/dwg/simplex.shx',
  82. '/dwg/hztxt.shx',
  83. '/dwg/arial.ttf',
  84. '/dwg/Microsoft_YaHei.ttf',
  85. '/dwg/Microsoft_YaHei_Regular.typeface.json',
  86. ];
  87. let markerIdCounter = 0;
  88. // ==================== 表格 ====================
  89. // 设备列表表格列
  90. const columns = [
  91. { title: '#', key: 'index', width: 36 },
  92. { title: '设备名称', dataIndex: 'name', ellipsis: true },
  93. { title: '类型', dataIndex: 'type', width: 56 },
  94. { title: '状态', key: 'status', width: 54 },
  95. { title: '操作', key: 'action', width: 56 },
  96. { title: '布点', key: 'markerStatus', width: 48 },
  97. ];
  98. // 设备列表数据
  99. const devices = ref<DeviceInfo[]>([
  100. // { id: 'd001', name: '1#瓦斯传感器', type: '传感器', status: 'online', markerId: null },
  101. // { id: 'd002', name: '2#瓦斯传感器', type: '传感器', status: 'online', markerId: null },
  102. // { id: 'd003', name: '主通风机', type: '风机', status: 'online', markerId: null },
  103. // { id: 'd004', name: '局部通风机', type: '风机', status: 'offline', markerId: null },
  104. // { id: 'd005', name: '风门A', type: '风门', status: 'online', markerId: null },
  105. // { id: 'd006', name: '风速传感器-1', type: '传感器', status: 'alarm', markerId: null },
  106. // { id: 'd007', name: '温度传感器-1', type: '传感器', status: 'online', markerId: null },
  107. // { id: 'd008', name: '风窗B', type: '风窗', status: 'online', markerId: null },
  108. ]);
  109. // 状态颜色映射
  110. function statusColor(s: string) {
  111. return { online: 'green', offline: 'gray', alarm: 'red' }[s] || 'default';
  112. }
  113. // 状态文本映射
  114. function statusText(s: string) {
  115. return { online: '在线', offline: '离线', alarm: '报警' }[s] || s;
  116. }
  117. // 模拟监测数据
  118. function getMockMonitoring(deviceId: string) {
  119. const d = devices.value.find((x) => x.id === deviceId);
  120. return {
  121. deviceId,
  122. deviceName: d?.name || '未知',
  123. timestamp: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  124. windSpeed: +(Math.random() * 5 + 1.5).toFixed(2),
  125. gasConcentration: +(Math.random() * 0.5 + 0.15).toFixed(3),
  126. temperature: +(Math.random() * 8 + 20).toFixed(1),
  127. pressure: +(Math.random() * 100 + 900).toFixed(0),
  128. dustDensity: +(Math.random() * 3 + 0.5).toFixed(2),
  129. status: d?.status === 'online' ? '正常' : d?.status === 'alarm' ? '异常' : '离线',
  130. };
  131. }
  132. // ==================== 状态 ====================
  133. const viewerRef = ref<HTMLElement | null>(null);
  134. const viewerReady = ref(false);
  135. let viewer: Viewer2d | null = null;
  136. const panelCollapsed = ref(true);
  137. const placingMode = ref(false);
  138. const placingDevice = ref<DeviceInfo | null>(null);
  139. const currentCoord = reactive({ x: 0, y: 0, z: 0 });
  140. // 存储 marker:markerId → { deviceId, worldPos, domEl }
  141. const markersMap = new Map<
  142. string,
  143. {
  144. deviceId: string;
  145. worldPos: { x: number; y: number };
  146. mesh: THREE.Mesh;
  147. iconSprite: THREE.Sprite;
  148. cssLabel: CSS2DObject;
  149. }
  150. >();
  151. const allMeshes: THREE.Mesh[] = [];
  152. const allIconSprites: THREE.Sprite[] = [];
  153. const allCssLabels: CSS2DObject[] = [];
  154. const textureLoader = new THREE.TextureLoader();
  155. const monitoringData = ref<ReturnType<typeof getMockMonitoring> | null>(null);
  156. const modalVisible = ref(false);
  157. // ==================== 核心:坐标转换 ====================
  158. // 参照 useEvent.ts: 画布容器有强制缩放 (widthScale/heightScale),需校准坐标
  159. function scaledScreenCoord(e: { clientX: number; clientY: number }, container: HTMLElement): THREE.Vector2 {
  160. const appStore = useAppStoreWithOut();
  161. const ws = appStore.getWidthScale;
  162. const hs = appStore.getHeightScale;
  163. const rect = container.getBoundingClientRect();
  164. // X: (event.clientX / ws - rect.left) = NDC*clientWidth
  165. // Y: (event.clientY - rect.top) / hs = NDC*clientHeight (reversed)
  166. return new THREE.Vector2(e.clientX / ws - rect.left, (e.clientY - rect.top) / hs);
  167. }
  168. function getHitResult(event: MouseEvent): { x: number; y: number } | null {
  169. if (!viewer) return null;
  170. const container = document.getElementById(containerId);
  171. if (!container) return null;
  172. const sc = scaledScreenCoord(event, container);
  173. const worldPos = CoordinateUtils.screen2World(sc, viewer.camera, container);
  174. if (!worldPos) return null;
  175. return { x: worldPos.x, y: worldPos.y };
  176. }
  177. // ==================== 放置模式 ====================
  178. let floatingDot: HTMLElement | null = null;
  179. function enterPlacingMode(device: DeviceInfo) {
  180. if (!viewer) return;
  181. if (device.markerId) removeMarker(device.markerId);
  182. placingMode.value = true;
  183. placingDevice.value = device;
  184. // 创建浮动跟随圆点
  185. if (!floatingDot) {
  186. floatingDot = document.createElement('div');
  187. floatingDot.id = 'floating-dot';
  188. floatingDot.style.cssText = `
  189. position:absolute; width:20px; height:20px; transform:translate(-50%,-50%);
  190. background:#ff4d4f; border:3px solid #fff; border-radius:50%;
  191. box-shadow:0 0 14px rgba(255,77,79,0.8); pointer-events:none; z-index:501;
  192. `;
  193. const cont = document.getElementById(containerId);
  194. if (cont) cont.appendChild(floatingDot);
  195. }
  196. if (floatingDot) floatingDot.style.display = '';
  197. console.log(`[布点] 进入放置模式: ${device.name}`);
  198. }
  199. // 退出放置模式
  200. function exitPlacingMode() {
  201. placingMode.value = false;
  202. placingDevice.value = null;
  203. if (floatingDot) floatingDot.style.display = 'none';
  204. }
  205. // 放置 marker
  206. function placeMarker(device: DeviceInfo, location: { x: number; y: number }) {
  207. if (!viewer) return;
  208. markerIdCounter++;
  209. const markerId = `m${markerIdCounter}`;
  210. // 动态半径:基于当前视口大小
  211. const cam = viewer.camera as THREE.OrthographicCamera;
  212. const frustumH = cam.top - cam.bottom;
  213. const radius = frustumH * 0.008;
  214. // THREE.Mesh 实心圆点 — 绘制在场景中,随模型移动
  215. const geom = new THREE.CircleGeometry(100, 32);
  216. const mat = new THREE.MeshBasicMaterial({
  217. color: 0xff4d4f,
  218. side: THREE.DoubleSide,
  219. depthTest: false,
  220. depthWrite: false,
  221. });
  222. const mesh = new THREE.Mesh(geom, mat);
  223. mesh.position.set(location.x, location.y, 0);
  224. mesh.renderOrder = 999;
  225. mesh.matrixAutoUpdate = true; // 覆盖 scene.matrixAutoUpdate=false
  226. mesh.updateMatrixWorld(); // 立即计算世界矩阵
  227. mesh.userData = { markerId, deviceId: device.id };
  228. viewer.scene.add(mesh);
  229. allMeshes.push(mesh);
  230. // 精灵图标(safetymonitor3D.png)
  231. const tex = textureLoader.load('/texture/safetymonitor3D.png');
  232. const spriteMat = new THREE.SpriteMaterial({ map: tex, depthTest: false, depthWrite: false, transparent: true });
  233. const icon = new THREE.Sprite(spriteMat);
  234. icon.position.set(location.x, location.y, 0);
  235. const iconSize = frustumH * 0.015;
  236. icon.scale.set(iconSize * 200, iconSize * 200, 1);
  237. icon.renderOrder = 1000;
  238. icon.matrixAutoUpdate = true;
  239. icon.updateMatrixWorld();
  240. viewer.scene.add(icon);
  241. allIconSprites.push(icon);
  242. // CSS2DObject 风速标签(图标上方)
  243. const labelDiv = document.createElement('div');
  244. labelDiv.textContent = `${+(Math.random() * 3 + 1.5).toFixed(1)} m/s`;
  245. labelDiv.style.cssText =
  246. 'color:#00ffcc;font-size:22px;font-weight:bold;background:rgba(0,0,0,0.7);padding:2px 6px;border-radius:3px;white-space:nowrap;';
  247. const cssLabel = new CSS2DObject(labelDiv);
  248. cssLabel.position.set(location.x, location.y + 150, 0.1);
  249. cssLabel.matrixAutoUpdate = true;
  250. cssLabel.updateMatrixWorld();
  251. viewer.scene.add(cssLabel);
  252. allCssLabels.push(cssLabel);
  253. markersMap.set(markerId, {
  254. deviceId: device.id,
  255. worldPos: { x: location.x, y: location.y },
  256. mesh,
  257. iconSprite: icon,
  258. cssLabel,
  259. });
  260. device.markerId = markerId;
  261. console.log(`[布点] 设备=${device.name} X=${location.x.toFixed(2)} Y=${location.y.toFixed(2)} r=${radius.toFixed(2)}`);
  262. exitPlacingMode();
  263. }
  264. // 移除 marker
  265. function removeMarker(markerId: string) {
  266. if (!viewer) return;
  267. const m = markersMap.get(markerId);
  268. if (!m) return;
  269. const dev = devices.value.find((d) => d.markerId === markerId);
  270. if (dev) dev.markerId = null;
  271. viewer.scene.remove(m.mesh);
  272. m.mesh.geometry.dispose();
  273. (m.mesh.material as THREE.Material).dispose();
  274. viewer.scene.remove(m.iconSprite);
  275. (m.iconSprite.material as THREE.SpriteMaterial).map?.dispose();
  276. m.iconSprite.material.dispose();
  277. const ci = allCssLabels.indexOf(m.cssLabel);
  278. if (ci > -1) allCssLabels.splice(ci, 1);
  279. const idx = allMeshes.indexOf(m.mesh);
  280. if (idx > -1) allMeshes.splice(idx, 1);
  281. const si = allIconSprites.indexOf(m.iconSprite);
  282. if (si > -1) allIconSprites.splice(si, 1);
  283. markersMap.delete(markerId);
  284. }
  285. let infoPanel: HTMLElement | null = null;
  286. // 显示 marker 信息面板
  287. function showMarkerInfo(markerId: string) {
  288. const m = markersMap.get(markerId);
  289. if (!m || !viewer) return;
  290. const data = getMockMonitoring(m.deviceId);
  291. const container = document.getElementById(containerId);
  292. if (!container) return;
  293. // 移除旧 panel
  294. infoPanel?.remove();
  295. // Marker 世界坐标 → 屏幕坐标
  296. const sp = CoordinateUtils.world2Screen(new THREE.Vector3(m.worldPos.x, m.worldPos.y, 0), viewer.camera, container);
  297. infoPanel = document.createElement('div');
  298. infoPanel.style.cssText = `
  299. position:absolute; left:${sp.x + 16}px; top:${sp.y - 10}px;
  300. background:rgba(0,0,0,0.88); color:#fff; font-size:12px;
  301. border:1px solid rgba(255,255,255,0.2); border-radius:6px;
  302. padding:8px 10px; white-space:nowrap; z-index:600;
  303. pointer-events:auto; line-height:1.8;
  304. `;
  305. infoPanel.innerHTML = `
  306. <div style="display:flex;justify-content:space-between;margin-bottom:4px;">
  307. <b>${data.deviceName}</b>
  308. <span style="cursor:pointer;color:#aaa;margin-left:12px;" id="info-close">&times;</span>
  309. </div>
  310. <div>风速: ${data.windSpeed} m/s</div>
  311. <div>瓦斯浓度: ${data.gasConcentration}%</div>
  312. <div>温度: ${data.temperature}°C</div>
  313. <div>时间: ${data.timestamp}</div>
  314. <div>状态: <span style="color:${data.status === '正常' ? '#52c41a' : '#ff4d4f'}">${data.status}</span></div>
  315. `;
  316. container.appendChild(infoPanel);
  317. infoPanel.querySelector('#info-close')?.addEventListener('click', () => infoPanel?.remove());
  318. // 点击其他地方关闭
  319. const closeHandler = (e: MouseEvent) => {
  320. if (!infoPanel?.contains(e.target as Node)) {
  321. infoPanel?.remove();
  322. infoPanel = null;
  323. document.removeEventListener('click', closeHandler);
  324. }
  325. };
  326. setTimeout(() => document.addEventListener('click', closeHandler), 100);
  327. }
  328. // 点击 marker 事件
  329. function onMarkerClick(markerId: string) {
  330. console.log(`[标记点击] marker=${markerId}`);
  331. showMarkerInfo(markerId);
  332. }
  333. // ==================== 初始化 ====================
  334. onMounted(async () => {
  335. // 初始化 viewer
  336. try {
  337. viewer = new Viewer2d({
  338. containerId,
  339. enableSpinner: true,
  340. enableProgressBar: true,
  341. enableLayoutBar: true,
  342. });
  343. await viewer.setFont(fontFiles);
  344. await viewer.loadModel({ modelId: 'main-plan', name: '总平面布置图', src: dwgSrc }, (event: ProgressEvent) => {
  345. if (event.total > 0) console.log(`加载: ${Math.round((event.loaded / event.total) * 100)}%`);
  346. });
  347. viewer.goToHomeView();
  348. viewerReady.value = true;
  349. // initCss();
  350. console.log('[DWG] Viewer2d 就绪');
  351. // === 参照官方示例: ViewerEvent.MouseClick ===
  352. viewer.addEventListener(ViewerEvent.MouseClick, (data: any) => {
  353. if (!viewer) return;
  354. // 获取 world 坐标用于实时显示
  355. const container = document.getElementById(containerId);
  356. if (data?.event && container) {
  357. const sc = scaledScreenCoord(data.event, container);
  358. const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
  359. if (wp) {
  360. currentCoord.x = wp.x;
  361. currentCoord.y = wp.y;
  362. currentCoord.z = wp.z;
  363. }
  364. }
  365. // 检测是否点击了已有 marker mesh
  366. if (!placingMode.value && data?.event && container) {
  367. const sc2 = scaledScreenCoord(data.event, container);
  368. const hit = viewer.pickObject(sc2);
  369. if (hit?.object?.userData?.markerId) {
  370. onMarkerClick(hit.object.userData.markerId as string);
  371. return;
  372. }
  373. }
  374. // 点击空白背景 → 放置 marker
  375. if (!data?.entityData && !data?.markupData && !data?.measureData) {
  376. if (placingMode.value && placingDevice.value) {
  377. const location = getHitResult(data.event);
  378. if (location) placeMarker(placingDevice.value, location);
  379. }
  380. }
  381. viewer.renderer.render(viewer.scene, viewer.camera);
  382. viewer.getCssRender().render(viewer.scene, viewer.camera);
  383. css3DRenderer?.render(viewer.scene, viewer.camera);
  384. });
  385. // === 鼠标移动:实时更新坐标 + 浮动圆点跟随 ===
  386. const container = document.getElementById(containerId);
  387. if (container) {
  388. container.addEventListener('mousemove', (e: MouseEvent) => {
  389. if (!viewer) return;
  390. // 浮动圆点跟随鼠标(缩放校准后坐标,与 marker 放置位置一致)
  391. const sc = scaledScreenCoord(e, container);
  392. if (floatingDot && placingMode.value) {
  393. floatingDot.style.left = sc.x + 'px';
  394. floatingDot.style.top = sc.y + 'px';
  395. }
  396. // 世界坐标
  397. const wp = CoordinateUtils.screen2World(sc, viewer.camera, container);
  398. if (wp) {
  399. currentCoord.x = wp.x;
  400. currentCoord.y = wp.y;
  401. currentCoord.z = wp.z;
  402. }
  403. css3DRenderer?.render(viewer.scene, viewer.camera);
  404. });
  405. }
  406. // === Mesh 标记在场景坐标系中,随模型自动移动,无需 CameraChange 更新 ===
  407. } catch (err) {
  408. console.error('[DWG] 初始化失败:', err);
  409. }
  410. });
  411. /** 键盘事件处理:Esc 键退出放置模式 */
  412. const onKeydown = (e: KeyboardEvent) => {
  413. if (e.key === 'Escape' && placingMode.value) exitPlacingMode();
  414. };
  415. // 注册键盘事件监听
  416. onMounted(() => window.addEventListener('keydown', onKeydown));
  417. /** 组件卸载时清理:移除事件监听、销毁所有 marker、释放 viewer 资源 */
  418. onUnmounted(() => {
  419. window.removeEventListener('keydown', onKeydown);
  420. floatingDot?.remove();
  421. floatingDot = null;
  422. if (viewer) {
  423. allMeshes.forEach((m) => {
  424. viewer!.scene.remove(m);
  425. m.geometry.dispose();
  426. (m.material as THREE.Material).dispose();
  427. });
  428. allMeshes.length = 0;
  429. allIconSprites.forEach((s) => {
  430. viewer!.scene.remove(s);
  431. (s.material as THREE.SpriteMaterial).map?.dispose();
  432. s.material.dispose();
  433. });
  434. allIconSprites.length = 0;
  435. allCssLabels.forEach((l) => viewer!.scene?.remove(l));
  436. allCssLabels.length = 0;
  437. }
  438. markersMap.clear();
  439. if (viewer) {
  440. viewer.destroy?.();
  441. viewer = null;
  442. }
  443. });
  444. </script>
  445. <style lang="less">
  446. /* 参照官方示例 hotpoint 样式 */
  447. .hotpoint {
  448. opacity: 0.85;
  449. pointer-events: auto;
  450. }
  451. .hotpoint-dot {
  452. width: 16px;
  453. height: 16px;
  454. cursor: pointer;
  455. background: #ff4d4f;
  456. border: 2px solid #fff;
  457. border-radius: 50%;
  458. box-shadow: 0 0 10px rgba(255, 77, 79, 0.8);
  459. }
  460. .hotpoint-dot:hover {
  461. box-shadow: 0 0 16px rgba(255, 100, 100, 0.9);
  462. background: #ff6666;
  463. }
  464. .hotpoint-panel {
  465. position: absolute;
  466. top: 20px;
  467. left: -75px;
  468. min-width: 120px;
  469. background: rgba(0, 0, 0, 0.85);
  470. border: 1px solid rgba(255, 255, 255, 0.2);
  471. border-radius: 6px;
  472. color: #fff;
  473. font-size: 12px;
  474. }
  475. .hotpoint-close {
  476. text-align: right;
  477. padding: 2px 6px;
  478. cursor: pointer;
  479. color: #aaa;
  480. font-size: 14px;
  481. &:hover {
  482. color: #fff;
  483. }
  484. }
  485. .hotpoint-body {
  486. padding: 4px 8px 8px;
  487. min-height: 30px;
  488. white-space: nowrap;
  489. }
  490. .hide {
  491. display: none;
  492. }
  493. </style>
  494. <style lang="less" scoped>
  495. @import '/@/design/theme.less';
  496. .content {
  497. width: 100%;
  498. height: 100%;
  499. position: relative;
  500. overflow: hidden;
  501. }
  502. .viewer-area {
  503. width: 100%;
  504. height: 100%;
  505. position: relative;
  506. }
  507. .viewer-canvas {
  508. width: 100%;
  509. height: 100%;
  510. position: relative;
  511. }
  512. .coord-bar {
  513. position: absolute;
  514. bottom: 8px;
  515. left: 50%;
  516. transform: translateX(-50%);
  517. padding: 6px 16px;
  518. background: rgba(0, 0, 0, 0.78);
  519. border-radius: 6px;
  520. color: #fff;
  521. font-size: 13px;
  522. font-family: Consolas, Monaco, monospace;
  523. z-index: 200;
  524. pointer-events: none;
  525. white-space: nowrap;
  526. > span {
  527. margin: 0 4px;
  528. }
  529. .placing-hint {
  530. color: #faad14;
  531. }
  532. }
  533. .device-panel {
  534. position: absolute;
  535. top: 8px;
  536. right: 8px;
  537. width: 300px;
  538. z-index: 300;
  539. background: var(--vent-device-manager-box-bg, rgba(20, 20, 40, 0.94));
  540. border: 1px solid var(--vent-device-manager-box-border, rgba(255, 255, 255, 0.15));
  541. border-radius: 8px;
  542. overflow: hidden;
  543. &.collapsed {
  544. width: auto;
  545. }
  546. .panel-header {
  547. display: flex;
  548. justify-content: space-between;
  549. align-items: center;
  550. padding: 8px 12px;
  551. cursor: pointer;
  552. border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  553. .panel-title {
  554. font-size: 14px;
  555. font-weight: 600;
  556. color: #e0e0e0;
  557. }
  558. .panel-toggle {
  559. font-size: 12px;
  560. color: rgba(255, 255, 255, 0.5);
  561. }
  562. }
  563. .panel-body {
  564. padding: 6px;
  565. }
  566. }
  567. .monitor-data {
  568. .monitor-row {
  569. display: flex;
  570. padding: 8px 0;
  571. border-bottom: 1px solid #f0f0f0;
  572. font-size: 14px;
  573. }
  574. .monitor-row:last-child {
  575. border-bottom: none;
  576. }
  577. .monitor-key {
  578. width: 72px;
  579. color: #888;
  580. flex-shrink: 0;
  581. }
  582. }
  583. </style>