12 changed files with 1627 additions and 207 deletions
@ -0,0 +1,194 @@ |
|||
const dashboardMessages = { |
|||
"zh-CN": { |
|||
dashboard: { |
|||
overview: { |
|||
stats: { |
|||
total: "设备总数", |
|||
enabled: "启用数", |
|||
disabled: "禁用数", |
|||
claimed: "已认领数", |
|||
unclaimed: "未认领数", |
|||
}, |
|||
map: { |
|||
title: "设备轨迹点", |
|||
serviceLabel: "地图服务", |
|||
empty: "暂无设备坐标数据", |
|||
}, |
|||
provider: { |
|||
maptiler: "MapTiler", |
|||
amap: "高德地图", |
|||
google: "谷歌地图", |
|||
}, |
|||
popup: { |
|||
device: "设备", |
|||
time: "时间", |
|||
remark: "备注", |
|||
coordinates: "坐标", |
|||
}, |
|||
message: { |
|||
mapConfigLoadFailed: "地图配置加载失败", |
|||
dataLoadFailed: "设备概览数据加载失败", |
|||
mapLoadFailed: "地图加载失败", |
|||
amapLoadFailed: "高德地图加载失败", |
|||
missingMaptilerKey: "当前企业未配置 MapTiler Key", |
|||
missingGoogleKey: "当前企业未配置谷歌地图 Key", |
|||
missingAmapKey: "当前企业未配置高德地图 Key", |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
"en-US": { |
|||
dashboard: { |
|||
overview: { |
|||
stats: { |
|||
total: "Total Devices", |
|||
enabled: "Enabled", |
|||
disabled: "Disabled", |
|||
claimed: "Claimed", |
|||
unclaimed: "Unclaimed", |
|||
}, |
|||
map: { |
|||
title: "Device Track Points", |
|||
serviceLabel: "Map Service", |
|||
empty: "No device coordinate data", |
|||
}, |
|||
provider: { |
|||
maptiler: "MapTiler", |
|||
amap: "Amap", |
|||
google: "Google Maps", |
|||
}, |
|||
popup: { |
|||
device: "Device", |
|||
time: "Time", |
|||
remark: "Remark", |
|||
coordinates: "Coordinates", |
|||
}, |
|||
message: { |
|||
mapConfigLoadFailed: "Failed to load map configuration", |
|||
dataLoadFailed: "Failed to load dashboard overview data", |
|||
mapLoadFailed: "Failed to load map", |
|||
amapLoadFailed: "Failed to load Amap", |
|||
missingMaptilerKey: "MapTiler key is not configured for this business", |
|||
missingGoogleKey: "Google Maps key is not configured for this business", |
|||
missingAmapKey: "Amap key is not configured for this business", |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
"fr-FR": { |
|||
dashboard: { |
|||
overview: { |
|||
stats: { |
|||
total: "Total appareils", |
|||
enabled: "Actifs", |
|||
disabled: "Desactives", |
|||
claimed: "Reclames", |
|||
unclaimed: "Non reclames", |
|||
}, |
|||
map: { |
|||
title: "Points de trajectoire", |
|||
serviceLabel: "Service de carte", |
|||
empty: "Aucune coordonnee appareil", |
|||
}, |
|||
provider: { |
|||
maptiler: "MapTiler", |
|||
amap: "Amap", |
|||
google: "Google Maps", |
|||
}, |
|||
popup: { |
|||
device: "Appareil", |
|||
time: "Heure", |
|||
remark: "Remarque", |
|||
coordinates: "Coordonnees", |
|||
}, |
|||
message: { |
|||
mapConfigLoadFailed: "Echec du chargement de la configuration de carte", |
|||
dataLoadFailed: "Echec du chargement des donnees d'apercu", |
|||
mapLoadFailed: "Echec du chargement de la carte", |
|||
amapLoadFailed: "Echec du chargement d'Amap", |
|||
missingMaptilerKey: "La cle MapTiler n'est pas configuree", |
|||
missingGoogleKey: "La cle Google Maps n'est pas configuree", |
|||
missingAmapKey: "La cle Amap n'est pas configuree", |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
"es-ES": { |
|||
dashboard: { |
|||
overview: { |
|||
stats: { |
|||
total: "Total de dispositivos", |
|||
enabled: "Habilitados", |
|||
disabled: "Deshabilitados", |
|||
claimed: "Reclamados", |
|||
unclaimed: "No reclamados", |
|||
}, |
|||
map: { |
|||
title: "Puntos de trayectoria", |
|||
serviceLabel: "Servicio de mapa", |
|||
empty: "No hay coordenadas de dispositivos", |
|||
}, |
|||
provider: { |
|||
maptiler: "MapTiler", |
|||
amap: "Amap", |
|||
google: "Google Maps", |
|||
}, |
|||
popup: { |
|||
device: "Dispositivo", |
|||
time: "Hora", |
|||
remark: "Observacion", |
|||
coordinates: "Coordenadas", |
|||
}, |
|||
message: { |
|||
mapConfigLoadFailed: "No se pudo cargar la configuracion del mapa", |
|||
dataLoadFailed: "No se pudo cargar el resumen del panel", |
|||
mapLoadFailed: "No se pudo cargar el mapa", |
|||
amapLoadFailed: "No se pudo cargar Amap", |
|||
missingMaptilerKey: "MapTiler key no esta configurada", |
|||
missingGoogleKey: "Google Maps key no esta configurada", |
|||
missingAmapKey: "Amap key no esta configurada", |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
"pt-BR": { |
|||
dashboard: { |
|||
overview: { |
|||
stats: { |
|||
total: "Total de dispositivos", |
|||
enabled: "Ativos", |
|||
disabled: "Desativados", |
|||
claimed: "Reivindicados", |
|||
unclaimed: "Nao reivindicados", |
|||
}, |
|||
map: { |
|||
title: "Pontos de trajeto", |
|||
serviceLabel: "Servico de mapa", |
|||
empty: "Sem dados de coordenadas de dispositivo", |
|||
}, |
|||
provider: { |
|||
maptiler: "MapTiler", |
|||
amap: "Amap", |
|||
google: "Google Maps", |
|||
}, |
|||
popup: { |
|||
device: "Dispositivo", |
|||
time: "Hora", |
|||
remark: "Observacao", |
|||
coordinates: "Coordenadas", |
|||
}, |
|||
message: { |
|||
mapConfigLoadFailed: "Falha ao carregar configuracao do mapa", |
|||
dataLoadFailed: "Falha ao carregar dados de visao geral", |
|||
mapLoadFailed: "Falha ao carregar mapa", |
|||
amapLoadFailed: "Falha ao carregar Amap", |
|||
missingMaptilerKey: "MapTiler key nao configurada", |
|||
missingGoogleKey: "Google Maps key nao configurada", |
|||
missingAmapKey: "Amap key nao configurada", |
|||
}, |
|||
}, |
|||
}, |
|||
}, |
|||
}; |
|||
|
|||
export default dashboardMessages; |
|||
File diff suppressed because one or more lines are too long
@ -0,0 +1,706 @@ |
|||
<template> |
|||
<div class="app-container dashboard-overview" v-loading="loading"> |
|||
<div class="dashboard-stats"> |
|||
<div class="stat-card"> |
|||
<div class="stat-card__label">{{ $t("dashboard.overview.stats.total") }}</div> |
|||
<div class="stat-card__value">{{ stats.total }}</div> |
|||
</div> |
|||
<div class="stat-card stat-card--enabled"> |
|||
<div class="stat-card__label">{{ $t("dashboard.overview.stats.enabled") }}</div> |
|||
<div class="stat-card__value">{{ stats.enabled }}</div> |
|||
</div> |
|||
<div class="stat-card stat-card--disabled"> |
|||
<div class="stat-card__label">{{ $t("dashboard.overview.stats.disabled") }}</div> |
|||
<div class="stat-card__value">{{ stats.disabled }}</div> |
|||
</div> |
|||
<div class="stat-card stat-card--claimed"> |
|||
<div class="stat-card__label">{{ $t("dashboard.overview.stats.claimed") }}</div> |
|||
<div class="stat-card__value">{{ stats.claimed }}</div> |
|||
</div> |
|||
<div class="stat-card stat-card--unclaimed"> |
|||
<div class="stat-card__label">{{ $t("dashboard.overview.stats.unclaimed") }}</div> |
|||
<div class="stat-card__value">{{ stats.unclaimed }}</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="dashboard-map-panel"> |
|||
<div class="dashboard-map-panel__header"> |
|||
<div class="dashboard-map-panel__title">{{ $t("dashboard.overview.map.title") }}</div> |
|||
<div class="dashboard-map-panel__toolbar"> |
|||
<span class="dashboard-map-panel__toolbar-label">{{ $t("dashboard.overview.map.serviceLabel") }}</span> |
|||
<el-radio-group v-model="mapProvider" size="small" @change="handleProviderChange"> |
|||
<el-radio-button label="google" :disabled="!hasGoogleKey"> |
|||
{{ $t("dashboard.overview.provider.google") }} |
|||
</el-radio-button> |
|||
<el-radio-button label="amap" :disabled="!hasAmapKey"> |
|||
{{ $t("dashboard.overview.provider.amap") }} |
|||
</el-radio-button> |
|||
|
|||
<el-radio-button label="maptiler" :disabled="!hasMaptilerKey"> |
|||
{{ $t("dashboard.overview.provider.maptiler") }} |
|||
</el-radio-button> |
|||
|
|||
</el-radio-group> |
|||
</div> |
|||
</div> |
|||
<el-alert |
|||
v-if="mapLoadError && !loading" |
|||
:title="mapLoadError" |
|||
type="warning" |
|||
show-icon |
|||
:closable="false" |
|||
class="dashboard-map-panel__alert" |
|||
/> |
|||
<el-empty |
|||
v-if="!devicePoints.length && !loading" |
|||
:description="$t('dashboard.overview.map.empty')" |
|||
:image-size="84" |
|||
/> |
|||
<div class="dashboard-map-shell" v-loading="mapLoading" v-show="devicePoints.length"> |
|||
<div ref="map" class="dashboard-map"></div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { getDashboardOverview, getDeviceTrajectoryMapConfig } from "@/api/device/device"; |
|||
import { loadAMap } from "@/utils/loadAMap"; |
|||
import { loadLeaflet } from "@/utils/loadLeaflet"; |
|||
|
|||
const DEFAULT_CENTER = [31.2304, 121.4737]; |
|||
const AMAP_DEFAULT_CENTER = [121.4737, 31.2304]; |
|||
const DEFAULT_ZOOM = 4; |
|||
const LEAFLET_MAX_ZOOM = 18; |
|||
const MAP_POINT_MAX_ZOOM = 14; |
|||
const LEAFLET_GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}"; |
|||
const LEAFLET_MAPTILER_TILE_URL = "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key="; |
|||
|
|||
function getDefaultStats() { |
|||
return { |
|||
total: 0, |
|||
enabled: 0, |
|||
disabled: 0, |
|||
claimed: 0, |
|||
unclaimed: 0, |
|||
}; |
|||
} |
|||
|
|||
export default { |
|||
name: "DashboardOverview", |
|||
data() { |
|||
return { |
|||
loading: false, |
|||
mapLoading: false, |
|||
mapLoadError: "", |
|||
stats: getDefaultStats(), |
|||
devicePoints: [], |
|||
mapProvider: "amap", |
|||
mapConfig: null, |
|||
map: null, |
|||
mapsApi: null, |
|||
mapVendor: "", |
|||
mapMarkers: [], |
|||
tileLayer: null, |
|||
mapInfoWindow: null, |
|||
mapReady: false, |
|||
resizeTimer: null, |
|||
}; |
|||
}, |
|||
computed: { |
|||
hasAmapKey() { |
|||
return !!(this.mapConfig && this.mapConfig.gaodeKey); |
|||
}, |
|||
hasGoogleKey() { |
|||
return !!(this.mapConfig && this.mapConfig.googleKey); |
|||
}, |
|||
hasMaptilerKey() { |
|||
return !!(this.mapConfig && this.mapConfig.maptilerKey); |
|||
}, |
|||
}, |
|||
async mounted() { |
|||
await this.fetchMapConfig(); |
|||
this.mapProvider = this.resolveDefaultProvider(); |
|||
await this.loadDashboardData(); |
|||
await this.waitLayoutStable(); |
|||
await this.initCurrentMap(); |
|||
this.renderMapPoints(); |
|||
window.addEventListener("resize", this.handleWindowResize); |
|||
}, |
|||
activated() { |
|||
this.$nextTick(() => { |
|||
this.handleWindowResize(); |
|||
}); |
|||
}, |
|||
deactivated() { |
|||
if (this.resizeTimer) { |
|||
clearTimeout(this.resizeTimer); |
|||
this.resizeTimer = null; |
|||
} |
|||
}, |
|||
beforeDestroy() { |
|||
window.removeEventListener("resize", this.handleWindowResize); |
|||
if (this.resizeTimer) { |
|||
clearTimeout(this.resizeTimer); |
|||
this.resizeTimer = null; |
|||
} |
|||
this.destroyMap(); |
|||
}, |
|||
methods: { |
|||
waitLayoutStable() { |
|||
return new Promise((resolve) => { |
|||
this.$nextTick(() => { |
|||
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") { |
|||
window.requestAnimationFrame(() => { |
|||
window.requestAnimationFrame(resolve); |
|||
}); |
|||
return; |
|||
} |
|||
setTimeout(resolve, 34); |
|||
}); |
|||
}); |
|||
}, |
|||
handleWindowResize() { |
|||
if (this.resizeTimer) { |
|||
clearTimeout(this.resizeTimer); |
|||
} |
|||
this.resizeTimer = setTimeout(() => { |
|||
this.resizeTimer = null; |
|||
if (!this.map || !this.mapReady) { |
|||
return; |
|||
} |
|||
if (this.mapVendor === "leaflet" && typeof this.map.invalidateSize === "function") { |
|||
this.map.invalidateSize(true); |
|||
} else if (this.mapVendor === "amap" && typeof this.map.resize === "function") { |
|||
this.map.resize(); |
|||
} |
|||
this.renderMapPoints(); |
|||
}, 120); |
|||
}, |
|||
resolveDefaultProvider() { |
|||
if (this.hasGoogleKey) { |
|||
return "google"; |
|||
} |
|||
if (this.hasAmapKey) { |
|||
return "amap"; |
|||
} |
|||
if (this.hasMaptilerKey) { |
|||
return "maptiler"; |
|||
} |
|||
|
|||
|
|||
return "maptiler"; |
|||
}, |
|||
async fetchMapConfig() { |
|||
try { |
|||
const response = await getDeviceTrajectoryMapConfig(); |
|||
this.mapConfig = (response && response.data) || {}; |
|||
} catch (error) { |
|||
this.mapConfig = {}; |
|||
this.$message.warning((error && error.message) || this.$t("dashboard.overview.message.mapConfigLoadFailed")); |
|||
} |
|||
}, |
|||
async handleProviderChange() { |
|||
await this.initCurrentMap(); |
|||
this.renderMapPoints(); |
|||
}, |
|||
async loadDashboardData() { |
|||
this.loading = true; |
|||
try { |
|||
const response = await getDashboardOverview(); |
|||
const data = response && response.data ? response.data : {}; |
|||
this.stats = { |
|||
total: Number(data.totalCount) || 0, |
|||
enabled: Number(data.enabledCount) || 0, |
|||
disabled: Number(data.disabledCount) || 0, |
|||
claimed: Number(data.claimedCount) || 0, |
|||
unclaimed: Number(data.unclaimedCount) || 0, |
|||
}; |
|||
this.devicePoints = this.buildDevicePoints(data.points); |
|||
this.renderMapPoints(); |
|||
} catch (error) { |
|||
this.stats = getDefaultStats(); |
|||
this.devicePoints = []; |
|||
this.clearMapMarkers(); |
|||
this.$message.error((error && error.message) || this.$t("dashboard.overview.message.dataLoadFailed")); |
|||
} finally { |
|||
this.loading = false; |
|||
} |
|||
}, |
|||
buildDevicePoints(points) { |
|||
if (!Array.isArray(points)) { |
|||
return []; |
|||
} |
|||
return points |
|||
.map((item) => { |
|||
const latNum = this.normalizeCoordinate(item.lastLat, "lat"); |
|||
const lngNum = this.normalizeCoordinate(item.lastLng, "lng"); |
|||
return { |
|||
id: item.id, |
|||
sn: item.sn, |
|||
alias: item.alias, |
|||
remark: item.remark, |
|||
lastLocationTime: item.lastLocationTime, |
|||
latNum, |
|||
lngNum, |
|||
}; |
|||
}) |
|||
.filter((item) => item.latNum !== null && item.lngNum !== null); |
|||
}, |
|||
normalizeCoordinate(value, type) { |
|||
if (value === undefined || value === null || value === "") { |
|||
return null; |
|||
} |
|||
const numberValue = Number(value); |
|||
if (!Number.isFinite(numberValue)) { |
|||
return null; |
|||
} |
|||
const directLimit = type === "lat" ? 90 : 180; |
|||
const normalizedValue = |
|||
Math.abs(numberValue) > directLimit ? numberValue / 10000000 : numberValue; |
|||
if (Math.abs(normalizedValue) > directLimit) { |
|||
return null; |
|||
} |
|||
return Number(normalizedValue.toFixed(7)); |
|||
}, |
|||
formatDateTime(value) { |
|||
if (!value) { |
|||
return "-"; |
|||
} |
|||
return this.parseTime ? this.parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : String(value); |
|||
}, |
|||
async initCurrentMap() { |
|||
this.mapLoadError = ""; |
|||
if (!this.$refs.map) { |
|||
return; |
|||
} |
|||
if (this.mapProvider === "maptiler") { |
|||
await this.initLeafletMap("maptiler"); |
|||
return; |
|||
} |
|||
if (this.mapProvider === "google") { |
|||
await this.initLeafletMap("google"); |
|||
return; |
|||
} |
|||
await this.initAmapMap(); |
|||
}, |
|||
async initLeafletMap(provider) { |
|||
this.mapLoading = true; |
|||
try { |
|||
const L = await loadLeaflet(); |
|||
if (!this.$refs.map) { |
|||
return; |
|||
} |
|||
|
|||
if (!this.map || this.mapVendor !== "leaflet") { |
|||
this.destroyMap(); |
|||
this.mapsApi = L; |
|||
this.map = L.map(this.$refs.map, { |
|||
center: DEFAULT_CENTER, |
|||
zoom: DEFAULT_ZOOM, |
|||
zoomControl: true, |
|||
}); |
|||
this.mapVendor = "leaflet"; |
|||
} else { |
|||
this.mapsApi = L; |
|||
if (this.tileLayer && typeof this.map.removeLayer === "function") { |
|||
this.map.removeLayer(this.tileLayer); |
|||
} |
|||
} |
|||
|
|||
if (provider === "maptiler" && !this.hasMaptilerKey) { |
|||
throw new Error(this.$t("dashboard.overview.message.missingMaptilerKey")); |
|||
} |
|||
if (provider === "google" && !this.hasGoogleKey) { |
|||
throw new Error(this.$t("dashboard.overview.message.missingGoogleKey")); |
|||
} |
|||
|
|||
const tileUrl = |
|||
provider === "maptiler" |
|||
? LEAFLET_MAPTILER_TILE_URL + encodeURIComponent(this.mapConfig.maptilerKey) |
|||
: LEAFLET_GOOGLE_TILE_URL; |
|||
const tileOptions = |
|||
provider === "maptiler" |
|||
? { |
|||
attribution: |
|||
'© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a>', |
|||
maxZoom: LEAFLET_MAX_ZOOM, |
|||
maxNativeZoom: LEAFLET_MAX_ZOOM, |
|||
tileSize: 256, |
|||
detectRetina: false, |
|||
} |
|||
: { |
|||
attribution: "© Google", |
|||
maxZoom: LEAFLET_MAX_ZOOM, |
|||
}; |
|||
|
|||
this.tileLayer = this.mapsApi.tileLayer(tileUrl, tileOptions); |
|||
this.tileLayer.addTo(this.map); |
|||
|
|||
if (typeof this.map.invalidateSize === "function") { |
|||
this.map.invalidateSize(); |
|||
this.$nextTick(() => { |
|||
setTimeout(() => { |
|||
if (this.map && typeof this.map.invalidateSize === "function") { |
|||
this.map.invalidateSize(true); |
|||
} |
|||
}, 60); |
|||
}); |
|||
} |
|||
this.mapReady = true; |
|||
this.handleWindowResize(); |
|||
} catch (error) { |
|||
this.mapReady = false; |
|||
this.mapLoadError = (error && error.message) || this.$t("dashboard.overview.message.mapLoadFailed"); |
|||
} finally { |
|||
this.mapLoading = false; |
|||
} |
|||
}, |
|||
async initAmapMap() { |
|||
this.mapLoading = true; |
|||
try { |
|||
if (!this.hasAmapKey) { |
|||
throw new Error(this.$t("dashboard.overview.message.missingAmapKey")); |
|||
} |
|||
const mapsApi = await loadAMap({ |
|||
key: this.mapConfig.gaodeKey, |
|||
securityJsCode: this.mapConfig.gaodeSecurityKey || "", |
|||
plugins: ["AMap.Scale", "AMap.ToolBar"], |
|||
}); |
|||
if (!this.$refs.map) { |
|||
return; |
|||
} |
|||
if (!this.map || this.mapVendor !== "amap") { |
|||
this.destroyMap(); |
|||
this.mapsApi = mapsApi; |
|||
this.map = new mapsApi.Map(this.$refs.map, { |
|||
center: AMAP_DEFAULT_CENTER, |
|||
zoom: DEFAULT_ZOOM, |
|||
zooms: [3, 20], |
|||
}); |
|||
if (mapsApi.Scale) { |
|||
this.map.addControl(new mapsApi.Scale()); |
|||
} |
|||
if (mapsApi.ToolBar) { |
|||
this.map.addControl(new mapsApi.ToolBar()); |
|||
} |
|||
this.mapVendor = "amap"; |
|||
} else { |
|||
this.mapsApi = mapsApi; |
|||
if (typeof this.map.resize === "function") { |
|||
this.map.resize(); |
|||
} |
|||
} |
|||
this.mapReady = true; |
|||
this.handleWindowResize(); |
|||
} catch (error) { |
|||
this.mapReady = false; |
|||
this.mapLoadError = (error && error.message) || this.$t("dashboard.overview.message.amapLoadFailed"); |
|||
} finally { |
|||
this.mapLoading = false; |
|||
} |
|||
}, |
|||
renderMapPoints() { |
|||
if (!this.mapReady || !this.map || !this.mapsApi) { |
|||
return; |
|||
} |
|||
if (this.mapVendor === "amap") { |
|||
this.renderAmapPoints(); |
|||
return; |
|||
} |
|||
this.renderLeafletPoints(); |
|||
}, |
|||
renderLeafletPoints() { |
|||
this.clearMapMarkers(); |
|||
if (!this.devicePoints.length) { |
|||
this.map.setView(DEFAULT_CENTER, DEFAULT_ZOOM); |
|||
return; |
|||
} |
|||
|
|||
const bounds = []; |
|||
this.devicePoints.forEach((point) => { |
|||
const marker = this.mapsApi.marker([point.latNum, point.lngNum], { |
|||
title: point.alias || point.sn || String(point.id || ""), |
|||
}); |
|||
marker.bindPopup(this.buildPopupContent(point)); |
|||
marker.addTo(this.map); |
|||
this.mapMarkers.push(marker); |
|||
bounds.push([point.latNum, point.lngNum]); |
|||
}); |
|||
|
|||
const latLngBounds = this.mapsApi.latLngBounds(bounds); |
|||
this.$nextTick(() => { |
|||
if (this.map && typeof this.map.invalidateSize === "function") { |
|||
this.map.invalidateSize(); |
|||
} |
|||
if (latLngBounds.isValid()) { |
|||
this.map.fitBounds(latLngBounds, { |
|||
padding: [30, 30], |
|||
maxZoom: MAP_POINT_MAX_ZOOM, |
|||
}); |
|||
} |
|||
}); |
|||
}, |
|||
renderAmapPoints() { |
|||
this.clearMapMarkers(); |
|||
if (!this.devicePoints.length) { |
|||
if (typeof this.map.setZoomAndCenter === "function") { |
|||
this.map.setZoomAndCenter(DEFAULT_ZOOM, AMAP_DEFAULT_CENTER); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
this.mapMarkers = this.devicePoints.map((point) => { |
|||
const marker = new this.mapsApi.Marker({ |
|||
position: [point.lngNum, point.latNum], |
|||
title: point.alias || point.sn || String(point.id || ""), |
|||
}); |
|||
marker.on("click", () => { |
|||
if (!this.mapInfoWindow) { |
|||
this.mapInfoWindow = new this.mapsApi.InfoWindow({ |
|||
offset: new this.mapsApi.Pixel(0, -24), |
|||
}); |
|||
} |
|||
this.mapInfoWindow.setContent(this.buildPopupContent(point)); |
|||
this.mapInfoWindow.open(this.map, [point.lngNum, point.latNum]); |
|||
}); |
|||
return marker; |
|||
}); |
|||
|
|||
if (this.mapMarkers.length) { |
|||
this.map.add(this.mapMarkers); |
|||
this.$nextTick(() => { |
|||
if (this.map && typeof this.map.setFitView === "function") { |
|||
this.map.setFitView(this.mapMarkers, false, [40, 40, 40, 40]); |
|||
} |
|||
}); |
|||
} |
|||
}, |
|||
buildPopupContent(point) { |
|||
return ( |
|||
'<div class="dashboard-map-popup">' + |
|||
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.device"))}</strong>锛?{this.escapeHtml( |
|||
point.alias || point.sn || point.id || "-" |
|||
)}</div>` + |
|||
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.time"))}</strong>锛?{this.escapeHtml( |
|||
this.formatDateTime(point.lastLocationTime) |
|||
)}</div>` + |
|||
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.remark"))}</strong>锛?{this.escapeHtml( |
|||
point.remark || "-" |
|||
)}</div>` + |
|||
`<div><strong>${this.escapeHtml(this.$t("dashboard.overview.popup.coordinates"))}</strong>锛?{this.escapeHtml( |
|||
point.latNum |
|||
)} / ${this.escapeHtml(point.lngNum)}</div>` + |
|||
"</div>" |
|||
); |
|||
}, |
|||
escapeHtml(value) { |
|||
return String(value) |
|||
.replace(/&/g, "&") |
|||
.replace(/</g, "<") |
|||
.replace(/>/g, ">") |
|||
.replace(/\"/g, """) |
|||
.replace(/'/g, "'"); |
|||
}, |
|||
clearMapMarkers() { |
|||
if (!this.map || !Array.isArray(this.mapMarkers) || !this.mapMarkers.length) { |
|||
this.mapMarkers = []; |
|||
return; |
|||
} |
|||
if (this.mapVendor === "amap") { |
|||
if (this.mapInfoWindow && typeof this.mapInfoWindow.close === "function") { |
|||
this.mapInfoWindow.close(); |
|||
} |
|||
this.mapInfoWindow = null; |
|||
if (typeof this.map.remove === "function") { |
|||
this.map.remove(this.mapMarkers); |
|||
} |
|||
this.mapMarkers = []; |
|||
return; |
|||
} |
|||
this.mapMarkers.forEach((marker) => { |
|||
if (marker && typeof marker.remove === "function") { |
|||
marker.remove(); |
|||
return; |
|||
} |
|||
if (marker && typeof this.map.removeLayer === "function") { |
|||
this.map.removeLayer(marker); |
|||
} |
|||
}); |
|||
this.mapMarkers = []; |
|||
}, |
|||
destroyMap() { |
|||
this.clearMapMarkers(); |
|||
if (this.mapVendor === "amap" && this.map && typeof this.map.destroy === "function") { |
|||
this.map.destroy(); |
|||
} else if (this.mapVendor === "leaflet" && this.map && typeof this.map.remove === "function") { |
|||
this.map.remove(); |
|||
} else if (this.$refs.map) { |
|||
this.$refs.map.innerHTML = ""; |
|||
} |
|||
this.map = null; |
|||
this.mapsApi = null; |
|||
this.mapVendor = ""; |
|||
this.tileLayer = null; |
|||
this.mapInfoWindow = null; |
|||
this.mapReady = false; |
|||
}, |
|||
}, |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.dashboard-overview { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 16px; |
|||
} |
|||
|
|||
.dashboard-stats { |
|||
display: grid; |
|||
grid-template-columns: repeat(5, minmax(0, 1fr)); |
|||
gap: 12px; |
|||
} |
|||
|
|||
.stat-card { |
|||
padding: 16px; |
|||
border-radius: 8px; |
|||
background: #f7fbff; |
|||
border: 1px solid #e1ebf5; |
|||
} |
|||
|
|||
.stat-card__label { |
|||
font-size: 13px; |
|||
color: #606266; |
|||
} |
|||
|
|||
.stat-card__value { |
|||
margin-top: 8px; |
|||
font-size: 28px; |
|||
line-height: 1; |
|||
font-weight: 700; |
|||
color: #303133; |
|||
} |
|||
|
|||
.stat-card--enabled { |
|||
background: #f1fbf5; |
|||
border-color: #d8f1e2; |
|||
} |
|||
|
|||
.stat-card--disabled { |
|||
background: #fff6f6; |
|||
border-color: #f5dddd; |
|||
} |
|||
|
|||
.stat-card--claimed { |
|||
background: #f4f8ff; |
|||
border-color: #dfe8fa; |
|||
} |
|||
|
|||
.stat-card--unclaimed { |
|||
background: #fffaf2; |
|||
border-color: #f5e8ce; |
|||
} |
|||
|
|||
.dashboard-map-panel { |
|||
background: #fff; |
|||
border: 1px solid #ebeef5; |
|||
border-radius: 8px; |
|||
padding: 14px; |
|||
} |
|||
|
|||
.dashboard-map-panel__header { |
|||
margin-bottom: 10px; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
gap: 12px; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.dashboard-map-panel__title { |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
color: #303133; |
|||
} |
|||
|
|||
.dashboard-map-panel__toolbar { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.dashboard-map-panel__toolbar-label { |
|||
font-size: 13px; |
|||
color: #606266; |
|||
} |
|||
|
|||
.dashboard-map-panel__alert { |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.dashboard-map { |
|||
position: absolute; |
|||
inset: 0; |
|||
width: 100%; |
|||
height: 100%; |
|||
max-width: 100%; |
|||
max-height: 100%; |
|||
box-sizing: border-box; |
|||
border-radius: 8px; |
|||
overflow: hidden; |
|||
border: none; |
|||
} |
|||
|
|||
.dashboard-map-shell { |
|||
position: relative; |
|||
width: 100%; |
|||
height: 560px; |
|||
max-width: 100%; |
|||
box-sizing: border-box; |
|||
border-radius: 8px; |
|||
overflow: hidden; |
|||
border: 1px solid #ebeef5; |
|||
} |
|||
|
|||
:deep(.dashboard-map-popup) { |
|||
line-height: 1.8; |
|||
font-size: 12px; |
|||
color: #303133; |
|||
} |
|||
|
|||
:deep(.dashboard-map.leaflet-container) { |
|||
position: absolute; |
|||
inset: 0; |
|||
width: 100% !important; |
|||
height: 100% !important; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
:deep(.dashboard-map .leaflet-tile), |
|||
:deep(.dashboard-map .leaflet-marker-icon), |
|||
:deep(.dashboard-map .leaflet-marker-shadow) { |
|||
max-width: none !important; |
|||
max-height: none !important; |
|||
} |
|||
|
|||
@media (max-width: 1280px) { |
|||
.dashboard-stats { |
|||
grid-template-columns: repeat(3, minmax(0, 1fr)); |
|||
} |
|||
} |
|||
|
|||
@media (max-width: 768px) { |
|||
.dashboard-stats { |
|||
grid-template-columns: repeat(2, minmax(0, 1fr)); |
|||
} |
|||
|
|||
.dashboard-map { |
|||
height: 100%; |
|||
} |
|||
|
|||
.dashboard-map-shell { |
|||
height: 420px; |
|||
} |
|||
} |
|||
</style> |
|||
@ -0,0 +1,472 @@ |
|||
<template> |
|||
<div class="app-container trajectory-page"> |
|||
<div class="trajectory-layout"> |
|||
<section class="panel left"> |
|||
<div class="panel-title">设备列表</div> |
|||
|
|||
<el-form :model="query" label-position="top" class="query-form" @submit.native.prevent> |
|||
<el-form-item label="序列号"> |
|||
<el-input |
|||
v-model="query.sn" |
|||
size="small" |
|||
clearable |
|||
placeholder="请输入序列号" |
|||
@keyup.enter.native="searchDevices" |
|||
/> |
|||
</el-form-item> |
|||
<el-form-item label="名称"> |
|||
<el-input |
|||
v-model="query.alias" |
|||
size="small" |
|||
clearable |
|||
placeholder="请输入名称" |
|||
@keyup.enter.native="searchDevices" |
|||
/> |
|||
</el-form-item> |
|||
<el-form-item label="备注"> |
|||
<el-input |
|||
v-model="query.remark" |
|||
size="small" |
|||
clearable |
|||
placeholder="请输入备注" |
|||
@keyup.enter.native="searchDevices" |
|||
/> |
|||
</el-form-item> |
|||
<div class="query-actions"> |
|||
<el-button type="primary" size="mini" @click="searchDevices">搜索</el-button> |
|||
<el-button size="mini" @click="resetDevices">重置</el-button> |
|||
</div> |
|||
</el-form> |
|||
|
|||
<div class="device-list" v-loading="loadingDevices"> |
|||
<el-empty v-if="!loadingDevices && !devices.length" description="暂无设备" :image-size="66" /> |
|||
<el-scrollbar v-else class="device-scroll"> |
|||
<div |
|||
v-for="item in devices" |
|||
:key="item.id" |
|||
class="device-item" |
|||
:class="{ active: currentDevice && currentDevice.id === item.id }" |
|||
@click="selectDevice(item)" |
|||
> |
|||
<div class="device-item__top"> |
|||
<span>{{ item.alias || item.sn || "-" }}</span> |
|||
<el-tag size="mini" :type="item.activationStatus ? 'success' : 'info'"> |
|||
{{ item.activationStatus ? "启用" : "禁用" }} |
|||
</el-tag> |
|||
</div> |
|||
<div class="device-item__line">SN:{{ item.sn || "-" }}</div> |
|||
<div class="device-item__line">时间:{{ formatTime(item.lastLocationTime) }}</div> |
|||
<div class="device-item__line ellipsis">备注:{{ item.remark || "-" }}</div> |
|||
</div> |
|||
</el-scrollbar> |
|||
</div> |
|||
|
|||
<div class="panel-pagination" v-show="total > 0"> |
|||
<pagination |
|||
:total="total" |
|||
:page.sync="query.pageNum" |
|||
:limit.sync="query.pageSize" |
|||
layout="prev, pager, next" |
|||
:pager-count="5" |
|||
:auto-scroll="false" |
|||
@pagination="getDevices" |
|||
/> |
|||
</div> |
|||
</section> |
|||
|
|||
<section class="panel center"> |
|||
<div class="panel-title">轨迹地图</div> |
|||
<div class="map-toolbar"> |
|||
<el-radio-group v-model="provider" size="small" @change="renderMap"> |
|||
<el-radio-button label="google">谷歌地图</el-radio-button> |
|||
<el-radio-button label="amap" :disabled="!hasAmap">高德地图</el-radio-button> |
|||
<el-radio-button label="maptiler" :disabled="!hasMaptiler">MapTiler</el-radio-button> |
|||
</el-radio-group> |
|||
<el-date-picker |
|||
v-model="range" |
|||
size="small" |
|||
type="datetimerange" |
|||
value-format="yyyy-MM-dd HH:mm:ss" |
|||
start-placeholder="开始时间" |
|||
end-placeholder="结束时间" |
|||
:clearable="false" |
|||
class="range" |
|||
/> |
|||
<el-button type="primary" size="mini" :loading="loadingTrack" @click="loadTrack">查询轨迹</el-button> |
|||
</div> |
|||
<el-alert v-if="mapError" :title="mapError" type="warning" :closable="false" show-icon class="map-error" /> |
|||
<div class="map-shell" v-loading="loadingTrack || loadingMap"> |
|||
<el-empty v-if="!currentDevice" description="请先选择左侧设备" :image-size="84" /> |
|||
<el-empty v-else-if="!points.length" description="当前范围暂无轨迹" :image-size="84" /> |
|||
<div v-show="currentDevice && points.length" ref="map" class="map"></div> |
|||
</div> |
|||
</section> |
|||
|
|||
<section class="panel right"> |
|||
<div class="panel-title">轨迹详情</div> |
|||
<el-empty v-if="!currentDevice" description="请选择设备" :image-size="66" /> |
|||
<template v-else> |
|||
<el-descriptions :column="1" size="mini" border class="detail-box"> |
|||
<el-descriptions-item label="设备">{{ currentDevice.alias || currentDevice.sn || "-" }}</el-descriptions-item> |
|||
<el-descriptions-item label="SN">{{ currentDevice.sn || "-" }}</el-descriptions-item> |
|||
<el-descriptions-item label="状态">{{ currentDevice.activationStatus ? "启用" : "禁用" }}</el-descriptions-item> |
|||
<el-descriptions-item label="最新地址">{{ currentDevice.lastAddress || "-" }}</el-descriptions-item> |
|||
<el-descriptions-item label="最新时间">{{ formatTime(currentDevice.lastLocationTime) }}</el-descriptions-item> |
|||
</el-descriptions> |
|||
<div class="summary">轨迹点数:{{ points.length }} | 开始:{{ startTime }} | 结束:{{ endTime }}</div> |
|||
<el-table :data="points" border size="mini" height="460"> |
|||
<el-table-column type="index" width="52" /> |
|||
<el-table-column label="位置时间" min-width="130"> |
|||
<template slot-scope="scope">{{ pointTime(scope.row) }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="经纬度" min-width="160"> |
|||
<template slot-scope="scope">{{ pointCoord(scope.row) }}</template> |
|||
</el-table-column> |
|||
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip /> |
|||
<el-table-column prop="battery" label="电量" width="70" /> |
|||
</el-table> |
|||
</template> |
|||
</section> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { listTrajectoryDevices, getDeviceTrajectory, getDeviceTrajectoryMapConfig } from "@/api/device/device"; |
|||
import { loadAMap } from "@/utils/loadAMap"; |
|||
import { loadLeaflet } from "@/utils/loadLeaflet"; |
|||
|
|||
const GOOGLE_TILE_URL = "https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}"; |
|||
const MAPTILER_URL = "https://api.maptiler.com/maps/streets/{z}/{x}/{y}.png?key="; |
|||
|
|||
export default { |
|||
name: "DeviceTrajectoryPage", |
|||
data() { |
|||
return { |
|||
query: { pageNum: 1, pageSize: 10, sn: null, alias: null, remark: null }, |
|||
devices: [], |
|||
total: 0, |
|||
loadingDevices: false, |
|||
currentDevice: null, |
|||
points: [], |
|||
loadingTrack: false, |
|||
range: [], |
|||
provider: "google", |
|||
mapConfig: {}, |
|||
loadingMap: false, |
|||
mapError: "", |
|||
map: null, |
|||
mapVendor: "", |
|||
mapsApi: null, |
|||
tileLayer: null, |
|||
overlays: [] |
|||
}; |
|||
}, |
|||
computed: { |
|||
hasAmap() { |
|||
return !!(this.mapConfig && this.mapConfig.gaodeKey); |
|||
}, |
|||
hasMaptiler() { |
|||
return !!(this.mapConfig && this.mapConfig.maptilerKey); |
|||
}, |
|||
startTime() { |
|||
return this.points.length ? this.pointTime(this.points[this.points.length - 1]) : "-"; |
|||
}, |
|||
endTime() { |
|||
return this.points.length ? this.pointTime(this.points[0]) : "-"; |
|||
} |
|||
}, |
|||
async created() { |
|||
this.initRange(); |
|||
await this.fetchMapConfig(); |
|||
await this.getDevices(); |
|||
}, |
|||
beforeDestroy() { |
|||
this.destroyMap(); |
|||
}, |
|||
methods: { |
|||
initRange() { |
|||
const now = new Date(); |
|||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0); |
|||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59); |
|||
const format = (date) => this.parseTime(date, "{y}-{m}-{d} {h}:{i}:{s}"); |
|||
this.range = [format(start), format(end)]; |
|||
}, |
|||
async fetchMapConfig() { |
|||
try { |
|||
const res = await getDeviceTrajectoryMapConfig(); |
|||
this.mapConfig = (res && res.data) || {}; |
|||
this.provider = "google"; |
|||
} catch (e) { |
|||
this.mapConfig = {}; |
|||
} |
|||
}, |
|||
async getDevices() { |
|||
this.loadingDevices = true; |
|||
try { |
|||
const res = await listTrajectoryDevices(this.query); |
|||
const data = (res && res.data) || res || {}; |
|||
this.devices = Array.isArray(data.list) ? data.list : Array.isArray(data.rows) ? data.rows : []; |
|||
this.total = Number(data.total || 0); |
|||
if (this.devices.length && (!this.currentDevice || !this.devices.find((x) => x.id === this.currentDevice.id))) { |
|||
await this.selectDevice(this.devices[0]); |
|||
} |
|||
} finally { |
|||
this.loadingDevices = false; |
|||
} |
|||
}, |
|||
searchDevices() { |
|||
this.query.pageNum = 1; |
|||
this.getDevices(); |
|||
}, |
|||
resetDevices() { |
|||
this.query = { pageNum: 1, pageSize: 10, sn: null, alias: null, remark: null }; |
|||
this.getDevices(); |
|||
}, |
|||
async selectDevice(item) { |
|||
this.currentDevice = item; |
|||
await this.loadTrack(); |
|||
}, |
|||
async loadTrack() { |
|||
if (!this.currentDevice) return; |
|||
this.loadingTrack = true; |
|||
this.mapError = ""; |
|||
try { |
|||
const res = await getDeviceTrajectory(this.currentDevice.id, { |
|||
startLocationTime: this.range[0], |
|||
endLocationTime: this.range[1] |
|||
}); |
|||
const source = Array.isArray(res && res.data) ? res.data : []; |
|||
this.points = source.map((point) => ({ |
|||
...point, |
|||
latNum: this.norm(point.lat, "lat"), |
|||
lngNum: this.norm(point.lng, "lng") |
|||
})); |
|||
await this.renderMap(); |
|||
} catch (e) { |
|||
this.points = []; |
|||
this.mapError = (e && e.message) || "轨迹加载失败"; |
|||
this.clearOverlays(); |
|||
} finally { |
|||
this.loadingTrack = false; |
|||
} |
|||
}, |
|||
async renderMap() { |
|||
if (!this.points.length) { |
|||
this.clearOverlays(); |
|||
return; |
|||
} |
|||
if (this.provider === "amap") { |
|||
await this.renderAmap(); |
|||
return; |
|||
} |
|||
await this.renderLeaflet(this.provider === "maptiler"); |
|||
}, |
|||
async renderLeaflet(useMaptiler) { |
|||
this.loadingMap = true; |
|||
try { |
|||
const L = await loadLeaflet(); |
|||
if (!this.map || this.mapVendor !== "leaflet") { |
|||
this.destroyMap(); |
|||
this.map = L.map(this.$refs.map, { center: [31.2304, 121.4737], zoom: 5, zoomControl: true }); |
|||
this.mapVendor = "leaflet"; |
|||
this.mapsApi = L; |
|||
} |
|||
this.clearOverlays(); |
|||
if (this.tileLayer) this.map.removeLayer(this.tileLayer); |
|||
|
|||
const tileUrl = useMaptiler ? MAPTILER_URL + encodeURIComponent(this.mapConfig.maptilerKey || "") : GOOGLE_TILE_URL; |
|||
this.tileLayer = L.tileLayer(tileUrl, { maxZoom: 18, detectRetina: false, tileSize: 256 }); |
|||
this.tileLayer.addTo(this.map); |
|||
|
|||
const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null); |
|||
const path = validPoints.map((point) => [point.latNum, point.lngNum]); |
|||
if (!path.length) return; |
|||
|
|||
const startPoint = validPoints[validPoints.length - 1]; |
|||
const endPoint = validPoints[0]; |
|||
const startTimeText = this.pointTime(startPoint); |
|||
const endTimeText = this.pointTime(endPoint); |
|||
|
|||
const line = L.polyline(path, { color: "#1a73e8", weight: 4 }).addTo(this.map); |
|||
const startMarker = L.marker([startPoint.latNum, startPoint.lngNum]) |
|||
.addTo(this.map) |
|||
.bindPopup(`起点<br/>${startTimeText}`) |
|||
.bindTooltip(`起点 ${startTimeText}`, { |
|||
permanent: true, |
|||
direction: "top", |
|||
offset: [0, -12], |
|||
className: "trajectory-point-tooltip" |
|||
}); |
|||
const endMarker = L.marker([endPoint.latNum, endPoint.lngNum]) |
|||
.addTo(this.map) |
|||
.bindPopup(`终点<br/>${endTimeText}`) |
|||
.bindTooltip(`终点 ${endTimeText}`, { |
|||
permanent: true, |
|||
direction: "top", |
|||
offset: [0, -12], |
|||
className: "trajectory-point-tooltip" |
|||
}); |
|||
|
|||
this.overlays = [line, startMarker, endMarker]; |
|||
this.map.fitBounds(L.latLngBounds(path), { padding: [30, 30], maxZoom: 16 }); |
|||
this.map.invalidateSize(true); |
|||
} catch (e) { |
|||
this.mapError = (e && e.message) || "地图加载失败"; |
|||
} finally { |
|||
this.loadingMap = false; |
|||
} |
|||
}, |
|||
async renderAmap() { |
|||
this.loadingMap = true; |
|||
try { |
|||
if (!this.hasAmap) throw new Error("当前企业未配置高德地图 Key"); |
|||
const AMap = await loadAMap({ |
|||
key: this.mapConfig.gaodeKey, |
|||
securityJsCode: this.mapConfig.gaodeSecurityKey || "", |
|||
plugins: ["AMap.Scale"] |
|||
}); |
|||
if (!this.map || this.mapVendor !== "amap") { |
|||
this.destroyMap(); |
|||
this.map = new AMap.Map(this.$refs.map, { center: [121.4737, 31.2304], zoom: 5, resizeEnable: true }); |
|||
this.mapVendor = "amap"; |
|||
this.mapsApi = AMap; |
|||
} |
|||
this.clearOverlays(); |
|||
|
|||
const validPoints = this.points.filter((point) => point.latNum !== null && point.lngNum !== null); |
|||
const path = validPoints.map((point) => [point.lngNum, point.latNum]); |
|||
if (!path.length) return; |
|||
|
|||
const startPoint = validPoints[validPoints.length - 1]; |
|||
const endPoint = validPoints[0]; |
|||
const startTimeText = this.pointTime(startPoint); |
|||
const endTimeText = this.pointTime(endPoint); |
|||
|
|||
const line = new AMap.Polyline({ |
|||
path, |
|||
strokeColor: "#1a73e8", |
|||
strokeOpacity: 0.9, |
|||
strokeWeight: 5, |
|||
showDir: true |
|||
}); |
|||
const startMarker = new AMap.Marker({ |
|||
position: [startPoint.lngNum, startPoint.latNum], |
|||
label: { content: `起点 ${startTimeText}`, direction: "top" } |
|||
}); |
|||
const endMarker = new AMap.Marker({ |
|||
position: [endPoint.lngNum, endPoint.latNum], |
|||
label: { content: `终点 ${endTimeText}`, direction: "top" } |
|||
}); |
|||
|
|||
this.map.add([line, startMarker, endMarker]); |
|||
this.overlays = [line, startMarker, endMarker]; |
|||
this.map.setFitView(this.overlays, false, [50, 50, 50, 50]); |
|||
} catch (e) { |
|||
this.mapError = (e && e.message) || "高德地图加载失败"; |
|||
} finally { |
|||
this.loadingMap = false; |
|||
} |
|||
}, |
|||
clearOverlays() { |
|||
if (!this.map || !this.overlays.length) return; |
|||
if (this.mapVendor === "leaflet") { |
|||
this.overlays.forEach((overlay) => this.map.removeLayer(overlay)); |
|||
} else if (this.mapVendor === "amap") { |
|||
this.map.remove(this.overlays); |
|||
} |
|||
this.overlays = []; |
|||
}, |
|||
destroyMap() { |
|||
this.clearOverlays(); |
|||
if (!this.map) return; |
|||
if (this.mapVendor === "leaflet") this.map.remove(); |
|||
else if (this.mapVendor === "amap") this.map.destroy(); |
|||
this.map = null; |
|||
this.mapVendor = ""; |
|||
this.mapsApi = null; |
|||
this.tileLayer = null; |
|||
}, |
|||
formatTime(value) { |
|||
return value ? this.parseTime(value, "{y}-{m}-{d} {h}:{i}:{s}") : "-"; |
|||
}, |
|||
pointTime(point) { |
|||
return this.formatTime(point.locationTime || point.reportedTime || point.createTime); |
|||
}, |
|||
pointCoord(point) { |
|||
const lat = point && (point.lat !== undefined ? point.lat : point.latNum); |
|||
const lng = point && (point.lng !== undefined ? point.lng : point.lngNum); |
|||
return `${this.formatCoordinateValue(lat)} / ${this.formatCoordinateValue(lng)}`; |
|||
}, |
|||
formatCoordinateValue(value) { |
|||
if (value === null || value === undefined) return "-"; |
|||
const strValue = String(value).trim(); |
|||
if (!strValue || strValue === "-") return "-"; |
|||
const match = strValue.match(/^([+-]?\d+)(?:\.(\d+))?$/); |
|||
if (!match) return "-"; |
|||
const integerPart = match[1]; |
|||
const decimalPart = match[2] || ""; |
|||
const fixedDecimal = decimalPart.slice(0, 2).padEnd(2, "0"); |
|||
return `${integerPart}.${fixedDecimal}`; |
|||
}, |
|||
norm(value, type) { |
|||
if (value === undefined || value === null || value === "") return null; |
|||
const num = Number(value); |
|||
if (!Number.isFinite(num)) return null; |
|||
const max = type === "lat" ? 90 : 180; |
|||
const normalized = Math.abs(num) > max ? num / 10000000 : num; |
|||
if (Math.abs(normalized) > max) return null; |
|||
return Number(normalized.toFixed(7)); |
|||
} |
|||
} |
|||
}; |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.trajectory-layout { display: grid; grid-template-columns: 340px minmax(0, 1fr) 380px; gap: 14px; min-height: calc(100vh - 170px); } |
|||
.panel { background: #fff; border: 1px solid #e8edf3; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 10px rgba(31,45,61,.05); } |
|||
.left { display: flex; flex-direction: column; min-height: 0; } |
|||
.panel-title { font-size: 16px; font-weight: 600; color: #1f2d3d; padding: 14px 16px; border-bottom: 1px solid #eef2f7; background: linear-gradient(180deg,#f8fbff 0,#fff 100%); } |
|||
.query-form { padding: 12px 14px 0; } |
|||
.query-actions { display: flex; gap: 8px; margin-bottom: 10px; } |
|||
.device-list { flex: 1; min-height: 240px; padding: 0 10px 10px; overflow: hidden; } |
|||
.device-scroll { height: 100%; } |
|||
.panel-pagination { flex: 0 0 auto; padding: 8px 10px 10px; border-top: 1px solid #eef2f7; background: #fff; } |
|||
.panel-pagination :deep(.pagination-container) { margin-top: 0; } |
|||
.panel-pagination :deep(.el-pagination) { display: flex; justify-content: center; } |
|||
.device-item { border: 1px solid #eef2f7; border-radius: 8px; padding: 10px 12px; margin-bottom: 10px; cursor: pointer; transition: all .2s ease; } |
|||
.device-item:hover { border-color: #c6e2ff; box-shadow: 0 2px 8px rgba(64,158,255,.12); } |
|||
.device-item.active { border-color: #409eff; background: #ecf5ff; } |
|||
.device-item__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 14px; font-weight: 600; color: #303133; } |
|||
.device-item__line { font-size: 12px; line-height: 1.6; color: #606266; } |
|||
.ellipsis { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } |
|||
.map-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid #eef2f7; } |
|||
.range { width: 380px; max-width: 100%; } |
|||
.map-error { margin: 10px 12px 0; } |
|||
.map-shell { position: relative; min-height: 520px; margin: 10px 12px 12px; border: 1px solid #ebeef5; border-radius: 10px; background: #f7f9fc; overflow: hidden; } |
|||
.map { position: absolute; inset: 0; width: 100%; height: 100%; } |
|||
.detail-box { margin: 12px; } |
|||
.summary { margin: 0 12px 12px; font-size: 12px; color: #606266; line-height: 1.6; background: #fafcff; border: 1px solid #e8edf3; border-radius: 8px; padding: 8px 10px; } |
|||
.right :deep(.el-table) { margin: 0 12px 12px; } |
|||
:deep(.map.leaflet-container) { position: absolute; inset: 0; width: 100% !important; height: 100% !important; overflow: hidden; } |
|||
:deep(.map .leaflet-tile), |
|||
:deep(.map .leaflet-marker-icon), |
|||
:deep(.map .leaflet-marker-shadow) { max-width: none !important; max-height: none !important; } |
|||
:deep(.trajectory-point-tooltip) { |
|||
background: rgba(32, 45, 64, 0.9); |
|||
color: #fff; |
|||
border: none; |
|||
border-radius: 4px; |
|||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); |
|||
padding: 3px 6px; |
|||
font-size: 12px; |
|||
line-height: 1.3; |
|||
} |
|||
:deep(.trajectory-point-tooltip:before) { border-top-color: rgba(32, 45, 64, 0.9); } |
|||
@media (max-width: 1280px) { |
|||
.trajectory-layout { grid-template-columns: 1fr; min-height: auto; } |
|||
.left { min-height: 0; } |
|||
.device-list { min-height: 320px; } |
|||
.map-shell { min-height: 460px; } |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue