feat: 添加 JLS 小程序底部导航支持
新增 JLS 底部导航组件,支持通过 navMode 查询参数切换导航模式。 提取导航模式解析和地图高度计算逻辑到独立模块,并添加相应测试。 更新 AGENTS.md 文档,明确运行环境要求。
Showing
6 changed files
with
277 additions
and
4 deletions
| ... | @@ -4,6 +4,7 @@ | ... | @@ -4,6 +4,7 @@ |
| 4 | 本仓库是一个基于 Vue 3 与 Vite 的地图项目。核心代码位于 `src/`:`views/` 放页面级地图与打卡流程,`components/` 放可复用组件,`api/` 放接口封装,`common/` 与 `utils/` 放共享工具。静态资源主要在 `src/assets/` 和顶层 `images/`。构建与接口辅助脚本位于 `scripts/`。现有测试样例主要放在 `src/test/`,多入口示例位于 `src/packages/mono1` 和 `src/packages/mono2`。 | 4 | 本仓库是一个基于 Vue 3 与 Vite 的地图项目。核心代码位于 `src/`:`views/` 放页面级地图与打卡流程,`components/` 放可复用组件,`api/` 放接口封装,`common/` 与 `utils/` 放共享工具。静态资源主要在 `src/assets/` 和顶层 `images/`。构建与接口辅助脚本位于 `scripts/`。现有测试样例主要放在 `src/test/`,多入口示例位于 `src/packages/mono1` 和 `src/packages/mono2`。 |
| 5 | 5 | ||
| 6 | ## 构建、测试与开发命令 | 6 | ## 构建、测试与开发命令 |
| 7 | +- 运行环境:进入仓库后先执行 `source "$HOME/.nvm/nvm.sh" && nvm use 18.13.0`,当前确认版本为 `node v18.13.0`、`npm v8.19.3`。 | ||
| 7 | - `npm run dev`:启动本地 Vite 开发服务。 | 8 | - `npm run dev`:启动本地 Vite 开发服务。 |
| 8 | - `npm run start`:以 `0.0.0.0` 暴露开发服务,便于真机联调。 | 9 | - `npm run start`:以 `0.0.0.0` 暴露开发服务,便于真机联调。 |
| 9 | - `npm run build`:按当前 `.env` 配置生成生产构建。 | 10 | - `npm run build`:按当前 `.env` 配置生成生产构建。 | ... | ... |
| ... | @@ -25,6 +25,7 @@ declare module '@vue/runtime-core' { | ... | @@ -25,6 +25,7 @@ declare module '@vue/runtime-core' { |
| 25 | InfoWindowLite: typeof import('./src/components/InfoWindowLite.vue')['default'] | 25 | InfoWindowLite: typeof import('./src/components/InfoWindowLite.vue')['default'] |
| 26 | InfoWindowWarn: typeof import('./src/components/InfoWindowWarn.vue')['default'] | 26 | InfoWindowWarn: typeof import('./src/components/InfoWindowWarn.vue')['default'] |
| 27 | InfoWindowYard: typeof import('./src/components/InfoWindowYard.vue')['default'] | 27 | InfoWindowYard: typeof import('./src/components/InfoWindowYard.vue')['default'] |
| 28 | + JlsBottomNav: typeof import('./src/components/JlsBottomNav.vue')['default'] | ||
| 28 | RouterLink: typeof import('vue-router')['RouterLink'] | 29 | RouterLink: typeof import('vue-router')['RouterLink'] |
| 29 | RouterView: typeof import('vue-router')['RouterView'] | 30 | RouterView: typeof import('vue-router')['RouterView'] |
| 30 | SvgIcon: typeof import('./src/components/Floor/svgIcon.vue')['default'] | 31 | SvgIcon: typeof import('./src/components/Floor/svgIcon.vue')['default'] | ... | ... |
scripts/checkin-nav-mode.test.cjs
0 → 100644
| 1 | +const chai = require('chai'); | ||
| 2 | + | ||
| 3 | +const expect = chai.expect; | ||
| 4 | + | ||
| 5 | +describe('checkin nav mode helpers', function () { | ||
| 6 | + it('resolves nav mode and map height from route query', async function () { | ||
| 7 | + const { | ||
| 8 | + CHECKIN_NAV_MODE, | ||
| 9 | + resolveCheckinNavMode, | ||
| 10 | + getCheckinMapHeight, | ||
| 11 | + } = await import('../src/views/checkin/nav-mode.js'); | ||
| 12 | + | ||
| 13 | + expect(resolveCheckinNavMode({})).to.equal(CHECKIN_NAV_MODE.LEGACY); | ||
| 14 | + expect(resolveCheckinNavMode({ navMode: 'jls' })).to.equal(CHECKIN_NAV_MODE.JLS); | ||
| 15 | + expect(resolveCheckinNavMode({ navMode: 'none' })).to.equal(CHECKIN_NAV_MODE.NONE); | ||
| 16 | + expect(resolveCheckinNavMode({ navMode: 'unexpected' })).to.equal( | ||
| 17 | + CHECKIN_NAV_MODE.LEGACY, | ||
| 18 | + ); | ||
| 19 | + | ||
| 20 | + expect( | ||
| 21 | + getCheckinMapHeight({ | ||
| 22 | + isMiniProgramWebView: false, | ||
| 23 | + navMode: CHECKIN_NAV_MODE.LEGACY, | ||
| 24 | + }), | ||
| 25 | + ).to.equal('100vh'); | ||
| 26 | + | ||
| 27 | + expect( | ||
| 28 | + getCheckinMapHeight({ | ||
| 29 | + isMiniProgramWebView: true, | ||
| 30 | + navMode: CHECKIN_NAV_MODE.LEGACY, | ||
| 31 | + }), | ||
| 32 | + ).to.equal('calc(100vh - 80px)'); | ||
| 33 | + | ||
| 34 | + expect( | ||
| 35 | + getCheckinMapHeight({ | ||
| 36 | + isMiniProgramWebView: true, | ||
| 37 | + navMode: CHECKIN_NAV_MODE.JLS, | ||
| 38 | + }), | ||
| 39 | + ).to.equal('calc(100vh - 80px)'); | ||
| 40 | + | ||
| 41 | + expect( | ||
| 42 | + getCheckinMapHeight({ | ||
| 43 | + isMiniProgramWebView: true, | ||
| 44 | + navMode: CHECKIN_NAV_MODE.NONE, | ||
| 45 | + }), | ||
| 46 | + ).to.equal('100vh'); | ||
| 47 | + }); | ||
| 48 | +}); |
src/components/JlsBottomNav.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div v-if="isMiniProgramWebView" class="bottom-nav jls-bottom-nav"> | ||
| 3 | + <div | ||
| 4 | + v-for="item in navItems" | ||
| 5 | + :key="item.path" | ||
| 6 | + @click="navigate(item.path)" | ||
| 7 | + :class="['nav-item', isActive(item.name) ? 'nav-item-active' : 'nav-item-inactive']" | ||
| 8 | + > | ||
| 9 | + <div class="nav-item-inner"> | ||
| 10 | + <div class="nav-icon"> | ||
| 11 | + <component :is="item.icon" size="18" :color="getIconColor(item.name)" /> | ||
| 12 | + </div> | ||
| 13 | + <span class="nav-label">{{ item.label }}</span> | ||
| 14 | + </div> | ||
| 15 | + </div> | ||
| 16 | + </div> | ||
| 17 | +</template> | ||
| 18 | + | ||
| 19 | +<script setup> | ||
| 20 | +import { computed } from 'vue'; | ||
| 21 | +import { useRoute } from 'vue-router'; | ||
| 22 | +import HomeIcon from '@nutui/icons-vue/dist/es/icons/Home.js'; | ||
| 23 | +import MessageIcon from '@nutui/icons-vue/dist/es/icons/Message.js'; | ||
| 24 | +import MyIcon from '@nutui/icons-vue/dist/es/icons/My.js'; | ||
| 25 | +import wx from 'weixin-js-sdk'; | ||
| 26 | + | ||
| 27 | +const ACTIVE_COLOR = '#a67939'; | ||
| 28 | +const INACTIVE_COLOR = '#8b95a7'; | ||
| 29 | + | ||
| 30 | +const route = useRoute(); | ||
| 31 | + | ||
| 32 | +const isMiniProgramWebView = computed(() => { | ||
| 33 | + return navigator.userAgent.includes('miniProgram'); | ||
| 34 | +}); | ||
| 35 | + | ||
| 36 | +const navItems = [ | ||
| 37 | + { name: 'home', path: '/pages/index/index', icon: HomeIcon, label: '首页' }, | ||
| 38 | + { name: 'message', path: '/pages/message/index', icon: MessageIcon, label: '消息' }, | ||
| 39 | + { name: 'mine', path: '/pages/mine/index', icon: MyIcon, label: '我的' }, | ||
| 40 | +]; | ||
| 41 | + | ||
| 42 | +const reservedActiveTab = computed(() => { | ||
| 43 | + const rawTab = Array.isArray(route.query.activeTab) ? route.query.activeTab[0] : route.query.activeTab; | ||
| 44 | + return String(rawTab || '').trim().toLowerCase(); | ||
| 45 | +}); | ||
| 46 | + | ||
| 47 | +const isActive = () => { | ||
| 48 | + // 地图页目前不属于 JLS tab 列表中的任何一项。 | ||
| 49 | + // activeTab 参数先预留,待业务确认后再决定是否映射到某个 tab 并启用高亮。 | ||
| 50 | + void reservedActiveTab.value; | ||
| 51 | + return false; | ||
| 52 | +}; | ||
| 53 | + | ||
| 54 | +const getIconColor = (name) => { | ||
| 55 | + return isActive(name) ? ACTIVE_COLOR : INACTIVE_COLOR; | ||
| 56 | +}; | ||
| 57 | + | ||
| 58 | +const navigate = (path) => { | ||
| 59 | + if (!path) { | ||
| 60 | + return; | ||
| 61 | + } | ||
| 62 | + | ||
| 63 | + if (wx?.miniProgram?.navigateTo) { | ||
| 64 | + wx.miniProgram.navigateTo({ | ||
| 65 | + url: path, | ||
| 66 | + fail: () => { | ||
| 67 | + wx?.miniProgram?.reLaunch?.({ url: path }); | ||
| 68 | + }, | ||
| 69 | + }); | ||
| 70 | + return; | ||
| 71 | + } | ||
| 72 | + | ||
| 73 | + wx?.miniProgram?.reLaunch?.({ url: path }); | ||
| 74 | +}; | ||
| 75 | +</script> | ||
| 76 | + | ||
| 77 | +<style scoped> | ||
| 78 | +.bottom-nav { | ||
| 79 | + position: fixed; | ||
| 80 | + bottom: 0; | ||
| 81 | + left: 0; | ||
| 82 | + right: 0; | ||
| 83 | + display: flex; | ||
| 84 | + align-items: stretch; | ||
| 85 | + justify-content: space-around; | ||
| 86 | + box-sizing: border-box; | ||
| 87 | + padding: 8px 12px; | ||
| 88 | + z-index: 50; | ||
| 89 | + min-height: 66px; | ||
| 90 | + background: rgba(255, 255, 255, 0.98); | ||
| 91 | + border-top: 1px solid rgba(166, 121, 57, 0.12); | ||
| 92 | + backdrop-filter: blur(6px); | ||
| 93 | +} | ||
| 94 | + | ||
| 95 | +.nav-item { | ||
| 96 | + display: flex; | ||
| 97 | + flex: 1; | ||
| 98 | + min-width: 0; | ||
| 99 | + align-items: stretch; | ||
| 100 | + justify-content: center; | ||
| 101 | + padding: 0; | ||
| 102 | + position: relative; | ||
| 103 | + cursor: pointer; | ||
| 104 | + transition: all 0.2s ease; | ||
| 105 | +} | ||
| 106 | + | ||
| 107 | +.nav-item-inner { | ||
| 108 | + display: flex; | ||
| 109 | + flex-direction: column; | ||
| 110 | + align-items: center; | ||
| 111 | + justify-content: center; | ||
| 112 | + min-height: 50px; | ||
| 113 | + gap: 5px; | ||
| 114 | + border-radius: 10px; | ||
| 115 | + color: #8b95a7; | ||
| 116 | + transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease; | ||
| 117 | +} | ||
| 118 | + | ||
| 119 | +.nav-item-active { | ||
| 120 | + color: #a67939; | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +.nav-item-inactive { | ||
| 124 | + color: #8b95a7; | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +.nav-icon { | ||
| 128 | + display: flex; | ||
| 129 | + align-items: center; | ||
| 130 | + justify-content: center; | ||
| 131 | + width: 18px; | ||
| 132 | + height: 18px; | ||
| 133 | + line-height: 1; | ||
| 134 | + transition: transform 0.2s ease; | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +.nav-label { | ||
| 138 | + font-size: 11px; | ||
| 139 | + margin-top: 0; | ||
| 140 | + line-height: 1.2; | ||
| 141 | + font-weight: 600; | ||
| 142 | +} | ||
| 143 | + | ||
| 144 | +.jls-bottom-nav .nav-item-active .nav-item-inner { | ||
| 145 | + color: #a67939; | ||
| 146 | +} | ||
| 147 | + | ||
| 148 | +@media screen and (min-width: 768px) { | ||
| 149 | + .bottom-nav { | ||
| 150 | + min-height: 72px; | ||
| 151 | + padding: 8px 16px; | ||
| 152 | + } | ||
| 153 | + | ||
| 154 | + .nav-item-inner { | ||
| 155 | + min-height: 56px; | ||
| 156 | + gap: 6px; | ||
| 157 | + } | ||
| 158 | + | ||
| 159 | + .nav-icon { | ||
| 160 | + width: 20px; | ||
| 161 | + height: 20px; | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + .nav-label { | ||
| 165 | + font-size: 12px; | ||
| 166 | + } | ||
| 167 | +} | ||
| 168 | + | ||
| 169 | +@media (hover: hover) { | ||
| 170 | + .nav-item:hover .nav-item-inner { | ||
| 171 | + transform: translateY(-1px); | ||
| 172 | + } | ||
| 173 | +} | ||
| 174 | +</style> |
| 1 | <!-- | 1 | <!-- |
| 2 | * @Date: 2023-05-19 14:54:27 | 2 | * @Date: 2023-05-19 14:54:27 |
| 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com | 3 | * @LastEditors: hookehuyr hookehuyr@gmail.com |
| 4 | - * @LastEditTime: 2026-02-10 13:58:46 | 4 | + * @LastEditTime: 2026-04-29 13:05:35 |
| 5 | * @FilePath: /map-demo/src/views/checkin/map.vue | 5 | * @FilePath: /map-demo/src/views/checkin/map.vue |
| 6 | * @Description: 公众地图主体页面 | 6 | * @Description: 公众地图主体页面 |
| 7 | --> | 7 | --> |
| ... | @@ -124,7 +124,8 @@ | ... | @@ -124,7 +124,8 @@ |
| 124 | </van-toast> | 124 | </van-toast> |
| 125 | 125 | ||
| 126 | <!-- 底部导航组件 --> | 126 | <!-- 底部导航组件 --> |
| 127 | - <BottomNav /> | 127 | + <BottomNav v-if="shouldShowLegacyBottomNav" /> |
| 128 | + <JlsBottomNav v-else-if="shouldShowJlsBottomNav" /> | ||
| 128 | </div> | 129 | </div> |
| 129 | </template> | 130 | </template> |
| 130 | 131 | ||
| ... | @@ -141,11 +142,13 @@ import wx from 'weixin-js-sdk' | ... | @@ -141,11 +142,13 @@ import wx from 'weixin-js-sdk' |
| 141 | import pageInfo from '@/views/checkin/info.vue' | 142 | import pageInfo from '@/views/checkin/info.vue' |
| 142 | import audioBackground1 from '@/components/audioBackground1.vue' | 143 | import audioBackground1 from '@/components/audioBackground1.vue' |
| 143 | import BottomNav from '@/components/BottomNav.vue' | 144 | import BottomNav from '@/components/BottomNav.vue' |
| 145 | +import JlsBottomNav from '@/components/JlsBottomNav.vue' | ||
| 144 | import { mapState, mapActions } from 'pinia' | 146 | import { mapState, mapActions } from 'pinia' |
| 145 | import { mainStore } from '@/store' | 147 | import { mainStore } from '@/store' |
| 146 | import { parseQueryString, getAdaptiveFontSize, getAdaptivePadding } from '@/utils/tools' | 148 | import { parseQueryString, getAdaptiveFontSize, getAdaptivePadding } from '@/utils/tools' |
| 147 | import AMapLoader from '@amap/amap-jsapi-loader' | 149 | import AMapLoader from '@amap/amap-jsapi-loader' |
| 148 | import { mapAudioAPI } from '@/api/map.js' | 150 | import { mapAudioAPI } from '@/api/map.js' |
| 151 | +import { CHECKIN_NAV_MODE, resolveCheckinNavMode, getCheckinMapHeight } from '@/views/checkin/nav-mode.js' | ||
| 149 | 152 | ||
| 150 | // 地图缓存管理器 | 153 | // 地图缓存管理器 |
| 151 | class MapCacheManager { | 154 | class MapCacheManager { |
| ... | @@ -332,7 +335,7 @@ window._AMapSecurityConfig = { | ... | @@ -332,7 +335,7 @@ window._AMapSecurityConfig = { |
| 332 | 335 | ||
| 333 | export default { | 336 | export default { |
| 334 | name: 'CheckinMap', | 337 | name: 'CheckinMap', |
| 335 | - components: { pageInfo, audioBackground1, BottomNav }, | 338 | + components: { pageInfo, audioBackground1, BottomNav, JlsBottomNav }, |
| 336 | computed: { | 339 | computed: { |
| 337 | ...mapState(mainStore, ['audio_entity', 'audio_src', 'audio_status']), | 340 | ...mapState(mainStore, ['audio_entity', 'audio_src', 'audio_status']), |
| 338 | /** | 341 | /** |
| ... | @@ -342,12 +345,24 @@ export default { | ... | @@ -342,12 +345,24 @@ export default { |
| 342 | isMiniProgramWebView() { | 345 | isMiniProgramWebView() { |
| 343 | return navigator.userAgent.includes('miniProgram'); | 346 | return navigator.userAgent.includes('miniProgram'); |
| 344 | }, | 347 | }, |
| 348 | + currentCheckinNavMode() { | ||
| 349 | + return resolveCheckinNavMode(this.$route?.query || {}); | ||
| 350 | + }, | ||
| 351 | + shouldShowLegacyBottomNav() { | ||
| 352 | + return this.isMiniProgramWebView && this.currentCheckinNavMode === CHECKIN_NAV_MODE.LEGACY; | ||
| 353 | + }, | ||
| 354 | + shouldShowJlsBottomNav() { | ||
| 355 | + return this.isMiniProgramWebView && this.currentCheckinNavMode === CHECKIN_NAV_MODE.JLS; | ||
| 356 | + }, | ||
| 345 | /** | 357 | /** |
| 346 | * 动态计算地图高度 | 358 | * 动态计算地图高度 |
| 347 | * @returns {string} 地图容器高度 | 359 | * @returns {string} 地图容器高度 |
| 348 | */ | 360 | */ |
| 349 | mapHeight() { | 361 | mapHeight() { |
| 350 | - return this.isMiniProgramWebView ? 'calc(100vh - 80px)' : '100vh'; | 362 | + return getCheckinMapHeight({ |
| 363 | + isMiniProgramWebView: this.isMiniProgramWebView, | ||
| 364 | + navMode: this.currentCheckinNavMode, | ||
| 365 | + }); | ||
| 351 | }, | 366 | }, |
| 352 | /** | 367 | /** |
| 353 | * 获取地图缩放级别 | 368 | * 获取地图缩放级别 | ... | ... |
src/views/checkin/nav-mode.js
0 → 100644
| 1 | +export const CHECKIN_NAV_MODE = { | ||
| 2 | + LEGACY: 'legacy', | ||
| 3 | + JLS: 'jls', | ||
| 4 | + NONE: 'none', | ||
| 5 | +}; | ||
| 6 | + | ||
| 7 | +const NAV_HEIGHT = '80px'; | ||
| 8 | + | ||
| 9 | +export function resolveCheckinNavMode(query = {}) { | ||
| 10 | + const rawMode = Array.isArray(query.navMode) ? query.navMode[0] : query.navMode; | ||
| 11 | + const normalizedMode = String(rawMode || '').trim().toLowerCase(); | ||
| 12 | + | ||
| 13 | + if (normalizedMode === CHECKIN_NAV_MODE.JLS) { | ||
| 14 | + return CHECKIN_NAV_MODE.JLS; | ||
| 15 | + } | ||
| 16 | + | ||
| 17 | + if (normalizedMode === CHECKIN_NAV_MODE.NONE) { | ||
| 18 | + return CHECKIN_NAV_MODE.NONE; | ||
| 19 | + } | ||
| 20 | + | ||
| 21 | + return CHECKIN_NAV_MODE.LEGACY; | ||
| 22 | +} | ||
| 23 | + | ||
| 24 | +export function getCheckinMapHeight({ | ||
| 25 | + isMiniProgramWebView = false, | ||
| 26 | + navMode = CHECKIN_NAV_MODE.LEGACY, | ||
| 27 | + navHeight = NAV_HEIGHT, | ||
| 28 | +} = {}) { | ||
| 29 | + if (!isMiniProgramWebView || navMode === CHECKIN_NAV_MODE.NONE) { | ||
| 30 | + return '100vh'; | ||
| 31 | + } | ||
| 32 | + | ||
| 33 | + return `calc(100vh - ${navHeight})`; | ||
| 34 | +} |
-
Please register or login to post a comment