IntersectionMapVideos.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. <template>
  2. <div class="map-wrapper" ref="wrapper">
  3. <div class="konva-container" ref="konvaContainer"></div>
  4. <div class="corner-videos-overlay" v-if="hasAnyVideo" :style="{ width: stageWidth + 'px', height: stageHeight + 'px' }">
  5. <div v-if="videoUrls.nw" class="video-corner top-left">
  6. <template v-if="activeVideos.nw">
  7. <XgVideoPlayer :src="videoUrls.nw" :autoplay="true" />
  8. <div class="close-btn" title="关闭视频" @click="closeVideo('nw')">✕</div>
  9. </template>
  10. <div v-else class="empty-state" @click="openVideo('nw')" title="点击关联视频">
  11. <span class="empty-tag">关联视频</span>
  12. <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
  13. </div>
  14. </div>
  15. <div v-if="videoUrls.ne" class="video-corner top-right">
  16. <template v-if="activeVideos.ne">
  17. <XgVideoPlayer :src="videoUrls.ne" :autoplay="true" />
  18. <div class="close-btn" title="关闭视频" @click="closeVideo('ne')">✕</div>
  19. </template>
  20. <div v-else class="empty-state" @click="openVideo('ne')" title="点击关联视频">
  21. <span class="empty-tag">关联视频</span>
  22. <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
  23. </div>
  24. </div>
  25. <div v-if="videoUrls.sw" class="video-corner bottom-left">
  26. <template v-if="activeVideos.sw">
  27. <XgVideoPlayer :src="videoUrls.sw" :autoplay="true" />
  28. <div class="close-btn" title="关闭视频" @click="closeVideo('sw')">✕</div>
  29. </template>
  30. <div v-else class="empty-state" @click="openVideo('sw')" title="点击关联视频">
  31. <span class="empty-tag">关联视频</span>
  32. <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
  33. </div>
  34. </div>
  35. <div v-if="videoUrls.se" class="video-corner bottom-right">
  36. <template v-if="activeVideos.se">
  37. <XgVideoPlayer :src="videoUrls.se" :autoplay="true" />
  38. <div class="close-btn" title="关闭视频" @click="closeVideo('se')">✕</div>
  39. </template>
  40. <div v-else class="empty-state" @click="openVideo('se')" title="点击关联视频">
  41. <span class="empty-tag">关联视频</span>
  42. <img :src="require('@/assets/images/camera.png')" alt="camera" class="camera-image" />
  43. </div>
  44. </div>
  45. </div>
  46. </div>
  47. </template>
  48. <script>
  49. import Konva from 'konva';
  50. import XgVideoPlayer from '@/components/ui/XgVideoPlayer.vue';
  51. // 方向箭头 SVG 路径映射
  52. // 所有方向臂共用同一套图标(以N方向/向下驶入为基准),由 createRoadArm 的 rotation 自动旋转
  53. // R(右转)用左转图标水平翻转
  54. const arrowSvgMap = {
  55. S: require('@/assets/images/svg/icon_straight_down.svg'),
  56. L: require('@/assets/images/svg/icon_turn_down_left.svg'),
  57. U: require('@/assets/images/svg/icon_turn_down_left_uturn.svg'),
  58. R: require('@/assets/images/svg/icon_turn_down_left.svg'), // 右转用左转图标,渲染时水平翻转
  59. };
  60. // SVG 原始文本映射(内联,避免 webpack loader 问题)
  61. const svgRawCache = {};
  62. // 首次加载时通过 Image → Canvas 获取不到 SVG 内容,所以直接用内联方式
  63. // 从 require 得到的 URL(可能是 base64 或路径)加载图片
  64. const imgCache = {};
  65. function loadSvgImage(svgUrl, fillColor) {
  66. const cacheKey = svgUrl + '|' + fillColor;
  67. if (imgCache[cacheKey]) return Promise.resolve(imgCache[cacheKey]);
  68. // 如果是 base64 data URL,解码后替换颜色
  69. if (svgUrl.startsWith('data:image/svg+xml;base64,')) {
  70. const base64 = svgUrl.split(',')[1];
  71. let svgText = atob(base64);
  72. svgText = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
  73. const encoded = btoa(svgText);
  74. const dataUrl = 'data:image/svg+xml;base64,' + encoded;
  75. return new Promise(resolve => {
  76. const img = new Image();
  77. img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
  78. img.src = dataUrl;
  79. });
  80. }
  81. // 普通 URL,fetch 后替换颜色
  82. return fetch(svgUrl)
  83. .then(r => r.text())
  84. .then(svgText => {
  85. const colored = svgText.replace(/fill="#[0-9a-fA-F]{3,8}"/g, `fill="${fillColor}"`);
  86. const blob = new Blob([colored], { type: 'image/svg+xml' });
  87. const url = URL.createObjectURL(blob);
  88. return new Promise(resolve => {
  89. const img = new Image();
  90. img.onload = () => { imgCache[cacheKey] = img; resolve(img); };
  91. img.src = url;
  92. });
  93. });
  94. }
  95. export default {
  96. name: 'IntersectionMapVideos',
  97. components: {
  98. XgVideoPlayer
  99. },
  100. props: {
  101. // 1. 路口数字孪生数据
  102. mapData: {
  103. type: Object,
  104. required: true
  105. },
  106. // 2. 【新增】:四路视频地址配置
  107. videoUrls: {
  108. type: Object,
  109. default: () => ({
  110. nw: '', ne: '', sw: '', se: ''
  111. })
  112. }
  113. },
  114. data() {
  115. return {
  116. stage: null,
  117. layer: null,
  118. armsNodes: {},
  119. panelNodes: {},
  120. resizeObserver: null,
  121. C: {
  122. BG: '#212842', ROAD: '#3d3938', YELLOW: '#D9A73D', WHITE: '#E0E0E0',
  123. SIGNAL_RED: '#FF5252', SIGNAL_GREEN: '#8DF582',
  124. PANEL_BG: 'rgba(30, 30, 40, 0.85)', BLUE: '#448AFF'
  125. },
  126. sizeConfig: {
  127. stageSize: 900,
  128. laneWidth: 40,
  129. halfRoad: 160,
  130. roadWidth: 320,
  131. armLength: 350
  132. },
  133. stageWidth: 900, // 当前画布缩放后的真实宽度
  134. stageHeight: 900, // 当前画布缩放后的真实高度
  135. activeVideos: { nw: false, ne: false, sw: false, se: false },
  136. };
  137. },
  138. computed: {
  139. // 判断是否传入了至少一个视频,如果没有,直接不渲染遮罩层提升性能
  140. hasAnyVideo() {
  141. if (!this.videoUrls) return false;
  142. return ['nw', 'ne', 'sw', 'se'].some(corner => {
  143. const v = this.videoUrls[corner];
  144. return v && (typeof v === 'string' ? v : v.url);
  145. });
  146. }
  147. },
  148. mounted() {
  149. this.initKonvaStage();
  150. if (this.mapData && Object.keys(this.mapData).length > 0) {
  151. this.renderStaticConfig();
  152. this.updateDynamicSignals();
  153. }
  154. this.initResizeObserver();
  155. },
  156. beforeDestroy() {
  157. if (this.resizeObserver) this.resizeObserver.disconnect();
  158. if (this.stage) this.stage.destroy();
  159. },
  160. watch: {
  161. mapData: {
  162. handler(newData, oldData) {
  163. if (!newData) return;
  164. if (!oldData || JSON.stringify(newData.armsConfig) !== JSON.stringify(oldData.armsConfig)) {
  165. this.renderStaticConfig();
  166. }
  167. this.updateDynamicSignals();
  168. },
  169. deep: true
  170. }
  171. },
  172. methods: {
  173. openVideo(corner) {
  174. this.$set(this.activeVideos, corner, true);
  175. },
  176. closeVideo(corner) {
  177. this.$set(this.activeVideos, corner, false);
  178. },
  179. // ================= 以下为原有的 Konva 绘制逻辑,完全保持不变 =================
  180. initKonvaStage() {
  181. const { stageSize, halfRoad, roadWidth } = this.sizeConfig;
  182. const center = stageSize / 2;
  183. this.stage = new Konva.Stage({
  184. container: this.$refs.konvaContainer,
  185. width: stageSize,
  186. height: stageSize
  187. });
  188. this.layer = new Konva.Layer();
  189. this.stage.add(this.layer);
  190. this.layer.add(new Konva.Rect({ width: stageSize, height: stageSize, fill: this.C.BG }));
  191. this.layer.add(new Konva.Rect({ x: center - halfRoad, y: center - halfRoad, width: roadWidth, height: roadWidth, fill: this.C.ROAD }));
  192. this.armsNodes = {
  193. N: this.createRoadArm(center, center - halfRoad, 0),
  194. E: this.createRoadArm(center + halfRoad, center, 90),
  195. S: this.createRoadArm(center, center + halfRoad, 180),
  196. W: this.createRoadArm(center - halfRoad, center, 270)
  197. };
  198. Object.values(this.armsNodes).forEach(arm => this.layer.add(arm));
  199. this.createCenterPanel(center);
  200. this.layer.draw();
  201. },
  202. initResizeObserver() {
  203. this.resizeObserver = new ResizeObserver(() => {
  204. window.requestAnimationFrame(() => {
  205. this.handleResize();
  206. });
  207. });
  208. if (this.$refs.wrapper) {
  209. this.resizeObserver.observe(this.$refs.wrapper);
  210. }
  211. },
  212. handleResize() {
  213. if (!this.stage || !this.$refs.wrapper) return;
  214. const containerWidth = this.$refs.wrapper.clientWidth;
  215. const containerHeight = this.$refs.wrapper.clientHeight;
  216. if (containerWidth === 0 || containerHeight === 0) return;
  217. const scaleX = containerWidth / this.sizeConfig.stageSize;
  218. const scaleY = containerHeight / this.sizeConfig.stageSize;
  219. const scale = Math.min(scaleX, scaleY);
  220. // 【核心修改】:记录缩放后的实际物理尺寸,供视频遮罩层使用
  221. this.stageWidth = this.sizeConfig.stageSize * scale;
  222. this.stageHeight = this.sizeConfig.stageSize * scale;
  223. this.stage.width(this.stageWidth);
  224. this.stage.height(this.stageHeight);
  225. this.stage.scale({ x: scale, y: scale });
  226. },
  227. createRoadArm(x, y, rotation) {
  228. const { halfRoad, roadWidth, laneWidth } = this.sizeConfig;
  229. const group = new Konva.Group({ x, y, rotation });
  230. group.add(new Konva.Rect({ x: -halfRoad, y: -350, width: roadWidth, height: 350, fill: this.C.ROAD }));
  231. group.add(new Konva.Line({ points: [0, -350, 0, -35], stroke: this.C.YELLOW, strokeWidth: 3 }));
  232. group.add(new Konva.Path({ data: `M -160 -350 L -160 -30 Q -160 0 -180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
  233. group.add(new Konva.Path({ data: `M 160 -350 L 160 -30 Q 160 0 180 0`, stroke: this.C.YELLOW, strokeWidth: 3 }));
  234. group.add(new Konva.Line({ points: [-160, -35, 0, -35], stroke: this.C.WHITE, strokeWidth: 4 }));
  235. for (let i = 1; i < 4; i++) {
  236. let ox = i * laneWidth;
  237. group.add(new Konva.Line({ points: [-ox, -35, -ox, -120], stroke: this.C.WHITE, strokeWidth: 2 }));
  238. group.add(new Konva.Line({ points: [-ox, -120, -ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
  239. group.add(new Konva.Line({ points: [ox, -35, ox, -350], stroke: this.C.WHITE, strokeWidth: 2, dash: [15, 15] }));
  240. }
  241. const lightGroup = new Konva.Group();
  242. const rectOpts = { y: -16, width: 8, height: 24, cornerRadius: 2, offsetX: 4, offsetY: 12 };
  243. for (let lx = -148; lx <= -20; lx += 16) lightGroup.add(new Konva.Rect({ x: lx, ...rectOpts }));
  244. for (let rx = 20; rx <= 148; rx += 16) lightGroup.add(new Konva.Rect({ x: rx, ...rectOpts }));
  245. group.add(lightGroup);
  246. group.lightGroup = lightGroup;
  247. group.arrowNodes = { 0: null, 1: null, 2: null, 3: null };
  248. group.cameraNode = null;
  249. return group;
  250. },
  251. createCenterPanel(center) {
  252. const panelGroup = new Konva.Group({ x: center - 80, y: center - 45 });
  253. panelGroup.add(new Konva.Rect({ width: 160, height: 90, fill: this.C.PANEL_BG, cornerRadius: 8 }));
  254. const labelFont = { fontSize: 18, fontFamily: 'monospace', fontStyle: 'bold', fill: this.C.WHITE };
  255. const valueFont = { fontSize: 28, fontFamily: 'monospace', fontStyle: 'bold' };
  256. this.panelNodes.nsLabel = new Konva.Text({ ...labelFont, x: 15, y: 22, text: '相位-:' });
  257. this.panelNodes.nsVal = new Konva.Text({ ...valueFont, x: 90, y: 15, text: '--', fill: this.C.SIGNAL_GREEN });
  258. this.panelNodes.ewLabel = new Konva.Text({ ...labelFont, x: 15, y: 55, text: '相位-:' });
  259. this.panelNodes.ewVal = new Konva.Text({ ...valueFont, x: 90, y: 48, text: '--', fill: this.C.SIGNAL_GREEN });
  260. panelGroup.add(this.panelNodes.nsLabel, this.panelNodes.nsVal, this.panelNodes.ewLabel, this.panelNodes.ewVal);
  261. this.layer.add(panelGroup);
  262. },
  263. createArrowIcon(type, x, y, color = this.C.WHITE) {
  264. const maxH = 40; // 最大高度
  265. const group = new Konva.Group({ x, y });
  266. if (type === 'R') group.scaleX(-1);
  267. group._arrowMeta = { type };
  268. const svgUrl = arrowSvgMap[type];
  269. if (svgUrl) {
  270. loadSvgImage(svgUrl, color).then(imgObj => {
  271. // 按原始比例等比缩放,高度不超过 maxH
  272. const natW = imgObj.naturalWidth || imgObj.width;
  273. const natH = imgObj.naturalHeight || imgObj.height;
  274. const scale = Math.min(maxH / natH, 1);
  275. const w = Math.round(natW * scale);
  276. const h = Math.round(natH * scale);
  277. const konvaImg = new Konva.Image({
  278. image: imgObj,
  279. x: -w / 2,
  280. y: -h - 5,
  281. width: w,
  282. height: h,
  283. name: 'arrowImg'
  284. });
  285. group.add(konvaImg);
  286. if (this.layer) this.layer.draw();
  287. });
  288. }
  289. return group;
  290. },
  291. createCameraIcon(type, x, y) {
  292. const group = new Konva.Group({ x, y });
  293. if (type === 1) {
  294. group.add(new Konva.Line({ points: [-16, -30, 16, -30], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
  295. group.add(new Konva.Line({ points: [0, -30, 0, -10], stroke: this.C.BLUE, strokeWidth: 2.5 }));
  296. const body = new Konva.Group({ y: -10, rotation: 15 });
  297. body.add(new Konva.Rect({ x: -16, y: -8, width: 32, height: 16, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 2 }));
  298. body.add(new Konva.Rect({ x: 16, y: -4, width: 6, height: 8, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 1 }));
  299. group.add(body);
  300. } else if (type === 2) {
  301. group.add(new Konva.Rect({ x: -14, y: -24, width: 28, height: 24, stroke: this.C.BLUE, strokeWidth: 2.5, cornerRadius: 6 }));
  302. group.add(new Konva.Circle({ x: 0, y: -12, radius: 5, stroke: this.C.BLUE, strokeWidth: 2.5 }));
  303. group.add(new Konva.Line({ points: [-10, 0, 10, 0], stroke: this.C.BLUE, strokeWidth: 2.5, lineCap: 'round' }));
  304. group.add(new Konva.Line({ points: [0, 0, 0, 6], stroke: this.C.BLUE, strokeWidth: 2.5 }));
  305. }
  306. return group;
  307. },
  308. renderStaticConfig() {
  309. const config = this.mapData.armsConfig;
  310. if (!config) return;
  311. Object.keys(config).forEach(dir => {
  312. const armData = config[dir];
  313. const armNode = this.armsNodes[dir];
  314. if (armNode.cameraNode) armNode.cameraNode.destroy();
  315. if (armData.cameraType > 0) {
  316. const cam = this.createCameraIcon(armData.cameraType, -80, -190);
  317. armNode.add(cam);
  318. armNode.cameraNode = cam;
  319. }
  320. armData.lanes.forEach((type, index) => {
  321. if (armNode.arrowNodes[index]) armNode.arrowNodes[index].destroy();
  322. if (type) {
  323. const lx = -20 - (index * this.sizeConfig.laneWidth);
  324. const arrow = this.createArrowIcon(type, lx, -80, this.C.WHITE);
  325. armNode.add(arrow);
  326. armNode.arrowNodes[index] = arrow;
  327. }
  328. });
  329. });
  330. this.layer.draw();
  331. },
  332. updateDynamicSignals() {
  333. const signals = this.mapData.signals;
  334. if (!signals) return;
  335. const config = this.mapData.armsConfig || {};
  336. const nsColor = signals.ns.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
  337. const ewColor = signals.ew.isGreen ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
  338. const nsActiveTypes = signals.ns.activeArrowTypes || [];
  339. const ewActiveTypes = signals.ew.activeArrowTypes || [];
  340. const dyeArm = (dir, armNode, pedColor, vehicleColor, activeTypes) => {
  341. // 灯带颜色(人行道信号)
  342. armNode.lightGroup.getChildren().forEach(r => r.fill(pedColor));
  343. // 箭头按 lane type 用不同颜色的 SVG 替换
  344. const lanes = (config[dir] && config[dir].lanes) || [];
  345. Object.keys(armNode.arrowNodes).forEach(index => {
  346. const arr = armNode.arrowNodes[index];
  347. if (!arr) return;
  348. const laneType = lanes[index];
  349. const isActive = activeTypes.length > 0 && activeTypes.includes(laneType);
  350. const targetColor = isActive ? this.C.SIGNAL_GREEN : this.C.SIGNAL_RED;
  351. const meta = arr._arrowMeta;
  352. if (!meta) return;
  353. const svgUrl = arrowSvgMap[meta.type];
  354. if (!svgUrl) return;
  355. loadSvgImage(svgUrl, targetColor).then(imgObj => {
  356. const existing = arr.findOne('.arrowImg');
  357. if (existing) {
  358. existing.image(imgObj);
  359. } else {
  360. const maxH = 40;
  361. const natW = imgObj.naturalWidth || imgObj.width;
  362. const natH = imgObj.naturalHeight || imgObj.height;
  363. const scale = Math.min(maxH / natH, 1);
  364. const w = Math.round(natW * scale);
  365. const h = Math.round(natH * scale);
  366. arr.add(new Konva.Image({
  367. image: imgObj,
  368. x: -w / 2,
  369. y: -h - 5,
  370. width: w,
  371. height: h,
  372. name: 'arrowImg'
  373. }));
  374. }
  375. if (this.layer) this.layer.draw();
  376. });
  377. });
  378. };
  379. // 灯带代表人行道:P1/P3绿灯期间正常(车绿人红、车红人绿),其余时段人行道全红
  380. const pedAllRed = signals.pedAllRed || false;
  381. const nsPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ns.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
  382. const ewPedColor = pedAllRed ? this.C.SIGNAL_RED : (signals.ew.isGreen ? this.C.SIGNAL_RED : this.C.SIGNAL_GREEN);
  383. dyeArm('N', this.armsNodes.N, nsPedColor, nsColor, nsActiveTypes);
  384. dyeArm('S', this.armsNodes.S, nsPedColor, nsColor, nsActiveTypes);
  385. dyeArm('E', this.armsNodes.E, ewPedColor, ewColor, ewActiveTypes);
  386. dyeArm('W', this.armsNodes.W, ewPedColor, ewColor, ewActiveTypes);
  387. this.panelNodes.nsLabel.text(`${signals.ns.phaseName}:`);
  388. this.panelNodes.nsVal.text(signals.ns.time.toString().padStart(2, '0')).fill(nsColor);
  389. this.panelNodes.ewLabel.text(`${signals.ew.phaseName}:`);
  390. this.panelNodes.ewVal.text(signals.ew.time.toString().padStart(2, '0')).fill(ewColor);
  391. this.layer.draw();
  392. }
  393. }
  394. };
  395. </script>
  396. <style scoped>
  397. /* ================= 地图外层 ================= */
  398. .map-wrapper {
  399. width: 100%;
  400. height: 100%;
  401. overflow: hidden;
  402. background-color: #212842;
  403. position: relative; /* 核心:让子元素能在其内部绝对定位 */
  404. }
  405. .konva-container {
  406. position: absolute;
  407. top: 50%;
  408. left: 50%;
  409. transform: translate(-50%, -50%);
  410. z-index: 1; /* 图层垫底 */
  411. }
  412. /* ================= 视频遮罩与挂件 ================= */
  413. .corner-videos-overlay {
  414. position: absolute;
  415. /* 【核心修改】:和 Canvas 一样,使用绝对居中对齐 */
  416. top: 50%;
  417. left: 50%;
  418. transform: translate(-50%, -50%);
  419. z-index: 10;
  420. pointer-events: none;
  421. border-radius: 2px;
  422. overflow: hidden;
  423. }
  424. .video-corner {
  425. position: absolute;
  426. /* (900-320)/2 / 900 = 32.222% */
  427. width: 32.222%;
  428. height: 32.222%;
  429. background: #000;
  430. pointer-events: auto;
  431. box-sizing: border-box;
  432. border: 1px solid rgba(68, 138, 255, 0.4);
  433. overflow: hidden;
  434. border-radius: 2px;
  435. }
  436. /* 四角贴死四个角 */
  437. .top-left { top: -1px; left: -1px; }
  438. .top-right { top: -1px; right: -1px; }
  439. .bottom-left { bottom: -1px; left: -1px; }
  440. .bottom-right { bottom: -1px; right: -1px; }
  441. /* ================= 关联视频空状态 & 关闭按钮 ================= */
  442. .close-btn {
  443. position: absolute;
  444. top: 4%;
  445. right: 4%;
  446. background: rgba(0, 0, 0, 0.6);
  447. color: #fff;
  448. width: 8%;
  449. height: 8%;
  450. min-width: 14px;
  451. min-height: 14px;
  452. border-radius: 50%;
  453. display: flex;
  454. align-items: center;
  455. justify-content: center;
  456. cursor: pointer;
  457. transition: background 0.3s;
  458. z-index: 10;
  459. font-size: clamp(8px, 4%, 14px);
  460. }
  461. .close-btn:hover { background: rgba(0, 0, 0, 1); }
  462. .empty-state {
  463. display: flex;
  464. flex-direction: column;
  465. align-items: center;
  466. justify-content: center;
  467. height: 100%;
  468. cursor: pointer;
  469. transition: all 0.3s ease;
  470. background: #112445;
  471. border-radius: 2px;
  472. overflow: hidden;
  473. }
  474. .empty-state:hover {
  475. background: rgba(68, 138, 255, 0.1);
  476. }
  477. .empty-state:hover .empty-tag {
  478. background: rgba(68, 138, 255, 1);
  479. box-shadow: 0 0 10px rgba(68, 138, 255, 0.5);
  480. }
  481. .empty-state:hover .camera-image {
  482. transform: scale(1.05);
  483. transition: transform 0.3s ease;
  484. }
  485. .empty-tag {
  486. background: rgba(68, 138, 255, 0.8);
  487. color: #fff;
  488. padding: 2% 6%;
  489. border-radius: 4px;
  490. font-size: clamp(8px, 5%, 14px);
  491. margin-bottom: 6%;
  492. letter-spacing: 1px;
  493. transition: all 0.3s ease;
  494. white-space: nowrap;
  495. }
  496. .camera-image {
  497. width: 30%;
  498. max-width: 50px;
  499. height: auto;
  500. object-fit: contain;
  501. opacity: 0.8;
  502. }
  503. /* xgplayer 填满角落容器 */
  504. .video-corner .xg-video-player {
  505. width: 100%;
  506. height: 100%;
  507. }
  508. .video-corner >>> .xgplayer {
  509. width: 100% !important;
  510. height: 100% !important;
  511. }
  512. .video-corner >>> .xgplayer video {
  513. object-fit: cover;
  514. }
  515. </style>