feat: MapLibre integration + app init + deploy

- Map pages wired to real MapLibre engine (MapManager/Factory/composables)
- mapMonitoring page with device markers and popups
- App.vue with keep-alive + route transitions
- main.ts with global error handlers + MapLibre CSS
- index.html with WeChat/WxJSBridge detection
- Deployed to ygcxy.top (nginx-static via Traefik)
This commit is contained in:
2026-06-15 21:39:37 +08:00
parent 2624fb0404
commit bd7dea2456
5 changed files with 394 additions and 41 deletions

View File

@@ -8,6 +8,21 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- WeixinJSBridge 检测 — 判断是否在微信环境中运行 -->
<script>
(function () {
var ua = navigator.userAgent.toLowerCase()
var isWechat = ua.indexOf('micromessenger') !== -1
// 标记微信环境,供应用内业务逻辑判断(如微信 JSSDK 授权)
window.__IS_WEIXIN__ = isWechat
// 如果是微信环境,监听 WeixinJSBridge 就绪事件
if (isWechat) {
document.addEventListener('WeixinJSBridgeReady', function () {
window.__WEIXIN_BRIDGE_READY__ = true
}, false)
}
})()
</script>
<!-- 运行时全局配置 — 开发环境 --> <!-- 运行时全局配置 — 开发环境 -->
<script src="/config/globalConfig.dev.js"></script> <script src="/config/globalConfig.dev.js"></script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>

View File

@@ -3,13 +3,26 @@
* 应用根组件 * 应用根组件
* *
* 使用 Vant ConfigProvider 包裹路由视图, * 使用 Vant ConfigProvider 包裹路由视图,
* 提供全局主题配置路由出口 * 提供全局主题配置路由缓存 (keep-alive) 和过渡动画
*/ */
import { useAppStore } from '@/stores/app'
const appStore = useAppStore()
</script> </script>
<template> <template>
<van-config-provider> <van-config-provider>
<router-view /> <router-view v-slot="{ Component, route }">
<transition
:name="route.meta.transition as string || 'fade-slide'"
mode="out-in"
appear
>
<keep-alive :include="appStore.cachedViews">
<component :is="Component" :key="route.path" />
</keep-alive>
</transition>
</router-view>
</van-config-provider> </van-config-provider>
</template> </template>
@@ -19,4 +32,49 @@
width: 100%; width: 100%;
min-height: 100vh; min-height: 100vh;
} }
/* ── 路由过渡动画 ── */
/* 淡入 + 滑动(默认) */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(20px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(-20px);
}
/* 淡入 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 向上滑动(用于详情页等) */
.slide-up-enter-active,
.slide-up-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(30px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-30px);
}
</style> </style>

View File

@@ -42,15 +42,38 @@ import {
// ── Vant 样式(组件样式按需引入) ── // ── Vant 样式(组件样式按需引入) ──
import 'vant/lib/index.css' import 'vant/lib/index.css'
// ── MapLibre GL 样式 ──
import 'maplibre-gl/dist/maplibre-gl.css'
// ── 全局样式 ── // ── 全局样式 ──
import './styles/index.scss' import './styles/index.scss'
// ── Store ──
import { useAppStore } from './stores/app'
// ── 路由 ── // ── 路由 ──
import router from './router' import router from './router'
// ── 根组件 ── // ── 根组件 ──
import App from './App.vue' import App from './App.vue'
// ══════════════════════════════════════════════
// 全局错误处理
// ══════════════════════════════════════════════
/** 捕获未处理的 Promise 拒绝 */
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
console.error('[Yuto Water H5] 未处理的 Promise 拒绝:', event.reason)
event.preventDefault()
})
/** 捕获全局 JavaScript 错误 */
window.addEventListener('error', (event: ErrorEvent) => {
console.error('[Yuto Water H5] 全局错误:', event.message, 'at', event.filename, ':', event.lineno)
// 防止错误冒泡导致白屏
event.preventDefault()
})
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
// 创建应用 // 创建应用
// ══════════════════════════════════════════════ // ══════════════════════════════════════════════
@@ -61,6 +84,11 @@ const app = createApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
// ── 初始化暗黑模式(从 localStorage 读取并应用) ──
// useAppStore 内部已通过 watch(darkMode, ..., { immediate: true })
// 在首次创建时自动同步 dark 类名到 <html> 元素
useAppStore()
// ── 注册 Vant 组件(组件类) ── // ── 注册 Vant 组件(组件类) ──
// Toast / Dialog / ImagePreview 为函数式 API // Toast / Dialog / ImagePreview 为函数式 API
// 直接从 vant 导入即可使用,无需 app.use 注册。 // 直接从 vant 导入即可使用,无需 app.use 注册。

