hookehuyr

feat: 添加 JLS 小程序底部导航支持

新增 JLS 底部导航组件,支持通过 navMode 查询参数切换导航模式。
提取导航模式解析和地图高度计算逻辑到独立模块,并添加相应测试。
更新 AGENTS.md 文档,明确运行环境要求。
...@@ -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']
......
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 +});
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 * 获取地图缩放级别
......
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 +}