Browse Source

feat: real map route editor, logs menu, env example

main
xiaosi 1 month ago
parent
commit
9f8d66ab11
  1. 11
      .env.example
  2. 2
      .gitignore
  3. 7
      src/layout/components/SideMenu.vue
  4. 9
      src/styles/prototype.css
  5. 233
      src/views/TasksView/TasksView.vue

11
.env.example

@ -0,0 +1,11 @@
# 复制为 .env.development / .env.local 后填写
# Vite 通过 envPrefix: 'APP_' 暴露给前端
# 天地图 token(必填,否则 MapLayer 跳过初始化)
# 申请:https://console.tianditu.gov.cn/
APP_TIANDITU_TOKEN=
# 默认地图中心(经度 / 纬度)与缩放级别
APP_MAP_CENTER_LNG=106.62341584179686
APP_MAP_CENTER_LAT=26.657826154258505
APP_MAP_ZOOM=5

2
.gitignore

@ -2,3 +2,5 @@ node_modules
dist
*.log
.DS_Store
.env.local
.env.*.local

7
src/layout/components/SideMenu.vue

@ -128,6 +128,13 @@ async function logout() {
告警运维
</t-menu-item>
<t-menu-item v-if="!isAdmin" value="logs">
<template #icon>
<svg :class="s.icon"><use href="#i-clock" /></svg>
</template>
操作日志
</t-menu-item>
<t-menu-item v-if="!isAdmin" value="account">
<template #icon>
<svg :class="s.icon"><use href="#i-wallet" /></svg>

9
src/styles/prototype.css

@ -363,12 +363,9 @@ svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width:
.waypoint-item-actions { display: flex; align-items: center; }.waypoint-item-actions button { width: 23px; height: 23px; display: grid; place-items: center; border-radius: 3px; color: #788792; background: transparent; }.waypoint-item-actions button:hover { color: var(--blue); background: #eaf2fc; }.waypoint-item-actions button:last-child:hover { color: var(--red); background: #fff0f0; }.waypoint-item-actions button svg { width: 11px; }
.route-form-error { min-height: 25px; padding-top: 8px; color: var(--red); font-size: 10px; }
.route-planner-map { position: relative; min-width: 0; min-height: 430px; overflow: hidden; background: #75887a; cursor: crosshair; isolation: isolate; }
.route-map-base { position: absolute; inset: 0; background-image: linear-gradient(rgba(20,45,42,.06), rgba(20,45,42,.16)), url("/assets/map-base-clean.png"); background-size: cover; background-position: center; filter: saturate(.58) contrast(.94); transform: scale(1.01); }
.route-drawing { position: absolute; z-index: 2; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
.route-drawing polyline { fill: none; stroke: #2d82da; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; vector-effect: non-scaling-stroke; filter: drop-shadow(0 1px 2px rgba(12,37,58,.35)); }
.route-drawing circle { stroke: #fff; stroke-width: 3; vector-effect: non-scaling-stroke; filter: drop-shadow(0 2px 3px rgba(12,31,43,.4)); }
.route-drawing text { fill: #fff; font-size: 16px; font-weight: 700; text-anchor: middle; dominant-baseline: central; pointer-events: none; }
.route-map-status, .route-map-legend { position: absolute; z-index: 3; display: flex; align-items: center; border-radius: 4px; background: rgba(255,255,255,.94); box-shadow: 0 3px 12px rgba(20,36,45,.13); color: #52616d; font-size: 10px; }
.route-planner-map > .maptalks-wrapper,
.route-planner-map > #map { position: absolute !important; inset: 0; width: 100% !important; height: 100% !important; z-index: 0; }
.route-map-status, .route-map-legend { position: absolute; z-index: 5; display: flex; align-items: center; border-radius: 4px; background: rgba(255,255,255,.94); box-shadow: 0 3px 12px rgba(20,36,45,.13); color: #52616d; font-size: 10px; pointer-events: none; }
.route-map-status { top: 12px; right: 12px; height: 30px; padding: 0 9px; gap: 13px; }.route-map-status span { display: flex; align-items: center; gap: 5px; }.route-map-status i { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
.route-map-legend { right: 12px; bottom: 12px; height: 31px; padding: 0 10px; gap: 11px; }.route-map-legend span { display: flex; align-items: center; gap: 4px; }.route-map-legend i { width: 7px; height: 7px; border: 2px solid #fff; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px #7e8c97; }.route-map-legend i.start { background: var(--green); }.route-map-legend i.end { background: #c56c28; }
.waypoint-dialog { width: min(540px, 100%); }.waypoint-id-row { min-height: 42px; margin-bottom: 14px; padding: 0 12px; display: flex; align-items: center; justify-content: space-between; border-radius: 4px; background: #f4f6f8; }.waypoint-id-row span { color: var(--muted); font-size: 11px; }.waypoint-id-row strong { font-size: 14px; }.waypoint-dialog .number-input input { padding-right: 38px; }.waypoint-turn-segment { height: 38px; grid-template-columns: repeat(2, minmax(0, 1fr)); }

233
src/views/TasksView/TasksView.vue

@ -152,10 +152,8 @@
</div>
<div class="route-form-error">{{ routeFormError }}</div>
</aside>
<div ref="routeMap" class="route-planner-map" @click="addWaypointFromMap" @mousemove="updateRouteCursor">
<div class="route-map-base"></div>
<svg class="route-drawing" viewBox="0 0 1000 600" preserveAspectRatio="none"><polyline :points="routePolyline" /><g><g v-for="(point, index) in routePoints" :key="index" @click.stop="openWaypointEditor(index)"><circle :cx="point.x" :cy="point.y" r="10" :fill="index === 0 ? '#35a66a' : index === routePoints.length - 1 ? '#c56c28' : '#2d82da'" /><text :x="point.x" :y="point.y">{{ index + 1 }}</text></g></g></svg>
<div class="route-map-status"><span><i></i>地图已加载</span><span>{{ routeCursor }}</span></div>
<div ref="routeMapEl" class="route-planner-map">
<div class="route-map-status"><span><i></i>{{ routeMapReady ? '地图已加载' : '地图加载中…' }}</span><span>{{ routeCursor }}</span></div>
<div class="route-map-legend"><span><i class="start"></i>起点</span><span><i></i>航点</span><span><i class="end"></i>终点</span></div>
</div>
</div>
@ -215,12 +213,18 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as maptalks from 'maptalks'
import { useUiStore } from '@/stores/modules/uiStore'
import request from '@/utils/http'
import { useDeviceStore } from '@/stores/modules/devicesStore'
import * as urls from '@/config/urls'
import commonRefs from '@/utils/commonRefs'
import mapHelper from '@/core/mapHelper'
const ROUTE_LAYER_ID = 'tasks-route-editor'
const route = useRoute()
const router = useRouter()
@ -237,8 +241,13 @@ const routeSearch = ref('')
const routeEditorOpen = ref(false)
const routeSaving = ref(false)
const routeFormError = ref('')
const routeCursor = ref('119.820000, 30.310000')
const routeMap = ref(null)
const routeCursor = ref('--')
const routeMapEl = ref(null)
const routeMapReady = ref(false)
let routeMapInstance = null
let routeLayer = null
let routeMapAttached = false
const routeForm = reactive({ name: '', altitude: 50, speed: 10, turnMode: 'auto' })
const routePoints = ref([])
const waypointEditorOpen = ref(false)
@ -355,6 +364,10 @@ async function load() {
onMounted(load)
onUnmounted(() => {
teardownRouteMap()
})
const filteredPlans = computed(() => {
const q = search.value.trim().toLowerCase()
return plans.value.filter((p) => {
@ -380,22 +393,167 @@ const filteredRoutes = computed(() => {
return routes.value.filter((r) => r.name.toLowerCase().includes(q))
})
const routePolyline = computed(() => routePoints.value.map((point) => `${point.x},${point.y}`).join(' '))
const routeSummary = computed(() => `${routePoints.value.length} 个航点 · ${routeDistance(routePoints.value).toFixed(1)} km`)
function formatCoord(lon, lat) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '--'
return `${lon.toFixed(6)}, ${lat.toFixed(6)}`
}
function resetRouteEditor() {
Object.assign(routeForm, { name: '', altitude: 50, speed: 10, turnMode: 'auto' })
routePoints.value = []
routeFormError.value = ''
routeCursor.value = '119.820000, 30.310000'
routeCursor.value = '--'
}
function syncRouteLayer() {
if (!routeLayer) return
routeLayer.clear()
const coords = routePoints.value
.map((p) => [Number(p.longitude), Number(p.latitude)])
.filter(([lon, lat]) => Number.isFinite(lon) && Number.isFinite(lat))
if (coords.length >= 2) {
new maptalks.LineString(coords, {
symbol: {
lineColor: '#2d82da',
lineWidth: 3,
lineOpacity: 0.95,
},
}).addTo(routeLayer)
}
coords.forEach((c, i) => {
const isStart = i === 0
const isEnd = i === coords.length - 1 && coords.length > 1
const color = isStart ? '#35a66a' : isEnd ? '#c56c28' : '#2d82da'
const marker = new maptalks.Marker(c, {
cursor: 'pointer',
properties: { index: i },
symbol: [
{
markerType: 'ellipse',
markerFill: color,
markerLineColor: '#fff',
markerLineWidth: 2,
markerWidth: 18,
markerHeight: 18,
},
{
textName: String(i + 1),
textFill: '#fff',
textSize: 11,
textWeight: 'bold',
textDy: 1,
},
],
})
marker.on('click', (e) => {
if (e?.domEvent) {
e.domEvent.stopPropagation?.()
e.domEvent.preventDefault?.()
}
openWaypointEditor(i)
})
marker.addTo(routeLayer)
})
}
function onRouteMapClick(e) {
if (!routeMapAttached) return
// marker
if (e?.target && e.target !== routeMapInstance) return
if (!validRouteDefaults()) return
const coord = e?.coordinate
if (!coord) return
const longitude = Number(coord.x)
const latitude = Number(coord.y)
if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return
routePoints.value.push({
longitude,
latitude,
altitude: Number(routeForm.altitude),
speed: Number(routeForm.speed),
turnMode: routeForm.turnMode,
})
routeCursor.value = formatCoord(longitude, latitude)
routeFormError.value = ''
syncRouteLayer()
}
function onRouteMapMove(e) {
if (!routeMapAttached) return
const coord = e?.coordinate
if (!coord) return
routeCursor.value = formatCoord(Number(coord.x), Number(coord.y))
}
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('地图未初始化(缺少天地图 token)')
return
}
routeMapInstance = map
mapHelper.replaceMapContainer(host)
mapHelper.toggleMapMode({ is2D: true })
routeMapAttached = true
routeMapReady.value = true
const existing = map.getLayer(ROUTE_LAYER_ID)
if (existing) existing.remove()
routeLayer = new maptalks.VectorLayer(ROUTE_LAYER_ID, [], { zIndex: 140, forceRenderOnMoving: true })
routeLayer.addTo(map)
map.on('click', onRouteMapClick)
map.on('mousemove', onRouteMapMove)
const center = map.getCenter()
if (center) routeCursor.value = formatCoord(center.x, center.y)
//
requestAnimationFrame(() => {
map.checkSize?.()
syncRouteLayer()
})
}
function teardownRouteMap() {
if (routeMapInstance) {
routeMapInstance.off('click', onRouteMapClick)
routeMapInstance.off('mousemove', onRouteMapMove)
const layer = routeMapInstance.getLayer(ROUTE_LAYER_ID)
if (layer) layer.remove()
}
routeLayer = null
if (routeMapAttached) {
mapHelper.restoreMapContainer()
routeMapAttached = false
}
routeMapInstance = null
routeMapReady.value = false
}
function openRouteEditor() {
resetRouteEditor()
routeEditorOpen.value = true
attachRouteMap()
}
function closeRouteEditor() {
teardownRouteMap()
routeEditorOpen.value = false
waypointEditorOpen.value = false
}
@ -412,18 +570,6 @@ function routeDistance(points) {
}, 0)
}
function mapPoint(event) {
const rect = routeMap.value.getBoundingClientRect()
const x = Math.max(0, Math.min(1000, (event.clientX - rect.left) / rect.width * 1000))
const y = Math.max(0, Math.min(600, (event.clientY - rect.top) / rect.height * 600))
return { x, y, longitude: 119.8 + x / 1000 * 0.04, latitude: 30.33 - y / 600 * 0.04 }
}
function updateRouteCursor(event) {
const point = mapPoint(event)
routeCursor.value = `${point.longitude.toFixed(6)}, ${point.latitude.toFixed(6)}`
}
function validRouteDefaults() {
if (routeForm.altitude < 20 || routeForm.altitude > 120) {
routeFormError.value = '默认高度应在 20~120 米之间。'
@ -436,32 +582,40 @@ function validRouteDefaults() {
return true
}
function addWaypointFromMap(event) {
if (!validRouteDefaults()) return
const point = mapPoint(event)
routePoints.value.push({ ...point, altitude: Number(routeForm.altitude), speed: Number(routeForm.speed), turnMode: routeForm.turnMode })
routeFormError.value = ''
}
function undoRoutePoint() {
routePoints.value.pop()
syncRouteLayer()
}
function clearRoutePoints() {
routePoints.value = []
syncRouteLayer()
}
function applyRouteDefaults() {
if (!validRouteDefaults()) return
routePoints.value = routePoints.value.map((point) => ({ ...point, altitude: Number(routeForm.altitude), speed: Number(routeForm.speed), turnMode: routeForm.turnMode }))
routePoints.value = routePoints.value.map((point) => ({
longitude: point.longitude,
latitude: point.latitude,
altitude: Number(routeForm.altitude),
speed: Number(routeForm.speed),
turnMode: routeForm.turnMode,
}))
routeFormError.value = ''
syncRouteLayer()
}
function openWaypointEditor(index) {
const point = routePoints.value[index]
if (!point) return
editingWaypointIndex.value = index
Object.assign(waypointForm, point)
Object.assign(waypointForm, {
longitude: point.longitude,
latitude: point.latitude,
altitude: point.altitude,
speed: point.speed,
turnMode: point.turnMode,
})
waypointFormError.value = ''
waypointEditorOpen.value = true
}
@ -476,6 +630,10 @@ function saveWaypoint() {
waypointFormError.value = '请输入有效的经纬度。'
return
}
if (waypointForm.longitude < -180 || waypointForm.longitude > 180 || waypointForm.latitude < -90 || waypointForm.latitude > 90) {
waypointFormError.value = '经纬度超出有效范围。'
return
}
if (waypointForm.altitude < 20 || waypointForm.altitude > 120) {
waypointFormError.value = '高度应在 20~120 米之间。'
return
@ -484,20 +642,23 @@ function saveWaypoint() {
waypointFormError.value = '速度应在 1~20 m/s 之间。'
return
}
const x = (waypointForm.longitude - 119.8) / 0.04 * 1000
const y = (30.33 - waypointForm.latitude) / 0.04 * 600
if (x < 0 || x > 1000 || y < 0 || y > 600) {
waypointFormError.value = '经纬度超出当前地图显示范围。'
return
routePoints.value[editingWaypointIndex.value] = {
longitude: Number(waypointForm.longitude),
latitude: Number(waypointForm.latitude),
altitude: Number(waypointForm.altitude),
speed: Number(waypointForm.speed),
turnMode: waypointForm.turnMode,
}
routePoints.value[editingWaypointIndex.value] = { ...waypointForm, x, y }
syncRouteLayer()
closeWaypointEditor()
}
function removeWaypoint(index) {
routePoints.value.splice(index, 1)
syncRouteLayer()
}
async function submitRoute() {
const name = routeForm.name.trim()
if (!name) {

Loading…
Cancel
Save