View File

@@ -5,13 +5,29 @@
* MapLibre 地图容器,底部工具栏可切换图层、 * MapLibre 地图容器,底部工具栏可切换图层、
* 打开弹出窗口等操作。 * 打开弹出窗口等操作。
*/ */
import { ref } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { MapManager } from '@/map/MapManager'
import { MapFactory } from '@/map/MapFactory'
import { useMap } from '@/map/composables/useMap'
import { useLayer } from '@/map/composables/useLayer'
import { usePopup } from '@/map/composables/usePopup'
import TckzPop from './tckzPop.vue' import TckzPop from './tckzPop.vue'
import YbPop from './ybPop.vue' import YbPop from './ybPop.vue'
const router = useRouter() const router = useRouter()
// ── 地图状态 ──────────────────────────────────────────────
const { map, loading, error, isReady } = useMap()
const { addLayer, removeLayer } = useLayer()
const { showPopup, hidePopup } = usePopup()
/** 地图容器 ref */
const mapContainer = ref<HTMLElement | null>(null)
/** 地图初始化错误map 本身加载失败,区别于运行错误) */
const initError = ref<string | null>(null)
/** 底部工具栏状态 */ /** 底部工具栏状态 */
const tools = [ const tools = [
{ icon: 'location-o', label: '定位' }, { icon: 'location-o', label: '定位' },
@@ -25,8 +41,60 @@ const tools = [
const showTckz = ref(false) const showTckz = ref(false)
const showYb = ref(false) const showYb = ref(false)
// ── 初始化地图 ───────────────────────────────────────────
onMounted(() => {
const manager = MapManager.getInstance()
// 如果地图已存在,复用即可
if (manager.hasMap()) return
const container = document.getElementById('ytmap-container')
if (!container) {
initError.value = '地图容器元素 #ytmap-container 未找到'
return
}
mapContainer.value = container
try {
MapFactory.createWithBasemap('osm', {
container: 'ytmap-container',
zoom: 10,
center: [104.065735, 30.659462], // 成都中心
onLoad: (m) => {
console.log('[MapPage] 地图已加载', m.getCenter())
},
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '地图初始化失败'
initError.value = msg
console.error('[MapPage] 地图创建失败:', err)
}
})
// ── 组件卸载 ─────────────────────────────────────────────
onUnmounted(() => {
MapManager.getInstance().destroyMap()
})
// ── 工具栏操作 ───────────────────────────────────────────
function handleToolClick(label: string) { function handleToolClick(label: string) {
if (label === '台账') { if (label === '定位') {
const m = map.value
if (m) {
// 尝试使用浏览器定位
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
m.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 })
},
() => {
// 定位失败,回到默认中心
m.flyTo({ center: [104.065735, 30.659462], zoom: 10 })
},
)
}
}
} else if (label === '台账') {
showTckz.value = true showTckz.value = true
} else if (label === '仪表') { } else if (label === '仪表') {
showYb.value = true showYb.value = true
@@ -38,12 +106,25 @@ function handleToolClick(label: string) {
<div class="map-page"> <div class="map-page">
<van-nav-bar title="地图" left-text="返回" left-arrow fixed placeholder @click-left="router.back()" /> <van-nav-bar title="地图" left-text="返回" left-arrow fixed placeholder @click-left="router.back()" />
<!-- MapLibre 地图容器占位 --> <!-- MapLibre 地图容器 -->
<div id="ytmap-container" class="map-container"> <div id="ytmap-container" class="map-container">
<div class="map-placeholder"> <!-- 加载状态 -->
<van-icon name="map-marked" size="64" color="#ccc" /> <div v-if="loading && !error && !initError" class="map-overlay map-loading">
<p class="placeholder-title">智慧水务地图</p> <van-loading type="spinner" size="32" color="var(--color-primary)" />
<p class="placeholder-hint">MapLibre 地图将在此处加载</p> <p class="overlay-text">地图加载中</p>
</div>
<!-- 初始化错误 -->
<div v-if="initError" class="map-overlay map-error">
<van-icon name="warning-o" size="48" color="#F44336" />
<p class="overlay-text">{{ initError }}</p>
<van-button size="small" type="primary" @click="router.back()">返回</van-button>
</div>
<!-- 运行时错误 -->
<div v-if="error" class="map-error-banner">
<van-icon name="warning-o" size="16" />
<span>{{ error }}</span>
</div> </div>
</div> </div>
@@ -85,9 +166,15 @@ function handleToolClick(label: string) {
flex: 1; flex: 1;
position: relative; position: relative;
margin: 0; margin: 0;
:deep(.maplibregl-map) {
width: 100%;
height: 100%;
}
} }
.map-placeholder { /* ── 加载 & 错误覆盖层 ── */
.map-overlay {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
@@ -95,20 +182,39 @@ function handleToolClick(label: string) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f0f3f7; background: #f0f3f7;
z-index: 5;
gap: 12px;
}
.placeholder-title { .overlay-text {
margin: 12px 0 4px 0; font-size: 14px;
font-size: 18px; color: var(--color-text-placeholder);
font-weight: 600; margin: 0;
color: var(--color-text-regular); }
}
.placeholder-hint { .map-error {
font-size: 13px; .overlay-text {
color: var(--color-text-placeholder); color: #F44336;
font-size: 14px;
} }
} }
/* ── 运行时错误横幅 ── */
.map-error-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 6;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #fff3f3;
color: #F44336;
font-size: 12px;
}
.bottom-tools { .bottom-tools {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;

View File

@@ -5,13 +5,25 @@
* 在地图上展示监测设备位置和状态, * 在地图上展示监测设备位置和状态,
* 可点击设备查看实时数据。 * 可点击设备查看实时数据。
*/ */
import { ref } from 'vue' import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import maplibregl from 'maplibre-gl'
import { MapManager } from '@/map/MapManager'
import { MapFactory } from '@/map/MapFactory'
import { useMap } from '@/map/composables/useMap'
import { usePopup } from '@/map/composables/usePopup'
const router = useRouter() const router = useRouter()
/** 地图加载状态 */ // ── 地图状态 ──────────────────────────────────────────────
const mapLoaded = ref(false) const { map, loading, error, isReady } = useMap()
const { showPopup, hidePopup } = usePopup()
/** 初始化错误 */
const initError = ref<string | null>(null)
/** 标记是否已添加设备标记 */
const markersAdded = ref(false)
/** 模拟设备点位 */ /** 模拟设备点位 */
const devicePoints = ref([ const devicePoints = ref([
@@ -27,7 +39,98 @@ const statusColorMap: Record<string, string> = {
offline: '#999', offline: '#999',
} }
/** 跳转设备详情 */ /** 存储动态创建的 Marker用于清理 */
const markers: maplibregl.Marker[] = []
// ── 添加设备点位标记 ────────────────────────────────────
function addDeviceMarkers(m: maplibregl.Map): void {
if (markersAdded.value) return
markersAdded.value = true
devicePoints.value.forEach((point) => {
const color = statusColorMap[point.status] || '#999'
const el = document.createElement('div')
el.style.cssText = `
width: 14px; height: 14px; border-radius: 50%;
background: ${color}; border: 2px solid #fff;
box-shadow: 0 1px 4px rgba(0,0,0,0.3); cursor: pointer;
`
const marker = new maplibregl.Marker({ element: el })
.setLngLat([point.lng, point.lat])
.addTo(m)
// 点击弹出设备信息
el.addEventListener('click', () => {
showPopup(
[point.lng, point.lat],
`<div class="device-popup">
<strong>${point.name}</strong><br/>
<span style="font-size:12px;color:#666">状态: ${point.status}</span><br/>
<span style="font-size:11px;color:#999">${point.lat}, ${point.lng}</span>
</div>`,
{ maxWidth: '220px', closeOnClick: true },
)
})
markers.push(marker)
})
}
function clearMarkers(): void {
markers.forEach((m) => m.remove())
markers.length = 0
markersAdded.value = false
}
// ── 初始化地图 ───────────────────────────────────────────
onMounted(() => {
const manager = MapManager.getInstance()
// 如果地图已存在,复用并添加标记
if (manager.hasMap()) {
const m = manager.getMap()
if (m) addDeviceMarkers(m)
return
}
const container = document.getElementById('mapmonitor-container')
if (!container) {
initError.value = '地图容器 #mapmonitor-container 未找到'
return
}
try {
MapFactory.createWithBasemap('osm', {
container: 'mapmonitor-container',
zoom: 10,
center: [104.0, 30.5],
onLoad: (m) => {
console.log('[MapMonitoring] 地图已加载')
addDeviceMarkers(m)
},
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '地图初始化失败'
initError.value = msg
console.error('[MapMonitoring] 地图创建失败:', err)
}
})
// ── 监听 isReady加载标记 ──────────────────────────────
watch(isReady, (ready) => {
if (ready && map.value && !markersAdded.value) {
addDeviceMarkers(map.value)
}
})
// ── 组件卸载 ─────────────────────────────────────────────
onUnmounted(() => {
clearMarkers()
hidePopup()
MapManager.getInstance().destroyMap()
})
// ── 跳转设备详情 ─────────────────────────────────────────
function goEquipmentInfo(id: number) { function goEquipmentInfo(id: number) {
router.push(`/equipmentInfo?id=${id}`) router.push(`/equipmentInfo?id=${id}`)
} }
@@ -37,11 +140,25 @@ function goEquipmentInfo(id: number) {
<div class="map-monitoring-page"> <div class="map-monitoring-page">
<van-nav-bar title="地图监控" left-text="返回" left-arrow fixed placeholder @click-left="router.back()" /> <van-nav-bar title="地图监控" left-text="返回" left-arrow fixed placeholder @click-left="router.back()" />
<!-- 地图占位区 --> <!-- MapLibre 地图容器 -->
<div class="map-placeholder"> <div id="mapmonitor-container" class="map-area">
<van-icon name="location-o" size="48" color="#ccc" /> <!-- 加载状态 -->
<p class="map-title">地图监控</p> <div v-if="!map && loading && !initError" class="map-status map-loading">
<p class="map-hint">设备点位将在此地图上展示</p> <van-loading type="spinner" size="28" color="var(--color-primary)" />
<p>地图加载中</p>
</div>
<!-- 初始化错误 -->
<div v-if="initError" class="map-status map-error-state">
<van-icon name="warning-o" size="40" color="#F44336" />
<p>{{ initError }}</p>
</div>
<!-- 运行时错误 -->
<div v-if="error" class="map-error-banner">
<van-icon name="warning-o" size="14" />
<span>{{ error }}</span>
</div>
</div> </div>
<!-- 设备点位列表面板底部浮动 --> <!-- 设备点位列表面板底部浮动 -->
@@ -82,31 +199,60 @@ function goEquipmentInfo(id: number) {
} }
} }
.map-placeholder { .map-area {
flex: 1; flex: 1;
position: relative;
margin: 8px;
border-radius: 10px;
overflow: hidden;
min-height: 300px;
:deep(.maplibregl-map) {
width: 100%;
height: 100%;
}
}
.map-status {
position: absolute;
inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #f5f7fa; background: #f5f7fa;
margin: 8px;
border-radius: 10px; border-radius: 10px;
border: 1px dashed #ddd; z-index: 5;
min-height: 300px; gap: 8px;
.map-title { p {
margin: 12px 0 4px 0; margin: 0;
font-size: 18px; font-size: 14px;
font-weight: 600;
color: var(--color-text-regular);
}
.map-hint {
font-size: 13px;
color: var(--color-text-placeholder); color: var(--color-text-placeholder);
} }
} }
.map-error-state {
p {
color: #F44336;
}
}
.map-error-banner {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 6;
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #fff3f3;
color: #F44336;
font-size: 11px;
}
.device-panel { .device-panel {
background: var(--color-bg-card); background: var(--color-bg-card);
margin: 0 8px 8px; margin: 0 8px 8px;