diff --git a/frontend/src/components/MapModal/components/NormalAddedSituation.vue b/frontend/src/components/MapModal/components/NormalAddedSituation.vue index 89747e55..59da2dcb 100644 --- a/frontend/src/components/MapModal/components/NormalAddedSituation.vue +++ b/frontend/src/components/MapModal/components/NormalAddedSituation.vue @@ -244,7 +244,7 @@ const getFtpOptions = async () => { // 刷新表格 const refreshTable = () => { - const year = searchParams.value.tm; + const year = searchParams.value.tm || new Date().getFullYear().toString(); const startDate = `${year}-01-01 00:00:00`; const endDate = `${year}-12-31 23:59:59`; @@ -298,7 +298,7 @@ watch( console.log('isActive', modelStore.filter.year); if (modelStore.filter.year) { searchParams.value.tm = modelStore.filter.year; - getFtpOptions(); + getFtpOptions(); } else { getYearList(); } diff --git a/frontend/src/components/gis/gisUtils.ts b/frontend/src/components/gis/gisUtils.ts index 1c59784c..57b79999 100644 --- a/frontend/src/components/gis/gisUtils.ts +++ b/frontend/src/components/gis/gisUtils.ts @@ -352,8 +352,6 @@ export const setMapLegendPos = ( const hasRight = rightList?.length > 0; const hasBottom = bottomList?.length > 0; - console.log('[图例] hasLeft:', hasLeft, 'hasBottom:', hasBottom, 'bottomRowHeight:', bottomRowHeight); - const w = `${offset}px`; const l = `${_theme === 'ly-8' ? (menuState ? 643 : 510) : offset}px`; let b = `0px`; diff --git a/frontend/src/components/gis/map.cesium.ts b/frontend/src/components/gis/map.cesium.ts index 90f2f555..41f94820 100644 --- a/frontend/src/components/gis/map.cesium.ts +++ b/frontend/src/components/gis/map.cesium.ts @@ -13,7 +13,6 @@ import { osgbLocation, type OSGBItem } from './osgbUtils'; - export class MapCesium implements MapInterface { private viewer: Cesium.Viewer | null = null; private _ready = false; @@ -78,12 +77,10 @@ export class MapCesium implements MapInterface { private _currentPitchMode: 'down' | 'flat' = 'down'; /** 漫游模式:强制锚点文字/图标开启深度测试,避免文字遮挡漫游模型 */ private _isRoaming = false; - /** 漫游已开始但模型尚未就绪(待 activateRoamingPopups 触发批量 popup) */ - private _pendingRoamingPopups = false; + /** 漫游中锚点地形预热的 rAF 句柄(hover 依赖 billboard 贴地才能被 drillPick 命中) */ + private _warmAnchorTerrainRafId: number | null = null; /** 重建批量 popup 时连续空候选次数(防御:避免瞬间过滤导致全屏 Popup 消失) */ private _emptyCandidateCount = 0; - /** 漫游批量模式空容器被动重建的节流时间戳 */ - private _lastRoamPassiveRebuild = 0; /** 碰撞检测防重入标志,避免级联触发导致重复执行 */ private _collisionBusy = false; /** 批量初始化锚点图层标志:flushPendingInitLayers 批量加载时合并碰撞检测,避免 O(N²) 全量重算 */ @@ -160,7 +157,7 @@ export class MapCesium implements MapInterface { async init(container: HTMLElement, _rectangle?: any): Promise { try { this.containerElement = container; - const token = 'bearer b734a443-2c8f-4f4a-8698-44828cc5f709'; + const token = 'bearer 1404a7e0-3bbf-4435-b864-6d71f509b29d'; this.viewer = new Cesium.Viewer(container, { animation: false, @@ -462,13 +459,6 @@ export class MapCesium implements MapInterface { const scene = this.viewer?.scene; if (!scene) return; - // 漫游批量模式下完全禁用 hover:所有锚点由批量 popup 固定展示,避免与漫游/模型交互冲突 - if (this.isBatchPopupMode && this._isRoaming) { - canvas.style.cursor = 'default'; - this.hidePopup(); - return; - } - const cameraHeight = this.getCurrentCameraHeight(); // 缩放门槛:中国视角以上不触发 hover if (cameraHeight > this.HOVER_POPUP_MAX_HEIGHT) { @@ -626,15 +616,8 @@ export class MapCesium implements MapInterface { this.refreshEntityPositionsWithTerrain(); if (this._isRoaming) { - if (this._pendingRoamingPopups) { - // 漫游模型尚未就绪(activateRoamingPopups 未触发),跳过提前开启批量 Popup - } else if (!this.isBatchPopupMode) { - // 漫游中:批量 popup 无条件开启(不受缩放阈值限制) - this.enableBatchPopupMode(true); - } else { - // 漫游中:无条件重建 - this.rebuildBatchPopupsIfNeeded(); - } + // 漫游中不启用固定批量 popup(只 hover 展示),保持批量模式关闭; + // 退出漫游后由下方 zoom 分支按缩放级别恢复 } else if (zoom >= this.BATCH_POPUP_MODE_ZOOM) { if (!this.isBatchPopupMode) { this.enableBatchPopupMode(); @@ -681,20 +664,69 @@ export class MapCesium implements MapInterface { setRoamDepthTest(enabled: boolean): void { this._isRoaming = enabled; this.applyPointDepthTest(this.getDepthTestValue()); - // 漫游开始时仅记录“待开启”标志,不在模型加载/相机飞到位前直接开启批量 popup - //(避免旧视角锚点 Popup 提前全部出现)。由 activateRoamingPopups() 在模型渲染就绪后真正开启; - // 退出漫游时清除标志,批量模式恢复由 moveEnd/postRender 的缩放逻辑接管。 - this._pendingRoamingPopups = enabled; + if (enabled) { + // 漫游中不启用固定批量 popup,只保留 hover 展示: + // 若进入漫游前批量模式已开启(缩放级别较高),先移除固定 popup + if (this.isBatchPopupMode) { + this.disableBatchPopupMode(); + } + } else { + // 退出漫游:取消锚点地形预热轮询,避免 rAF 泄漏;批量模式由 moveEnd/缩放逻辑恢复 + if (this._warmAnchorTerrainRafId !== null) { + cancelAnimationFrame(this._warmAnchorTerrainRafId); + this._warmAnchorTerrainRafId = null; + } + } } /** - * 漫游模型加载完成并渲染后,由外部(ThreeDRoamManager/面板)调用, - * 执行 enableBatchPopupMode(true),确保 Popup 与漫游模型同时出现。 + * 漫游模型加载完成并渲染后,由外部(ThreeDRoamManager/面板)调用。 + * 漫游中不再启用固定批量 popup,仅 hover 展示; + * 这里预热所有锚点位置的地形瓦片,保证 hover 的 drillPick 能命中贴地 billboard。 */ activateRoamingPopups(): void { - if (!this._isRoaming || !this._pendingRoamingPopups) return; - this._pendingRoamingPopups = false; - this.enableBatchPopupMode(true); + if (!this._isRoaming) return; + this.warmAnchorTerrainForHover(); + } + + /** + * 逐帧触发所有锚点位置的地形瓦片请求,直到全部解析(或超时封顶)。 + * billboard 的 dynamicPosition 依赖 globe.getHeight 贴地,瓦片未加载时锚点回退到椭球面(高度≈1), + * 图标沉到地形下导致 drillPick 无法命中 → hover 失效。 + */ + private warmAnchorTerrainForHover(): void { + if (!this.viewer) return; + if (this._warmAnchorTerrainRafId !== null) return; + let frames = 0; + const check = () => { + this._warmAnchorTerrainRafId = null; + if (!this.viewer || !this._isRoaming) return; + frames++; + // ~10s 封顶:避免个别锚点瓦片长时间不返回导致 rAF 空转 + if (frames >= 600 || this.areAllAnchorsTerrainResolved()) return; + this._warmAnchorTerrainRafId = requestAnimationFrame(check); + }; + this._warmAnchorTerrainRafId = requestAnimationFrame(check); + } + + /** 所有锚点位置的地形高度是否已解析(未解析时调用 getHeight 会顺带触发瓦片请求) */ + private areAllAnchorsTerrainResolved(): boolean { + const viewer = this.viewer; + const globe = viewer?.scene.globe; + if (!globe) return true; + if (this.pointLayerRegistry.size === 0) return true; + const time = viewer!.clock.currentTime; + for (const entities of this.pointLayerRegistry.values()) { + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (!entity?.position) continue; + const pos = entity.position.getValue(time); + if (!pos) continue; + const carto = Cesium.Cartographic.fromCartesian(pos); + if (globe.getHeight(carto) === undefined) return false; + } + } + return true; } /** 当前应使用的深度测试值:flat(平视/漫游)→ 0 启用深度测试让模型可遮挡;down(俯视)→ 禁用避免地形遮挡文字 */ @@ -789,6 +821,8 @@ export class MapCesium implements MapInterface { private isEntityInteractive(entity: Cesium.Entity): boolean { const e = entity as any; if (e._isWfs) return false; // WFS 河段实体走独立高亮逻辑,不参与锚点交互 + // 漫游模型(CZML 飞机/路径)不作为可交互锚点:避免 hover/点击把模型当锚点处理 + if (this._isRoaming && this.viewer?.trackedEntity === entity) return false; if (e._layerVisible === false) return false; if (e._legendVisible === false) return false; if (e._regionVisible === false) return false; @@ -1046,7 +1080,8 @@ export class MapCesium implements MapInterface { samplePoint.y = y; const picked = scene.pickPosition(samplePoint); if (!Cesium.defined(picked)) return true; - return Cesium.Cartesian3.distance(cameraPos, picked) + 10 >= posDist; + const pickedDist = Cesium.Cartesian3.distance(cameraPos, picked); + return pickedDist + 10 >= posDist; }; // 快速路径:图标中心仍可见 → 直接判定未遮挡(最常见情况,仅 1 次拾取) @@ -1244,8 +1279,8 @@ export class MapCesium implements MapInterface { ) return; - // 被地形遮挡的不展示 popup(漫游时跳过:模型上方锚点易被新加载的高精度地形误判为遮挡) - if (!this._isRoaming && this.isPositionBehindTerrain(worldPos)) return; + // 被地形/模型遮挡的不展示 popup(漫游时同样检测:锚点被遮挡 popup 一起消失) + if (this.isPositionBehindTerrain(worldPos)) return; // 模型之上的锚点不固定展示 popup,仅通过 hover 触发(漫游强制批量模式下不过滤,需固定展示) if (!this._isRoaming && this.isEntityOnModel(entity)) return; @@ -1414,6 +1449,10 @@ export class MapCesium implements MapInterface { /** 每一帧都在更新,不需要持久化,但清理时一并重置 */ private popupCollisionFrames = new WeakMap(); + /** 地形/模型遮挡迟滞计数:连续多帧判定被遮挡才隐藏,避免深度判定逐帧抖动导致 popup 闪烁 */ + private popupOcclusionFrames = new WeakMap(); + /** popup 被地形/模型遮挡后连续隐藏所需帧数 */ + private readonly POPUP_OCCLUSION_HIDE_FRAMES = 3; /** 每帧更新批量 popup 屏幕位置 + 碰撞检测(拖拽/缩放时流畅跟随) */ private updateBatchPopupPositions() { @@ -1430,15 +1469,6 @@ export class MapCesium implements MapInterface { } if (this.batchPopupItems.length === 0) { - // 漫游强制批量模式下容器为空:节流触发一次被动重建, - // 防止相机/模型就位后仍无 Popup(配合 rebuild 的空候选容忍,不会造成清空闪烁) - if (this._isRoaming) { - const now = performance.now(); - if (now - this._lastRoamPassiveRebuild > 1500) { - this._lastRoamPassiveRebuild = now; - this.rebuildBatchPopupsIfNeeded(); - } - } return; } @@ -1446,7 +1476,8 @@ export class MapCesium implements MapInterface { const viewW = canvas.clientWidth; const viewH = canvas.clientHeight; const padding = 48; - // 俯视模式下锚点图标始终置顶渲染(深度测试关闭),不会被地形遮挡,跳过遮挡检测省开销 + // 俯视模式下锚点图标始终置顶渲染(深度测试关闭),不会被地形遮挡,跳过遮挡检测省开销; + // 平视时深度测试开启,遮挡检测必须生效 const skipOcclusionCheck = this._currentPitchMode === 'down'; // 第一遍:更新位置、收集可见项的屏幕矩形(用缓存宽高,不依赖 DOM 测量) @@ -1513,19 +1544,30 @@ export class MapCesium implements MapInterface { continue; } - // 拖拽/平视时锚点被地形遮挡 → 隐藏 popup(与 rebuild 候选阶段的遮挡过滤一致); - // 漫游时跳过:模型上方锚点易被新加载的高精度地形误判为遮挡 + // 遮挡判定:拖拽/平视时锚点被地形/模型遮挡 → 隐藏 popup(与 rebuild 候选阶段的遮挡过滤一致)。 + // 连续多帧判定被遮挡才真正隐藏(迟滞),避免深度判定逐帧抖动导致 popup 闪烁。 + const x = cp.x; + const y = cp.y; + let popupBlocked = false; if ( !skipOcclusionCheck && - !this._isRoaming && this.isPositionBehindTerrain(item.worldPosition) ) { + popupBlocked = true; + } + if (popupBlocked) { + const prevOcc = this.popupOcclusionFrames.get(element) || 0; + const occCount = prevOcc > 0 ? prevOcc + 1 : 1; + this.popupOcclusionFrames.set(element, occCount); + if (occCount < this.POPUP_OCCLUSION_HIDE_FRAMES) { + continue; // 未达隐藏阈值:保留上一帧显示状态 + } element.style.display = 'none'; continue; } + // 未被遮挡:重置遮挡迟滞计数 + this.popupOcclusionFrames.delete(element); - const x = cp.x; - const y = cp.y; // 通过所有检查后才设为可见(与开头的惰性策略配合,避免全量闪一下) element.style.display = 'block'; element.style.left = `${x}px`; @@ -1654,6 +1696,7 @@ export class MapCesium implements MapInterface { }); this.batchPopupItems = newItems; this.popupCollisionFrames = new WeakMap(); + this.popupOcclusionFrames = new WeakMap(); // 无缝替换容器 if (oldContainer) oldContainer.remove(); this.batchPopupContainer = newContainer; @@ -1674,6 +1717,7 @@ export class MapCesium implements MapInterface { }); this.batchPopupItems = []; this.popupCollisionFrames = new WeakMap(); + this.popupOcclusionFrames = new WeakMap(); if (this.batchPopupContainer) { this.batchPopupContainer.remove(); this.batchPopupContainer = null; @@ -1720,6 +1764,12 @@ export class MapCesium implements MapInterface { this.hoverRafId = null; } + // 漫游锚点地形预热 rAF + if (this._warmAnchorTerrainRafId !== null) { + cancelAnimationFrame(this._warmAnchorTerrainRafId); + this._warmAnchorTerrainRafId = null; + } + // 相机事件监听 if (this.removePostRenderListener) { this.removePostRenderListener(); @@ -1789,6 +1839,7 @@ export class MapCesium implements MapInterface { // 重置所有状态变量(确保下次 init 时是干净状态) this.popupCollisionFrames = new WeakMap(); + this.popupOcclusionFrames = new WeakMap(); this.currentClipGeoJson = null; this.originalBgColor = null; this.skyBoxShown = true; @@ -2172,7 +2223,6 @@ export class MapCesium implements MapInterface { hasBaseLayer(layerKey: string): boolean { if (layerKey === 'BASEMAP-img') return !!this.imageryBaseLayer; const hasWfs = this.wfsLayerRegistry.has(layerKey); - console.log('[WFS 3D] hasBaseLayer key:', layerKey, '→', hasWfs); if (hasWfs) return true; return !!this.resolvePrimaryBaseLayerKey(layerKey); } @@ -3622,7 +3672,7 @@ export class MapCesium implements MapInterface { } }); - // ===== 阶段3:标签碰撞(标签 vs 所有图标 + 标签间碰撞) ===== + // ===== 阶段3:标签碰撞(标签 vs 所有已放置图标 + 标签间碰撞) ===== // 标签 vs 所有已放置图标:跨图层/跨优先级均检测,仅排除自己的图标 // 标签间碰撞:按优先级顺序放置,后放置的标签若与已放置的标签重叠则隐藏 const labelPlacedGrid = new Map< diff --git a/frontend/src/components/gis/map.class.ts b/frontend/src/components/gis/map.class.ts index 8d8f5b2a..fed76336 100644 --- a/frontend/src/components/gis/map.class.ts +++ b/frontend/src/components/gis/map.class.ts @@ -145,6 +145,10 @@ export class MapClass implements MapClassInterface { setRoamDepthTest(enabled: boolean): void { this.service.setRoamDepthTest?.(enabled); } + // 漫游模型就绪后触发批量 popup(仅 3D 有效,2D 直接忽略) + activateRoamingPopups(): void { + this.service.activateRoamingPopups?.(); + } // 销毁地图 destroy(): void { this.service.destroy(); diff --git a/frontend/src/components/gis/map.d.ts b/frontend/src/components/gis/map.d.ts index e874834c..0526c1f9 100644 --- a/frontend/src/components/gis/map.d.ts +++ b/frontend/src/components/gis/map.d.ts @@ -220,6 +220,13 @@ export interface MapInterface { */ setRoamDepthTest?(enabled: boolean): void; + /** + * 漫游模型就绪后调用(仅 3D 有效) + * 漫游中不启用固定批量 popup,仅 hover 展示;本方法预热所有锚点位置的地形瓦片, + * 保证 hover 的 drillPick 能命中贴地 billboard + */ + activateRoamingPopups?(): void; + /** * 长度量算 */ diff --git a/frontend/src/components/gis/map.ol.ts b/frontend/src/components/gis/map.ol.ts index 31048ba0..d3dceb50 100644 --- a/frontend/src/components/gis/map.ol.ts +++ b/frontend/src/components/gis/map.ol.ts @@ -53,6 +53,8 @@ const FULL_DISPLAY_NO_COLLISION_ZOOM = Number.POSITIVE_INFINITY; const POINT_FADE_DURATION = 300; // 锚点/文字碰撞显隐的淡入淡出时长(ms) // 标签文字上方的避让边距(px):防止下方锚点文字紧贴上方锚点图标底部,看着像重叠 const LABEL_ICON_TOP_CLEARANCE = 6; +// 栖息地锚点(sttpMap=FH) 联动 fh_qxd 河段高亮:锚点 idss 字段值暂未确定,先写死为 233 +const FH_ANCHOR_HIGHLIGHT_IDSS = 233; // 定义边界 [minX, minY, maxX, maxY] (Web Mercator 坐标) const BOUNDS_SW = [26.5, -9.99999999999929]; @@ -63,6 +65,8 @@ export class MapOl implements MapInterface { view: View | null = null; private layerRegistry: Map = new Map(); private iconLoadState = new Map(); + // 图标图片加载完成后缓存其原始尺寸,供 hover 命中框按实际图标大小计算 + private iconSizeCache = new Map(); private pointStyleCache = new Map(); private measureCanvas: HTMLCanvasElement | null = null; private measureTextCache = new Map< @@ -201,6 +205,7 @@ export class MapOl implements MapInterface { } this.popupManager.showPopup(undefined, undefined); this.popupManager.handleMapClick(evt.pixel, detectedFeature => { + console.log(detectedFeature); if (detectedFeature.values_.sttpMap == 'ylfb') { modelStore.ylfbModalVisible = true; modelStore.params = detectedFeature.values_; @@ -216,40 +221,68 @@ export class MapOl implements MapInterface { this.map.on('pointermove', evt => { // fh_qxd 河段悬停高亮 - let hitLayer: any = null; const hitFeature = this.map.forEachFeatureAtPixel( evt.pixel, - (feature, layer) => { - hitLayer = layer; - return feature; - }, + feature => feature, { hitTolerance: 0, layerFilter: layer => layer.get('key') === 'fh_qxd' } ); - // 高亮图层存在时,命中要素复制到高亮图层,未命中则清空 + // 命中河段或锚点时直接切换鼠标样式(与 demo 一致,popup 是节流回调不能依赖它) + const targetElement = this.map?.getTargetElement() as HTMLElement; + let anchorDetect: { + detectedFeature?: Feature; + isHitIcon: boolean; + coordinate?: number[]; + } = { isHitIcon: false }; + if (targetElement) { + anchorDetect = this.detectFeatureAtPixel(evt.pixel); + targetElement.style.cursor = + anchorDetect.isHitIcon || hitFeature ? 'pointer' : ''; + } + + // 第二种高亮方式:悬停栖息地锚点(sttpMap=FH)时,按其 idss 关联值联动高亮对应 fh_qxd 河段 + let anchorLinkedFeature: Feature | undefined; + const hoveredAnchor = anchorDetect.isHitIcon + ? anchorDetect.detectedFeature + : undefined; + // FH 锚点识别与 popup 的 getNormalizedPopupSttpMap 保持一致:优先用 sttpMap(_sttpMap/popupSttpMap) + const isFhAnchor = + hoveredAnchor && + (String( + hoveredAnchor.get('_sttpMap') || + hoveredAnchor.get('sttpMap') || + hoveredAnchor.get('popupSttpMap') || + '' + ).toUpperCase() === 'FH' || + String(hoveredAnchor.get('sttp') ?? '').toUpperCase() === 'FH' || + String(hoveredAnchor.get('sttpCode') ?? '').toUpperCase() === 'FH'); + if (isFhAnchor) { + // 模拟获取锚点的 idss 字段:实际数据暂未提供该字段,先注释掉真实读取,写死固定值 + // 后期字段确定后,只需把下面固定值替换为:hoveredAnchor.get('idss') + // const anchorIdss = hoveredAnchor.get('idss'); + const anchorIdss = FH_ANCHOR_HIGHLIGHT_IDSS; + + // 与直接悬停检测(forEachFeatureAtPixel 的 layerFilter)取同一图层来源,避免 registry 过期取不到图层 + const fhQxdLayer = this.map + ?.getLayers() + .getArray() + .find(layer => layer.get('key') === 'fh_qxd') as + | VectorLayer + | undefined; + anchorLinkedFeature = fhQxdLayer + ?.getSource() + ?.getFeatures() + .find(f => String(f.get('Id')) === String(anchorIdss)); + } + + // 高亮图层存在时,命中要素复制到高亮图层,未命中则清空(锚点联动高亮优先于直接悬停河段) if (this.wfsHighlightLayer) { const highlightSource = this.wfsHighlightLayer.getSource(); - if (hitFeature) { - // 根据命中图层的底色动态调整高亮颜色(统一加深) - const baseColor = hitLayer?.get('bjColor') || ''; - // 读取命中图层原始描边宽度,保持高亮只变颜色不加粗 - let highlightWidth = 5; - const layerStyle = hitLayer?.getStyle?.(); - if ( - layerStyle && - typeof layerStyle === 'object' && - !Array.isArray(layerStyle) - ) { - const strokeWidth = (layerStyle as Style) - .getStroke?.() - ?.getWidth?.(); - if (typeof strokeWidth === 'number' && strokeWidth > 0) { - highlightWidth = strokeWidth; - } - } + const highlightFeature = anchorLinkedFeature || hitFeature; + if (highlightFeature) { this.wfsHighlightLayer.setStyle( new Style({ stroke: new Stroke({ @@ -259,26 +292,19 @@ export class MapOl implements MapInterface { }) ); highlightSource?.clear(); - highlightSource?.addFeature(hitFeature); + highlightSource?.addFeature(highlightFeature); } else { highlightSource?.clear(); } } - // 命中河段或锚点时直接切换鼠标样式(与 demo 一致,popup 是节流回调不能依赖它) - const targetElement = this.map?.getTargetElement() as HTMLElement; - let isHitAnchor = false; - if (targetElement) { - isHitAnchor = this.detectFeatureAtPixel(evt.pixel).isHitIcon; - targetElement.style.cursor = - isHitAnchor || hitFeature ? 'pointer' : ''; - } - this.popupManager.handlePointerMove(evt.pixel, payload => { if (targetElement) { // 命中河段或 hover popup 命中锚点时显示 pointer 光标 targetElement.style.cursor = - isHitAnchor || payload.hoveredId || hitFeature ? 'pointer' : ''; + anchorDetect.isHitIcon || payload.hoveredId || hitFeature + ? 'pointer' + : ''; } // 该锚点已有固定展示(批量 popup)的 popup,跳过 hover popup,避免同一内容出现双份; @@ -463,6 +489,13 @@ export class MapOl implements MapInterface { return null; } + // 把图标原始尺寸写到要素上,供 PopupManager 按实际图标大小计算 hover 命中框 + const iconSize = this.iconSizeCache.get(iconUrl); + if (iconSize) { + feature.set('_iconWidth', iconSize.width); + feature.set('_iconHeight', iconSize.height); + } + const currentZoom: any = this.view ? this.view.getZoom() : 4.5; const cachedDensityVisible = feature.get('_densityVisible'); const densityVisible = @@ -1421,6 +1454,10 @@ export class MapOl implements MapInterface { const image = new Image(); image.crossOrigin = 'anonymous'; image.onload = () => { + this.iconSizeCache.set(iconUrl, { + width: image.naturalWidth || 0, + height: image.naturalHeight || 0 + }); this.iconLoadState.set(iconUrl, 'loaded'); this.requestRefreshPointLayerStyles(); }; @@ -1431,6 +1468,10 @@ export class MapOl implements MapInterface { image.src = iconUrl; if (image.complete && image.naturalWidth > 0) { + this.iconSizeCache.set(iconUrl, { + width: image.naturalWidth, + height: image.naturalHeight + }); this.iconLoadState.set(iconUrl, 'loaded'); return true; } diff --git a/frontend/src/components/gis/ol/popup-manager.ts b/frontend/src/components/gis/ol/popup-manager.ts index a52fc1c4..6385e615 100644 --- a/frontend/src/components/gis/ol/popup-manager.ts +++ b/frontend/src/components/gis/ol/popup-manager.ts @@ -136,6 +136,13 @@ export class PopupManager { }; } + // 图标命中检测默认只识别不透明像素,图标带透明留白时只有中心附近能命中; + // 这里按渲染缩放放大容差,保证鼠标放到图标任意位置都能命中(命中框再由 isPixelInsideIconArea 兜底) + const zoom = this.map?.getView().getZoom() ?? 4.5; + let dynamicScale = 0.7 + (zoom - 4.5) * 0.08; + dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); + const hitTolerance = Math.round(16 * dynamicScale); + this.map?.forEachFeatureAtPixel( pixel, (feature, layer) => { @@ -148,7 +155,7 @@ export class PopupManager { if (iconPixel) { const dx = pixel[0] - iconPixel[0]; const dy = pixel[1] - iconPixel[1]; - if (this.isPixelInsideIconArea(dx, dy)) { + if (this.isPixelInsideIconArea(dx, dy, detectedFeature)) { isHitIcon = true; } } @@ -158,29 +165,36 @@ export class PopupManager { } return false; }, - { hitTolerance: 0 } + { hitTolerance } ); return { detectedFeature, isHitIcon, coordinate }; } - // 备注:仅把图标本体区域作为 hover 命中范围,避免上方文字标签被误判为图标命中。 - private isPixelInsideIconArea(dx: number, dy: number): boolean { + // 备注:按图标实际渲染尺寸判断 hover 命中范围(图标锚点 [0.5, 0.5],中心即要素位置)。 + // 之前用固定小方框(横向 ±12px、纵向 -8/+12px),图标稍大或带透明留白时只有中心附近能命中; + // 现在按 createPointStyle 写回要素的 _iconWidth/_iconHeight 计算,未取到尺寸时用保守默认值。 + private isPixelInsideIconArea( + dx: number, + dy: number, + feature?: Feature + ): boolean { if (!this.map) return false; const zoom = this.map.getView().getZoom() ?? 4.5; let dynamicScale = 0.7 + (zoom - 4.5) * 0.08; dynamicScale = Math.max(0.5, Math.min(3.0, dynamicScale)); - const halfWidth = 12 * dynamicScale; - const topReach = 8 * dynamicScale; - const bottomReach = 12 * dynamicScale; + const iconWidth = (feature?.get('_iconWidth') as number) || 24; + const iconHeight = (feature?.get('_iconHeight') as number) || 28; + const halfWidth = (iconWidth * dynamicScale) / 2; + const halfHeight = (iconHeight * dynamicScale) / 2; return ( dx >= -halfWidth && dx <= halfWidth && - dy >= -topReach && - dy <= bottomReach + dy >= -halfHeight && + dy <= halfHeight ); } diff --git a/frontend/src/components/gis/osgbUtils.ts b/frontend/src/components/gis/osgbUtils.ts index 5f4abea8..b0f446bb 100644 --- a/frontend/src/components/gis/osgbUtils.ts +++ b/frontend/src/components/gis/osgbUtils.ts @@ -38,10 +38,49 @@ export interface OSGBItem { eventListener?: any; _loading?: boolean; _loadId?: number; + /** 已失败次数(含首次),超过上限后置 _loadFailed,不再自动重试 */ + _loadRetries?: number; + /** 失败次数超限标志:为 true 时不再自动加载,需用户手动开关重置 */ + _loadFailed?: boolean; /** 用户手动关闭开关标志:为 true 时不自动加载/显示,只做显隐切换 */ _switchOff?: boolean; } +// ==================== 加载保护(超时 / 失败上限) ==================== + +/** 单次 tileset.json 加载超时时间(ms),服务端挂起时避免 _loading 永远占用 */ +const OSGB_LOAD_TIMEOUT = 30000; +/** 允许的最大失败次数(含首次),超过后停止自动重试,需用户手动开关重置 */ +const OSGB_MAX_RETRIES = 3; + +/** 统一失败处理:计数 + 超过上限后置失败标记(停止自动重试) */ +const onLoadFail = (osgbObj: OSGBItem) => { + osgbObj._loading = false; + osgbObj._loadRetries = (osgbObj._loadRetries || 0) + 1; + if (osgbObj._loadRetries >= OSGB_MAX_RETRIES) { + osgbObj._loadFailed = true; + } +}; + +/** 给 promise 加超时,超时后 reject(底层挂起的请求由浏览器自行回收) */ +const withTimeout = (promise: Promise, ms: number): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`倾斜摄影加载超时(${ms}ms)`)), + ms + ); + promise.then( + value => { + clearTimeout(timer); + resolve(value); + }, + error => { + clearTimeout(timer); + reject(error); + } + ); + }); + // ==================== 内部状态(每个 OSGB 实例独立) ==================== interface OSGBInstanceState { @@ -100,6 +139,31 @@ const removeOSGB = (viewer: Cesium.Viewer, tileset: Cesium.Cesium3DTileset) => { viewer.scene.primitives.remove(tileset); }; +/** + * 按 URL 在场景中查找已加载且未销毁的 tileset(去重复用,匹配旧项目 getOSGB 逻辑) + * 同一个模型 URL 只发一次请求;返回 undefined 表示需要新建 + */ +const findOSGBByUrl = ( + viewer: Cesium.Viewer, + url: string +): Cesium.Cesium3DTileset | undefined => { + if (!viewer || viewer.isDestroyed()) return undefined; + const primitives = viewer.scene.primitives; + for (let i = 0; i < primitives.length; i++) { + const p = primitives.get(i); + if (!(p instanceof Cesium.Cesium3DTileset)) continue; + if (p.isDestroyed()) continue; + // 1.141: fromUrl 成功后 tileset._url 是字符串(resource.url),与传入 url 同源 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pUrl = (p as any)?._url; + if (!pUrl) continue; + const pUrlStr = String(pUrl); + // url 可能带 token 参数,双向包含判断兜底 + if (pUrlStr.includes(url) || url.includes(pUrlStr)) return p; + } + return undefined; +}; + // ==================== 裁切相关 ==================== /** @@ -317,6 +381,8 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { if (distance < 12000) { // 用户手动关闭了开关 → 不自动加载 if (osgbObj._switchOff) return; + // 失败次数超限 → 不再自动重试(需用户手动开关重置) + if (osgbObj._loadFailed) return; // 匹配旧代码逻辑:osgbObj.osgbtitles 同步设置为 nonClassificationTileset 防重入 // 新代码用 _loading + _loadId 实现同样的效果(因为 fromUrl 是异步的) @@ -325,14 +391,15 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { osgbObj._loadId = (osgbObj._loadId || 0) + 1; const loadId = osgbObj._loadId; - const url = osgbObj.url.includes('?') - ? osgbObj.url + `&token=${getToken()}` - : osgbObj.url + `?token=${getToken()}`; - Cesium.Cesium3DTileset.fromUrl(url, { - maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16, - show: true, - maximumMemoryUsage: 128 - }) + const url = buildUrl(osgbObj.url); + withTimeout( + Cesium.Cesium3DTileset.fromUrl(url, { + maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16, + show: true, + maximumMemoryUsage: 128 + }), + OSGB_LOAD_TIMEOUT + ) .then((tileset: Cesium.Cesium3DTileset) => { // 竞态保护:版本号变了(removeQxsy 被调用或新一轮加载开始)→ 销毁退出 if ( @@ -344,6 +411,7 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { return; } osgbObj._loading = false; + osgbObj._loadRetries = 0; if (!Cesium.defined(tileset)) return; tileset.maximumScreenSpaceError = 32; @@ -373,9 +441,8 @@ const dynamicSetVisible = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { addOSGB(viewer, tileset); osgbObj.osgbtitles = tileset; }) - .catch((e: Error) => { - osgbObj._loading = false; - console.log('dynamicSetVisible 加载倾斜摄影报错', e); + .catch(() => { + onLoadFail(osgbObj); }); } } else if (distance > 13000) { @@ -436,6 +503,23 @@ const dynamicClipTerrain = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { // ==================== 公开 API ==================== +/** GIS 服务反代前缀:服务端 18085 不返回 CORS 头,跨域 fetch 会被浏览器拦截(Network 红色)。 + * 将绝对地址改写为同源 /gis-map,由 vite/nginx 反代转发(对齐旧项目相对路径方案)。 + * tileset.json 内相对瓦片路径会基于改写后的 base URL 解析,因此瓦片请求同样走反代。 */ +const GIS_PROXY_PREFIX = '/gis-map'; +const GIS_ORIGIN_REGEX = /https?:\/\/211\.99\.26\.225:18085/i; +const toSameOriginUrl = (url: string): string => + url.replace(GIS_ORIGIN_REGEX, GIS_PROXY_PREFIX); + +const buildUrl = (url: string): string => { + // 跨域绝对地址 → 同源反代地址 + const u = toSameOriginUrl(url); + // 如果已包含 token,不再添加 + if (u.includes('token=')) return u; + const token = getToken(); + if (!token) return u; + return u.includes('?') ? `${u}&token=${token}` : `${u}?token=${token}`; +}; /** * 加载倾斜摄影 3D Tileset 模型 * @@ -448,6 +532,9 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { // 已加载则跳过 if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) return; + // 失败次数超限 → 不再自动重试(需用户手动开关重置) + if (osgbObj._loadFailed) return; + // 正在加载中也跳过(防止重复调用 fromUrl) if (osgbObj._loading) return; osgbObj._loading = true; @@ -456,15 +543,26 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { osgbObj._loadId = (osgbObj._loadId || 0) + 1; const loadId = osgbObj._loadId; - const url = osgbObj.url.includes('?') - ? osgbObj.url + `&token=${getToken()}` - : osgbObj.url + `?token=${getToken()}`; + const url = buildUrl(osgbObj.url); - Cesium.Cesium3DTileset.fromUrl(url, { - maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16, - show: true, - maximumMemoryUsage: 128 - }) + // 去重复用:场景中已有同 URL 的 tileset → 直接复用,不再发请求(旧项目 getOSGB 逻辑) + const existing = findOSGBByUrl(viewer, url); + if (existing) { + osgbObj.osgbtitles = existing; + osgbObj.cartesian3 = existing.boundingSphere?.center?.clone(); + osgbObj._loading = false; + osgbObj._loadRetries = 0; + return; + } + + withTimeout( + Cesium.Cesium3DTileset.fromUrl(url, { + maximumScreenSpaceError: Number(osgbObj?.accuracy) || 16, + show: true, + maximumMemoryUsage: 128 + }), + OSGB_LOAD_TIMEOUT + ) .then((tileset: Cesium.Cesium3DTileset) => { // 竞态保护:版本号变了 → 销毁退出 if ( @@ -478,6 +576,7 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { if (!Cesium.defined(tileset)) return; osgbObj._loading = false; + osgbObj._loadRetries = 0; tileset.maximumScreenSpaceError = 32; // height 向上偏移:模型几何自带高程,height 只是微调(如 44m) @@ -532,9 +631,8 @@ export const LoadOSGB = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { dynamicClipTerrain(viewer, osgbObj); }); }) - .catch((e: Error) => { - osgbObj._loading = false; - console.log('加载倾斜摄影报错', e); + .catch(() => { + onLoadFail(osgbObj); }); }; @@ -550,6 +648,9 @@ export const removeQxsy = (viewer: Cesium.Viewer, osgbObj: OSGBItem) => { osgbObj.clippingPlanes = undefined; osgbObj._loading = false; osgbObj._switchOff = undefined; + // 重置失败标记,重新进入后允许重新加载 + osgbObj._loadFailed = false; + osgbObj._loadRetries = 0; if (typeof osgbObj.eventListener === 'function') { osgbObj.eventListener(); @@ -589,6 +690,9 @@ export const osgbChangeClick = ( } if (checked) { + // 用户主动开启:重置失败标记,允许重新加载 + osgbObj._loadFailed = false; + osgbObj._loadRetries = 0; // 开关 ON:已有未销毁的 tileset → 直接显示;否则加载 if (osgbObj.osgbtitles && !osgbObj.osgbtitles.isDestroyed?.()) { osgbObj.osgbtitles.show = true; diff --git a/frontend/src/components/gis/roam/ThreeDRoamPanel.vue b/frontend/src/components/gis/roam/ThreeDRoamPanel.vue index a1328898..4f2e221e 100644 --- a/frontend/src/components/gis/roam/ThreeDRoamPanel.vue +++ b/frontend/src/components/gis/roam/ThreeDRoamPanel.vue @@ -211,6 +211,8 @@ async function initRoamManager(): Promise { currentProgress.value = seconds; } ); + // 漫游模型(CZML)加载并锁定第一人称相机后,触发批量 popup(漫游中不受缩放阈值限制) + MapClass.getInstance().activateRoamingPopups?.(); } // ==================== API Calls ==================== diff --git a/frontend/src/modules/map/application/map-orchestrator.ts b/frontend/src/modules/map/application/map-orchestrator.ts index d658cbf7..0ca5c4be 100644 --- a/frontend/src/modules/map/application/map-orchestrator.ts +++ b/frontend/src/modules/map/application/map-orchestrator.ts @@ -178,15 +178,38 @@ export const useMapOrchestrator = () => { } ); - // 同时等待两个 promise - const [{ layerConfig }, { legendOriginal, pageLegend }] = - await Promise.all([layerConfigPromise, legendConfigPromise]); + // 仅等待图层配置:配置就绪即可发起锚点数据加载。 + // 图例接口在后台就绪后再补设,避免图例慢时拖住所有锚点数据的加载。 + const { layerConfig } = await layerConfigPromise; if (pageLoadRequestId !== activePageLoadRequestId) { return; } - // 先设置图层数据(更新 checkedLayerKeys),再设置图例数据 + // 后台等待图例接口:就绪后补设图例数据,并重刷已勾选 pointMap 图层, + // 补齐“图例未就绪时锚点已先渲染”的情况下的按图例过滤显隐。 + void legendConfigPromise + .then(({ legendOriginal, pageLegend }) => { + if (pageLoadRequestId !== activePageLoadRequestId) return; + if (legendOriginal.length > 0) { + mapStore.setLegendData(legendOriginal, pageLegend); + mapViewStore.getCheckedLayerKeys().forEach(key => { + if (!key || key === '-') return; + const layerItem = mapStore.findLayerByKey( + mapStore.layerData, + key + ); + if (layerItem?.type === 'pointMap' && layerItem.url) { + mapStore.refreshPointLayerDisplayData(key); + } + }); + } + }) + .catch(() => { + // 图例接口失败不影响地图锚点加载 + }); + + // 先设置图层数据(更新 checkedLayerKeys);图例已由后台 legendConfigPromise 补设 if (layerConfig.length > 0) { hydroMenuDefaultCheckedKeys = new Set( mapConfigStore.extractCheckedLayerKeys(layerConfig) @@ -221,11 +244,6 @@ export const useMapOrchestrator = () => { } } - // 设置图例数据(此时 checkedLayerKeys 已正确设置) - if (legendOriginal.length > 0) { - mapStore.setLegendData(legendOriginal, pageLegend); - } - const activePageToken = mapStore.activatePageContext( pageKey, layerConfig @@ -425,10 +443,15 @@ export const useMapOrchestrator = () => { await initializeMapShell(options); bindBaseSelection(); bindZoomListener(options.getIsHydroMenu); + + // 备注:3D 视角飞入与页面图层/锚点加载并行触发,不再等全部锚点加载完成才飞入中国视角, + // 锚点数据在飞行途中增量上屏,显著缩短 3D 首屏等待。2D 下 flyToDefaultView 为空实现,并行无副作用。 + const flyToDefaultTask = mapClass.flyToDefaultView?.(); + await loadPage({ pageKey: options.pageKey, isInitialLoad: true }); - // 加载完边界线等动态图层后,再从地球视图飞入中国视角 - if (mapClass.flyToDefaultView) { - await mapClass.flyToDefaultView(); + + if (flyToDefaultTask) { + await flyToDefaultTask; } await syncZoomSensitiveState({ isHydroMenu: options.getIsHydroMenu(), diff --git a/frontend/src/store/modules/map.ts b/frontend/src/store/modules/map.ts index e9e558dd..4a9eac70 100644 --- a/frontend/src/store/modules/map.ts +++ b/frontend/src/store/modules/map.ts @@ -14,6 +14,7 @@ import { useMapConfigStore } from '@/modules/map/stores/map-config.store'; import { useMapDataStore } from '@/modules/map/stores/map-data.store'; import { useMapViewStore } from '@/modules/map/stores/map-view.store'; import request from '@/utils/request'; +import { getIconPath } from '@/utils/index'; const mapClass = MapClass.getInstance(); const ENG_POINT_LAYER_KEY = 'eng_point'; const ENG_ALARM_POINT_LAYER_KEY = 'eng_alarm_point'; @@ -209,6 +210,16 @@ export const useMapStore = defineStore('map', () => { cacheKey?: string; } >(); + // 备注:图例配置未就绪时到达的锚点原始数据暂存区,待图例就绪后统一归一化并上屏, + // 避免锚点响应先于图例配置到达时被归一化为空导致数据丢失。 + const pendingLegendRawLayerData = new Map< + string, + { + checked: boolean; + data: any[]; + cacheKey?: string; + } + >(); let loadSessionSeed = 0; let activePageRenderToken = 0; let activePageKey = ''; @@ -576,6 +587,70 @@ export const useMapStore = defineStore('map', () => { } }; + // 备注:图例配置就绪后,把暂存的锚点原始数据统一归一化并上屏, + // 修复"锚点响应先于图例配置到达"时数据被过滤为空的竞态。 + const flushPendingLegendLayerData = () => { + if ( + pendingLegendRawLayerData.size === 0 || + legendDataOriginal.value.length === 0 + ) { + return; + } + + pendingLegendRawLayerData.forEach((payload, key) => { + if (!shouldApplyToActivePage(key)) { + return; + } + + const layer = findLayerByKey(layerData.value, key); + if (!layer) { + return; + } + + const list = normalizePointLayerItems(payload.data); + pendingLegendRawLayerData.delete(key); + + if (list.length === 0) { + return; + } + + mapDataStore.setPointLayerCache(key, { + checked: payload.checked, + data: list, + cacheKey: payload.cacheKey + }); + if (shouldSyncMergedPointData(key)) { + syncPointDataForFilter(getRuntimeCheckedLayerKeys()); + } + layer.data = list; + applyLoadedPointLayerToMap(layer, key, list); + mapDataStore.setLayerLoaded(key); + }); + }; + + // 备注:图例配置就绪后预取锚点图标图片(new Image 预热), + // 锚点数据到达时图标已就绪,首屏一次出图,减少等待图标异步加载的空白期。 + const prefetchLegendPointIcons = () => { + const prefetched = new Set(); + const walk = (items: any[] = []) => { + items.forEach(item => { + if (item?.childrenList?.length > 0) { + walk(item.childrenList); + return; + } + const iconUrl = getIconPath(item?.icon || ''); + if (!iconUrl || prefetched.has(iconUrl)) { + return; + } + prefetched.add(iconUrl); + const image = new Image(); + image.crossOrigin = 'anonymous'; + image.src = iconUrl; + }); + }; + walk(legendDataOriginal.value); + }; + /** * 设置图例数据(过滤掉 name == '地图' 的项,ifShow == 0 时不显示) * @param data - 全部图例数据 @@ -588,6 +663,9 @@ export const useMapStore = defineStore('map', () => { buildLegendCheckedState(legendDataOriginal.value, normalizeLegendNameEn) ); rebuildLegendRuntimeData(checkedLayerKeys.value); + // 图例就绪后:冲刷暂存锚点数据并预热锚点图标,让提前到达的数据及时上屏。 + flushPendingLegendLayerData(); + prefetchLegendPointIcons(); }; /** @@ -859,18 +937,30 @@ export const useMapStore = defineStore('map', () => { processItems(items); - const allTasks = [...checkedTasks, ...uncheckedTasks]; - - // 方案三:逐个处理 —— 内部不 await 批量完成,每个请求独立释放 HTTP 连接 + // 勾选图层优先:先并发发起勾选图层数据请求(每层返回即渲染锚点), + // 全部勾选图层完成后,再后台并发加载未勾选图层,避免同时抢占连接拖慢默认勾选图层。 void (async () => { try { - const loadResults = await Promise.allSettled( - allTasks.map(task => task()) + const checkedResults = await Promise.allSettled( + checkedTasks.map(task => task()) ); - const failedResults = loadResults.filter( + const failedChecked = checkedResults.filter( result => result.status === 'rejected' ); + if (failedChecked.length > 0) { + console.warn( + '勾选图层数据加载失败,但不会阻断其他图层:', + failedChecked + ); + } + const uncheckedResults = await Promise.allSettled( + uncheckedTasks.map(task => task()) + ); + const failedResults = [ + ...failedChecked, + ...uncheckedResults.filter(result => result.status === 'rejected') + ]; if (failedResults.length > 0) { console.warn( '部分图层数据加载失败,但不会阻断其他图层:', @@ -1330,18 +1420,30 @@ export const useMapStore = defineStore('map', () => { } } - list = normalizePointLayerItems( - list.map((item: any) => ({ - ...item, - layerKey: item.layerKey || key - })) - ); + const rawList = list.map((item: any) => ({ + ...item, + layerKey: item.layerKey || key + })); if (activeLayerRequestKeyMap.get(key) !== requestIdentifier) { return []; } const isLayerChecked = layer.checked === 1; + + // 备注:图例配置未就绪时锚点无法解析 iconCode,立即归一化会被过滤为空导致数据丢失。 + // 此时暂存原始数据,待图例配置就绪后由 flushPendingLegendLayerData 统一归一化并上屏。 + if (legendDataOriginal.value.length === 0) { + pendingLegendRawLayerData.set(key, { + checked: isLayerChecked, + data: rawList, + cacheKey: requestIdentifier + }); + return rawList; + } + + list = normalizePointLayerItems(rawList); + const cachePayload = { checked: isLayerChecked, data: list, @@ -1461,6 +1563,8 @@ export const useMapStore = defineStore('map', () => { activeLayerRequestKeyMap.delete(entry.layerKey); } backgroundPointLayerCache.clear(); + // 页面切换时清空图例暂存数据,避免旧页面的锚点原始数据被冲刷到新页面。 + pendingLegendRawLayerData.clear(); }; /** diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 7747e529..4e449aa9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -38,6 +38,13 @@ export default ({ mode }: ConfigEnv): UserConfig => { target: 'http://localhost:5174', changeOrigin: true }, + // GIS 服务反代:18085 服务端不返回 CORS 头,浏览器直接跨域请求会被拦截 + // 前端将 https://211.99.26.225:18085 改写为 /gis-map,由本代理转发(对齐旧项目相对路径方案) + '/gis-map': { + target: 'https://211.99.26.225:18085', + changeOrigin: true, + secure: false + }, '/api/dec-lygk-base-server': { target: 'https://211.99.26.225:12122', changeOrigin: true,