# Mapbox 全量替换 maptalks Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 将运行时地图引擎从 `maptalks` + 天地图全量切到 `mapbox-gl` + Mapbox 官方样式,并迁移 Monitor / Tasks / Replay 全部地图 API。 **Architecture:** 保留常驻 `MapLayer` + `commonRefs('map')` + `mapHelper` 薄封装;内部换成 `mapboxgl.Map`。页面用 GeoJSON Source + Layer;`setStyle` 后经 `style.load` 重建业务层。Tasks 继续 DOM `replace`/`restore`,内部改 `resize()`。 **Tech Stack:** Vue 3、Vite 5、mapbox-gl、现有 Pinia devicesStore、`prototype.css` **Spec:** `docs/superpowers/specs/2026-08-27-mapbox-migration-design.md` --- ## File map | File | Responsibility | |------|----------------| | `package.json` | 依赖:加 `mapbox-gl`,删 `maptalks` | | `.env.example` / `.env.development` / `.env.production` | `APP_MAPBOX_TOKEN`;去掉天地图必填 | | `src/config/map.js` | token / center / zoom / `MAP_STYLES`;删天地图 helper | | `src/layout/MapLayer.vue` | 唯一建图入口:`mapboxgl.Map` | | `src/core/mapHelper.js` | pitch/bearing、replace/restore + `resize()` | | `src/views/MonitorView/MonitorView.vue` | 设备点、绑定线、底图切换、fit/fly、比例尺 | | `src/views/ReplayView/ReplayView.vue` | 航迹 line + 点 + fitBounds | | `src/views/TasksView/TasksView.vue` | 搬迁宿主、点击加点、航线/航点 layer | | `src/styles/prototype.css` | `#map` / `.mapboxgl-map`;删 `.maptalks-wrapper` | | `vite.config.js` | 仅在 worker 加载需要时补配置(优先用官方默认 worker) | --- ### Task 1: 依赖与 env / map config **Files:** - Modify: `package.json` - Modify: `.env.example` - Modify: `.env.development` - Modify: `.env.production` - Modify: `src/config/map.js` - [ ] **Step 1: 安装 mapbox-gl,移除 maptalks** ```bash npm uninstall maptalks npm install mapbox-gl@^3.9.0 ``` Expected: `package.json` dependencies 含 `mapbox-gl`,无 `maptalks`。 - [ ] **Step 2: 重写 `src/config/map.js`** 整文件替换为: ```js const token = import.meta.env.APP_MAPBOX_TOKEN || import.meta.env.VITE_MAPBOX_TOKEN || '' export const MAP_STYLES = { satellite: 'mapbox://styles/mapbox/satellite-streets-v12', street: 'mapbox://styles/mapbox/streets-v12', } export default { token, center: [ Number(import.meta.env.APP_MAP_CENTER_LNG) || 106.62341584179686, Number(import.meta.env.APP_MAP_CENTER_LAT) || 26.657826154258505, ], zoom: Number(import.meta.env.APP_MAP_ZOOM) || 5, } ``` - [ ] **Step 3: 更新 env 文件** `.env.example`: ```env # 复制为 .env.development / .env.local 后填写 # Vite 通过 envPrefix: ['APP_','VITE_'] 暴露给前端 # Mapbox token(必填,否则 MapLayer 跳过初始化) # 申请:https://account.mapbox.com/access-tokens/ APP_MAPBOX_TOKEN= # 默认地图中心(经度 / 纬度)与缩放级别 APP_MAP_CENTER_LNG=106.62341584179686 APP_MAP_CENTER_LAT=26.657826154258505 APP_MAP_ZOOM=5 ``` `.env.development` / `.env.production`:把 `APP_TIANDITU_TOKEN=...` 换成 `APP_MAPBOX_TOKEN=`(留空或填真实 token;**不要把旧天地图 token 当成 Mapbox token**)。保留中心/缩放行。 - [ ] **Step 4: Commit** ```bash git add package.json package-lock.json src/config/map.js .env.example .env.development .env.production git commit -m "$(cat <<'EOF' chore: 引入 mapbox-gl 并替换地图 env/config 删除 maptalks 与天地图配置,改为 APP_MAPBOX_TOKEN + MAP_STYLES。 EOF )" ``` --- ### Task 2: MapLayer + mapHelper 薄封装 **Files:** - Modify: `src/layout/MapLayer.vue` - Modify: `src/core/mapHelper.js` - Modify: `src/styles/prototype.css`(Tasks 宿主选择器,可本 task 先改) - [ ] **Step 1: 重写 `src/core/mapHelper.js`** ```js /** * 地图助手(Mapbox) * replace/restore 移动 #map 容器后必须 resize() */ class MapHelper { _map = null _originParent = null _savedView = null setMap(map) { this._map = map } getMap() { return this._map } toggleMapMode({ is2D = false, is3D = false } = {}) { if (!this._map) return if (is3D) { this._map.easeTo({ pitch: 45, bearing: 0, duration: 0 }) this._map.dragRotate.enable() this._map.touchZoomRotate.enableRotation() if (this._map.dragPitch) this._map.dragPitch.enable() } if (is2D) { this._map.easeTo({ pitch: 0, bearing: 0, duration: 0 }) this._map.dragRotate.disable() this._map.touchZoomRotate.disableRotation() if (this._map.dragPitch) this._map.dragPitch.disable() } } replaceMapContainer(newParent) { if (!this._map || !newParent) return const el = this._map.getContainer() this._originParent = this._originParent || el.parentNode const c = this._map.getCenter() this._savedView = { center: [c.lng, c.lat], zoom: this._map.getZoom(), pitch: this._map.getPitch(), bearing: this._map.getBearing(), } newParent.appendChild(el) this._map.resize() } restoreMapContainer() { if (!this._map || !this._originParent) return const el = this._map.getContainer() this._originParent.appendChild(el) if (this._savedView) { this._map.jumpTo({ center: this._savedView.center, zoom: this._savedView.zoom, pitch: this._savedView.pitch, bearing: this._savedView.bearing, }) } this._map.resize() this._originParent = null this._savedView = null } } export default new MapHelper() ``` - [ ] **Step 2: 重写 `src/layout/MapLayer.vue`** ```vue ``` - [ ] **Step 3: CSS 宿主适配** 在 `src/styles/prototype.css` 把: ```css .route-planner-map > .maptalks-wrapper, .route-planner-map > #map { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; z-index: 0; } ``` 改为: ```css .route-planner-map > #map, .route-planner-map > .mapboxgl-map { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; z-index: 0; } ``` 全文件搜索删除其它 `maptalks` 字样(若有)。 - [ ] **Step 4: 冒烟共享层(需有效 token)** 1. 确保 `.env.development` 已填真实 `APP_MAPBOX_TOKEN` 2. `npm run dev` 3. 登录后应看到 Mapbox 卫星底图;控制台无 maptalks 报错 4. 若缺 token:控制台 `[MapLayer] 缺少 APP_MAPBOX_TOKEN...`,页面不白屏崩 - [ ] **Step 5: Commit** ```bash git add src/layout/MapLayer.vue src/core/mapHelper.js src/styles/prototype.css git commit -m "$(cat <<'EOF' feat: MapLayer/mapHelper 切换到 mapbox-gl 常驻地图改用官方样式;容器搬迁改为 resize。 EOF )" ``` --- ### Task 3: Monitor 设备点 / 绑定线 / 底图切换 **Files:** - Modify: `src/views/MonitorView/MonitorView.vue` **约定 ID:** - source/layer devices: `monitor-devices` / `monitor-devices-circle` / `monitor-devices-label` - source/layer binding: `monitor-binding` / `monitor-binding-line` - [ ] **Step 1: 替换 import 与图层状态变量** 删除: ```js import * as maptalks from 'maptalks' import mapConfig, { createTiandituBaseLayer } from '@/config/map' ``` 改为: ```js import mapConfig, { MAP_STYLES } from '@/config/map' ``` 将 `deviceLayer` / `bindingLayer` / `markerById` / `bindingLine` 改为: ```js const DEVICE_SOURCE = 'monitor-devices' const DEVICE_CIRCLE = 'monitor-devices-circle' const DEVICE_LABEL = 'monitor-devices-label' const BINDING_SOURCE = 'monitor-binding' const BINDING_LINE = 'monitor-binding-line' let mapInstance = null let layersReady = false let suppressMapClick = false ``` 删除 `markerById` Map 与 `markerSymbol`(改由 GeoJSON properties + paint 表达)。 - [ ] **Step 2: 实现 ensureMonitorLayers / clearMonitorLayers / devicesFeatureCollection** ```js function statusColor(statusClass) { return STATUS_COLORS[statusClass] || STATUS_COLORS.offline } function devicesFeatureCollection() { return { type: 'FeatureCollection', features: mapMarkers.value .map((asset) => { const lon = Number(asset.longitude) const lat = Number(asset.latitude) if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null const selected = selectedId.value === asset.id return { type: 'Feature', id: asset.id, // 供 feature-state;若 id 非数值,改用 properties + setData 全量刷新 properties: { assetId: String(asset.id), name: asset.name || '', kind: asset.type, selected, color: statusColor(asset.statusClass), radius: asset.type === 'drone' ? (selected ? 10 : 8) : (selected ? 12 : 10), }, geometry: { type: 'Point', coordinates: [lon, lat] }, } }) .filter(Boolean), } } function bindingFeatureCollection() { const ends = bindingEndpoints() if (!ends) { return { type: 'FeatureCollection', features: [] } } return { type: 'FeatureCollection', features: [ { type: 'Feature', properties: { label: '绑定设备' }, geometry: { type: 'LineString', coordinates: ends }, }, ], } } function ensureMonitorLayers() { if (!mapInstance || !mapInstance.isStyleLoaded()) return if (!mapInstance.getSource(DEVICE_SOURCE)) { mapInstance.addSource(DEVICE_SOURCE, { type: 'geojson', data: devicesFeatureCollection() }) } if (!mapInstance.getSource(BINDING_SOURCE)) { mapInstance.addSource(BINDING_SOURCE, { type: 'geojson', data: bindingFeatureCollection() }) } if (!mapInstance.getLayer(BINDING_LINE)) { mapInstance.addLayer({ id: BINDING_LINE, type: 'line', source: BINDING_SOURCE, paint: { 'line-color': 'rgba(117,191,246,0.95)', 'line-width': 2, 'line-dasharray': [2, 1.5], 'line-opacity': 0.95, }, }) } if (!mapInstance.getLayer(DEVICE_CIRCLE)) { mapInstance.addLayer({ id: DEVICE_CIRCLE, type: 'circle', source: DEVICE_SOURCE, paint: { 'circle-radius': ['get', 'radius'], 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': [ 'case', ['==', ['get', 'selected'], true], 3, 2, ], }, }) } if (!mapInstance.getLayer(DEVICE_LABEL)) { mapInstance.addLayer({ id: DEVICE_LABEL, type: 'symbol', source: DEVICE_SOURCE, layout: { 'text-field': ['get', 'name'], 'text-size': 11, 'text-offset': [0, 1.4], 'text-anchor': 'top', 'text-allow-overlap': false, }, paint: { 'text-color': '#fff', 'text-halo-color': 'rgba(24,35,45,.86)', 'text-halo-width': 1.2, }, }) } layersReady = true } function clearMonitorLayers() { if (!mapInstance) return for (const id of [DEVICE_LABEL, DEVICE_CIRCLE, BINDING_LINE]) { if (mapInstance.getLayer(id)) mapInstance.removeLayer(id) } for (const id of [DEVICE_SOURCE, BINDING_SOURCE]) { if (mapInstance.getSource(id)) mapInstance.removeSource(id) } layersReady = false } ``` 保留现有 `bindingEndpoints()` 逻辑不变。 - [ ] **Step 3: 重写 syncMarkers / syncBindingLine / setMapMode / fit / fly** ```js function syncMarkers() { if (!mapInstance || !layersReady) return const src = mapInstance.getSource(DEVICE_SOURCE) if (src) src.setData(devicesFeatureCollection()) syncBindingLine() } function syncBindingLine() { if (!mapInstance || !layersReady) return const src = mapInstance.getSource(BINDING_SOURCE) if (src) src.setData(bindingFeatureCollection()) } function rebuildAfterStyle() { layersReady = false ensureMonitorLayers() syncMarkers() updateMapScale() } function setMapMode(mode) { if (!mapInstance || mapMode.value === mode) { mapMode.value = mode return } if (!mapConfig.token) { ui.toast('缺少 Mapbox token,无法切换底图') return } mapMode.value = mode mapInstance.setStyle(MAP_STYLES[mode] || MAP_STYLES.satellite) mapInstance.once('style.load', rebuildAfterStyle) } function fitAllMarkers() { if (!mapInstance) return const coords = mapMarkers.value .map((a) => [Number(a.longitude), Number(a.latitude)]) .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)) if (!coords.length) { mapInstance.jumpTo({ center: mapConfig.center, zoom: mapConfig.zoom }) return } if (coords.length === 1) { mapInstance.easeTo({ center: coords[0], zoom: 16, duration: 450 }) return } const bounds = coords.reduce( (b, c) => b.extend(c), new mapboxgl.LngLatBounds(coords[0], coords[0]), ) mapInstance.fitBounds(bounds, { padding: 60, duration: 450 }) } function flyToSelected() { if (!mapInstance || !selectedId.value) return const asset = devices.asset(selectedId.value) if (!asset?.hasCoordinates) return const lon = Number(asset.longitude) const lat = Number(asset.latitude) if (!Number.isFinite(lon) || !Number.isFinite(lat)) return mapInstance.easeTo({ center: [lon, lat], zoom: Math.max(mapInstance.getZoom(), 16), duration: 450, }) } ``` 在文件顶部 script 增加: ```js import mapboxgl from 'mapbox-gl' ``` (仅用于 `LngLatBounds`;若不想顶层 import,可用 `mapInstance.fitBounds` 前动态 `import('mapbox-gl')`,但本项目直接静态 import 即可。) - [ ] **Step 4: 点击选中与 setup/teardown** ```js function onMapClick(e) { if (suppressMapClick) return const feats = mapInstance.queryRenderedFeatures(e.point, { layers: [DEVICE_CIRCLE, DEVICE_LABEL].filter((id) => mapInstance.getLayer(id)), }) const id = feats[0]?.properties?.assetId if (id) { suppressMapClick = true select(String(id)) setTimeout(() => { suppressMapClick = false }, 0) return } clearSelection() } async function setupMap() { const map = await commonRefs.getRef('map') if (!map) { ui.toast('地图未初始化(缺少 Mapbox token)') return } mapInstance = map mapHelper.toggleMapMode({ is2D: true }) const onReady = () => { ensureMonitorLayers() syncMarkers() if (selectedId.value) flyToSelected() else fitAllMarkers() updateMapScale() } if (map.isStyleLoaded()) onReady() else map.once('load', onReady) map.on('click', onMapClick) map.on('zoom', updateMapScale) map.on('move', updateMapScale) } function teardownMap() { if (mapInstance) { mapInstance.off('click', onMapClick) mapInstance.off('zoom', updateMapScale) mapInstance.off('move', updateMapScale) clearMonitorLayers() } mapInstance = null commonRefs.clearPendingList('map') } ``` 删除旧的 `onMapBlankClick`、`map.getLayer` maptalks 路径、`zoomIn`/`zoomOut` 若仍调用 maptalks API 则改为: ```js function zoomIn() { if (!mapInstance) return mapInstance.zoomTo(Math.min(mapInstance.getMaxZoom(), mapInstance.getZoom() + 1), { duration: 200 }) } function zoomOut() { if (!mapInstance) return mapInstance.zoomTo(Math.max(mapInstance.getMinZoom(), mapInstance.getZoom() - 1), { duration: 200 }) } ``` `updateMapScale` 继续用 `mapInstance.getZoom()`(Mapbox 同样有)。 - [ ] **Step 5: 浏览器冒烟 Monitor** 1. 登录 `/monitor`,见卫星底图与设备圆点/文字 2. 点设备 → 右侧详情 + 飞向;飞行无人机出现绑定虚线 3. 切「地图」→ 街道样式;点/线在 `style.load` 后仍在 4. fitAll 清选中并框住全部点 5. 源码检索:`rg "maptalks|createTianditu|setBaseLayer" src/views/MonitorView` 应无匹配 - [ ] **Step 6: Commit** ```bash git add src/views/MonitorView/MonitorView.vue git commit -m "$(cat <<'EOF' feat: Monitor 地图改为 Mapbox GeoJSON 图层 设备点、绑定虚线、样式切换与 fit/fly 全量替换 maptalks。 EOF )" ``` --- ### Task 4: Replay 航迹 **Files:** - Modify: `src/views/ReplayView/ReplayView.vue` - [ ] **Step 1: 替换 import 与 drawTrajectory** 删除 `import * as maptalks from 'maptalks'`。 ```js import mapboxgl from 'mapbox-gl' const LAYER_ID = 'replay-traj' const SOURCE_ID = 'replay-traj' const LINE_LAYER = 'replay-traj-line' const POINT_LAYER = 'replay-traj-points' function clearReplayLayers(map) { if (!map) return for (const id of [POINT_LAYER, LINE_LAYER]) { if (map.getLayer(id)) map.removeLayer(id) } if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID) } function drawTrajectory(map, list) { clearReplayLayers(map) const coords = toCoords(list) const lineFeatures = coords.length >= 2 ? [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {}, }] : [] const pointFeatures = coords.map((c, i) => ({ type: 'Feature', properties: { role: i === 0 ? 'start' : i === coords.length - 1 ? 'end' : 'mid', color: i === 0 ? '#21a06a' : i === coords.length - 1 ? '#c56c28' : '#2b79c8', radius: i === 0 || i === coords.length - 1 ? 6 : 3.5, }, geometry: { type: 'Point', coordinates: c }, })) map.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [...lineFeatures, ...pointFeatures], }, }) if (coords.length >= 2) { map.addLayer({ id: LINE_LAYER, type: 'line', source: SOURCE_ID, filter: ['==', ['geometry-type'], 'LineString'], paint: { 'line-color': '#2b79c8', 'line-width': 3, 'line-dasharray': [2, 1.5], 'line-opacity': 0.95, }, }) } map.addLayer({ id: POINT_LAYER, type: 'circle', source: SOURCE_ID, filter: ['==', ['geometry-type'], 'Point'], paint: { 'circle-radius': ['get', 'radius'], 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2, }, }) if (!coords.length) return if (coords.length === 1) { map.easeTo({ center: coords[0], zoom: Math.max(map.getZoom(), 16), duration: 400 }) return } const bounds = coords.reduce( (b, c) => b.extend(c), new mapboxgl.LngLatBounds(coords[0], coords[0]), ) map.fitBounds(bounds, { padding: 80, duration: 400 }) } ``` - [ ] **Step 2: setup/teardown 文案与清理** ```js async function setupMap() { const map = await commonRefs.getRef('map') if (!map) { ui.toast('地图未初始化(缺少 Mapbox token)') return } mapInstance = map mapHelper.toggleMapMode({ is2D: true }) const run = () => drawTrajectory(map, points.value) if (map.isStyleLoaded()) run() else map.once('load', run) } function teardownMap() { clearReplayLayers(mapInstance) trajLayer = null mapInstance = null commonRefs.clearPendingList('map') } ``` 删除对 `trajLayer` maptalks 实例的依赖(可删变量或仅作标志)。 - [ ] **Step 3: 冒烟** 打开任一回放页:航迹虚线 + 起终点色点 + 自动 fit;离开页无残留 layer(再进 Monitor 不应看到回放线)。 - [ ] **Step 4: Commit** ```bash git add src/views/ReplayView/ReplayView.vue git commit -m "$(cat <<'EOF' feat: Replay 航迹改为 Mapbox GeoJSON 图层 EOF )" ``` --- ### Task 5: Tasks 航线编辑地图 **Files:** - Modify: `src/views/TasksView/TasksView.vue` **约定 ID:** `task-route` source;`task-route-line`;`task-waypoints` source;`task-waypoints-circle`;`task-waypoints-label` - [ ] **Step 1: 替换 import,去掉 maptalks** ```js import mapboxgl from 'mapbox-gl' ``` - [ ] **Step 2: 重写 syncRouteLayer / 点击 / attach / teardown** ```js const ROUTE_SOURCE = 'task-route' const ROUTE_LINE = 'task-route-line' const WP_SOURCE = 'task-waypoints' const WP_CIRCLE = 'task-waypoints-circle' const WP_LABEL = 'task-waypoints-label' function clearTaskLayers(map) { if (!map) return for (const id of [WP_LABEL, WP_CIRCLE, ROUTE_LINE]) { if (map.getLayer(id)) map.removeLayer(id) } for (const id of [WP_SOURCE, ROUTE_SOURCE]) { if (map.getSource(id)) map.removeSource(id) } } function ensureTaskLayers(map) { if (!map.getSource(ROUTE_SOURCE)) { map.addSource(ROUTE_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) } if (!map.getSource(WP_SOURCE)) { map.addSource(WP_SOURCE, { type: 'geojson', data: { type: 'FeatureCollection', features: [] }, }) } if (!map.getLayer(ROUTE_LINE)) { map.addLayer({ id: ROUTE_LINE, type: 'line', source: ROUTE_SOURCE, paint: { 'line-color': '#2d82da', 'line-width': 3, 'line-opacity': 0.95, }, }) } if (!map.getLayer(WP_CIRCLE)) { map.addLayer({ id: WP_CIRCLE, type: 'circle', source: WP_SOURCE, paint: { 'circle-radius': 9, 'circle-color': ['get', 'color'], 'circle-stroke-color': '#fff', 'circle-stroke-width': 2, }, }) } if (!map.getLayer(WP_LABEL)) { map.addLayer({ id: WP_LABEL, type: 'symbol', source: WP_SOURCE, layout: { 'text-field': ['get', 'indexLabel'], 'text-size': 11, 'text-allow-overlap': true, }, paint: { 'text-color': '#fff' }, }) } } function syncRouteLayer() { if (!routeMapInstance) return ensureTaskLayers(routeMapInstance) const coords = routePoints.value .map((p) => [Number(p.longitude), Number(p.latitude)]) .filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat)) const lineData = { type: 'FeatureCollection', features: coords.length >= 2 ? [{ type: 'Feature', geometry: { type: 'LineString', coordinates: coords }, properties: {} }] : [], } const wpData = { type: 'FeatureCollection', features: coords.map((c, i) => { const isStart = i === 0 const isEnd = i === coords.length - 1 && coords.length > 1 return { type: 'Feature', properties: { index: i, indexLabel: String(i + 1), color: isStart ? '#35a66a' : isEnd ? '#c56c28' : '#2d82da', }, geometry: { type: 'Point', coordinates: c }, } }), } routeMapInstance.getSource(ROUTE_SOURCE)?.setData(lineData) routeMapInstance.getSource(WP_SOURCE)?.setData(wpData) } function onRouteMapClick(e) { if (!routeMapAttached || !routeMapInstance) return const hit = routeMapInstance.queryRenderedFeatures(e.point, { layers: [WP_CIRCLE, WP_LABEL].filter((id) => routeMapInstance.getLayer(id)), }) if (hit[0]?.properties?.index != null) { openWaypointEditor(Number(hit[0].properties.index)) return } if (!validRouteDefaults()) return const { lng, lat } = e.lngLat routePoints.value.push({ longitude: lng, latitude: lat, altitude: Number(routeForm.altitude), speed: Number(routeForm.speed), turnMode: routeForm.turnMode, }) routeCursor.value = formatCoord(lng, lat) routeFormError.value = '' syncRouteLayer() } function onRouteMapMove(e) { if (!routeMapAttached) return routeCursor.value = formatCoord(e.lngLat.lng, e.lngLat.lat) } async function attachRouteMap() { await nextTick() const host = routeMapEl.value if (!host) return const map = await commonRefs.getRef('map') if (!map) { routeMapReady.value = false ui.toast('地图未初始化(缺少 Mapbox token)') return } routeMapInstance = map mapHelper.replaceMapContainer(host) mapHelper.toggleMapMode({ is2D: true }) routeMapAttached = true routeMapReady.value = true clearTaskLayers(map) ensureTaskLayers(map) map.on('click', onRouteMapClick) map.on('mousemove', onRouteMapMove) const c = map.getCenter() routeCursor.value = formatCoord(c.lng, c.lat) requestAnimationFrame(() => { map.resize() syncRouteLayer() }) } function teardownRouteMap() { if (routeMapInstance) { routeMapInstance.off('click', onRouteMapClick) routeMapInstance.off('mousemove', onRouteMapMove) clearTaskLayers(routeMapInstance) } if (routeMapAttached) { mapHelper.restoreMapContainer() routeMapAttached = false } routeMapInstance = null routeMapReady.value = false } ``` 删除 `routeLayer` maptalks 变量及所有 `new maptalks.*`。 - [ ] **Step 3: 冒烟 Tasks** 1. 打开航线编辑器:地图出现在编辑面板内(非主壳空白) 2. 点击加点 → 序号圆点 + 连线;点已有点打开编辑 3. 关闭编辑器:地图回到主壳;再进 Monitor 无航线残留 - [ ] **Step 4: Commit** ```bash git add src/views/TasksView/TasksView.vue git commit -m "$(cat <<'EOF' feat: Tasks 航线编辑改为 Mapbox 图层与点击交互 保留 replace/restore 搬迁语义,容器刷新改 resize。 EOF )" ``` --- ### Task 6: 清残留 + 全量验收 **Files:** - Modify: any remaining `maptalks` / `TIANDITU` references in `src/` - Verify: `package.json`, build, browser - [ ] **Step 1: 全局清扫** ```bash rg -n "maptalks|createTianditu|TIANDITU|setBaseLayer|checkSize|fitExtent|VectorLayer" src package.json ``` Expected: `src/` 与 `package.json` **无** `maptalks` / 天地图运行时引用(docs 历史文件可留)。 若 `vite` 构建报 mapbox worker 错,在 `MapLayer.vue` init 前加: ```js mapboxgl.workerUrl = new URL('mapbox-gl/dist/mapbox-gl-csp-worker.js', import.meta.url).toString() ``` 或按报错补 `vite.config.js` `optimizeDeps.include: ['mapbox-gl']`。 - [ ] **Step 2: 构建** ```bash npm run build ``` Expected: 成功;产物无 maptalks。 - [ ] **Step 3: 1920×1080 浏览器冒烟清单** - [ ] 有效 token:登录见卫星底图 - [ ] 缺 token(临时清空):不白屏,有 toast/日志 - [ ] Monitor:点选、绑定线、卫星↔街道、fitAll、比例尺 - [ ] Replay:航迹 + fit - [ ] Tasks:搬迁编辑、加点、关闭恢复 - [ ] **Step 4: 最终 commit(若有清扫改动)** ```bash git add -A git status git commit -m "$(cat <<'EOF' chore: 清除 maptalks/天地图残留并完成 Mapbox 迁移验收 EOF )" ``` --- ## Spec coverage checklist | Spec 项 | Task | |---------|------| | mapbox-gl 依赖 / 删 maptalks | 1, 6 | | `APP_MAPBOX_TOKEN` + `MAP_STYLES` | 1 | | MapLayer 初始化 / 缺 token 降级 | 2 | | mapHelper toggle + replace/restore + resize | 2, 5 | | Monitor GeoJSON 点/线 / setStyle+style.load | 3 | | Replay 航迹 | 4 | | Tasks 搬迁 + 点击加点 | 5 | | CSS 去 maptalks-wrapper | 2 | | 验收清单 | 6 | ## 执行前注意 - **必须先准备真实 Mapbox token** 写入 `.env.development`,否则 Task 2 起无法目视验收底图。 - 本仓库无地图单测;验收以 `npm run build` + 浏览器冒烟为准(与既有 Monitor 计划一致)。