hookehuyr

feat(底部导航): 重构小程序 WebView 底部导航组件

- 新增 JlsBottomNav 组件目录,包含配置加载、状态管理、预览工具等模块
- 统一底部导航数据源,支持 API 动态配置与本地 mock 预览
- 重构 activeTab 解析逻辑,兼容新旧 key 映射关系
- 优化组件样式,支持滚动布局与响应式设计
- 更新组件类型声明,移除未使用的 Element Plus 组件
......@@ -13,10 +13,6 @@ declare module '@vue/runtime-core' {
AudioBackground1: typeof import('./src/components/audioBackground1.vue')['default']
AudioList: typeof import('./src/components/audioList.vue')['default']
BottomNav: typeof import('./src/components/BottomNav.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElSelect: typeof import('element-plus/es')['ElSelect']
Floor: typeof import('./src/components/Floor/index.vue')['default']
InfoPopup: typeof import('./src/components/InfoPopup.vue')['default']
InfoPopupLite: typeof import('./src/components/InfoPopupLite.vue')['default']
......@@ -25,25 +21,20 @@ declare module '@vue/runtime-core' {
InfoWindowLite: typeof import('./src/components/InfoWindowLite.vue')['default']
InfoWindowWarn: typeof import('./src/components/InfoWindowWarn.vue')['default']
InfoWindowYard: typeof import('./src/components/InfoWindowYard.vue')['default']
JlsBottomNav: typeof import('./src/components/JlsBottomNav.vue')['default']
JlsBottomNav: typeof import('./src/components/JlsBottomNav/JlsBottomNav.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
SvgIcon: typeof import('./src/components/Floor/svgIcon.vue')['default']
VanBackTop: typeof import('vant/es')['BackTop']
VanButton: typeof import('vant/es')['Button']
VanCol: typeof import('vant/es')['Col']
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
VanDialog: typeof import('vant/es')['Dialog']
VanField: typeof import('vant/es')['Field']
VanFloatingPanel: typeof import('vant/es')['FloatingPanel']
VanIcon: typeof import('vant/es')['Icon']
VanImage: typeof import('vant/es')['Image']
VanImagePreview: typeof import('vant/es')['ImagePreview']
VanLoading: typeof import('vant/es')['Loading']
VanOverlay: typeof import('vant/es')['Overlay']
VanPopup: typeof import('vant/es')['Popup']
VanRow: typeof import('vant/es')['Row']
VanSlider: typeof import('vant/es')['Slider']
VanSwipe: typeof import('vant/es')['Swipe']
VanSwipeItem: typeof import('vant/es')['SwipeItem']
VanTab: typeof import('vant/es')['Tab']
......
<template>
<div v-if="isMiniProgramWebView" class="bottom-nav jls-bottom-nav">
<div
v-for="item in navItems"
:key="item.path"
@click="navigate(item.path)"
:class="['nav-item', isActive(item.name) ? 'nav-item-active' : 'nav-item-inactive']"
>
<div class="nav-item-inner">
<div class="nav-icon">
<component :is="item.icon" size="18" :color="getIconColor(item.name)" />
</div>
<span class="nav-label">{{ item.label }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import HomeIcon from '@nutui/icons-vue/dist/es/icons/Home.js';
import MessageIcon from '@nutui/icons-vue/dist/es/icons/Message.js';
import MyIcon from '@nutui/icons-vue/dist/es/icons/My.js';
import wx from 'weixin-js-sdk';
import { resolveCheckinActiveTab } from '@/views/checkin/nav-mode.js';
const ACTIVE_COLOR = '#a67939';
const INACTIVE_COLOR = '#8b95a7';
const route = useRoute();
const isMiniProgramWebView = computed(() => {
return navigator.userAgent.includes('miniProgram');
});
const navItems = [
{ name: 'home', path: '/pages/index/index', icon: HomeIcon, label: '首页' },
{ name: 'message', path: '/pages/message/index', icon: MessageIcon, label: '消息' },
{ name: 'mine', path: '/pages/mine/index', icon: MyIcon, label: '我的' },
];
const currentActiveTab = computed(() => {
return resolveCheckinActiveTab(route.query || {});
});
const isActive = (name) => {
return currentActiveTab.value === name;
};
const getIconColor = (name) => {
return isActive(name) ? ACTIVE_COLOR : INACTIVE_COLOR;
};
const navigate = (path) => {
if (!path) {
return;
}
if (wx?.miniProgram?.redirectTo) {
wx.miniProgram.redirectTo({
url: path,
fail: () => {
wx?.miniProgram?.reLaunch?.({ url: path });
},
});
return;
}
wx?.miniProgram?.reLaunch?.({ url: path });
};
</script>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: stretch;
justify-content: space-around;
box-sizing: border-box;
height: 80px;
padding: 8px 12px;
z-index: 50;
min-height: 80px;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(6px);
}
.nav-item {
display: flex;
flex: 1;
min-width: 0;
align-items: stretch;
justify-content: center;
padding: 0;
position: relative;
cursor: pointer;
transition: all 0.2s ease;
}
.nav-item-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
gap: 5px;
border-radius: 10px;
color: #8b95a7;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.nav-item-active {
color: #a67939;
}
.nav-item-inactive {
color: #8b95a7;
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
line-height: 1;
transition: transform 0.2s ease;
}
.nav-label {
font-size: 11px;
margin-top: 0;
line-height: 1.2;
font-weight: 600;
}
.jls-bottom-nav .nav-item-active .nav-item-inner {
color: #a67939;
}
@media screen and (min-width: 768px) {
.bottom-nav {
height: 80px;
min-height: 80px;
padding: 8px 16px;
}
.nav-item-inner {
gap: 6px;
}
.nav-icon {
width: 20px;
height: 20px;
}
.nav-label {
font-size: 12px;
}
}
@media (hover: hover) {
.nav-item:hover .nav-item-inner {
transform: translateY(-1px);
}
}
</style>
<template>
<div v-if="visible" class="jls-bottom-nav">
<div ref="panelRef" class="jls-bottom-nav__panel" @scroll="syncScrollState">
<div class="jls-bottom-nav__content" :class="{ 'is-scrollable': isScrollable }">
<button
v-for="item in tabItems"
:key="item.key"
type="button"
class="jls-bottom-nav__item"
:class="{ 'is-active': isActive(item.key), 'is-scrollable': isScrollable }"
:style="itemStyle"
@click="navigate(item)"
>
<span class="jls-bottom-nav__item-inner">
<span class="jls-bottom-nav__icon">
<i class="jls-bottom-nav__icon-font" :class="getIconClasses(item)" aria-hidden="true"></i>
</span>
<span class="jls-bottom-nav__label">{{ item.title }}</span>
</span>
</button>
</div>
</div>
<div v-if="showScrollFade" class="jls-bottom-nav__fade" />
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import wx from 'weixin-js-sdk';
import './iconfont.css';
import { useJlsTabbar } from './useTabbar';
import { getTabbarIconClasses } from './tabbar.utils';
import { resolveJlsCheckinActiveTab } from './nav-state';
const SCROLLABLE_ITEM_WIDTH = 92;
const defaultLoadOptions = Object.freeze({
useMock: false,
mockData: null,
});
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
loadOptions: {
type: Object,
default: () => ({
useMock: false,
mockData: null,
}),
},
});
const route = useRoute();
const panelRef = ref(null);
const isScrolledToEnd = ref(false);
const tabbar = useJlsTabbar();
const tabItems = computed(() => tabbar.visibleTabItems.value);
const isScrollable = computed(() => tabItems.value.length > 4);
const itemStyle = computed(() => {
if (!isScrollable.value) {
return null;
}
return {
width: `${SCROLLABLE_ITEM_WIDTH}px`,
minWidth: `${SCROLLABLE_ITEM_WIDTH}px`,
};
});
const showScrollFade = computed(() => isScrollable.value && !isScrolledToEnd.value);
const currentActiveTab = computed(() => resolveJlsCheckinActiveTab(route.query || {}));
const isActive = (key) => key === currentActiveTab.value;
const getIconClasses = (item) => getTabbarIconClasses(item);
const syncScrollState = () => {
const panel = panelRef.value;
if (!panel || !isScrollable.value) {
isScrolledToEnd.value = false;
return;
}
const maxScrollLeft = Math.max(panel.scrollWidth - panel.clientWidth, 0);
isScrolledToEnd.value = panel.scrollLeft >= maxScrollLeft - 4;
};
const navigate = (item) => {
const targetUrl = tabbar.resolveTargetUrl(item);
if (!targetUrl || isActive(item?.key)) {
return;
}
if (wx?.miniProgram?.redirectTo) {
wx.miniProgram.redirectTo({
url: targetUrl,
fail: () => {
wx?.miniProgram?.reLaunch?.({ url: targetUrl });
},
});
return;
}
wx?.miniProgram?.reLaunch?.({ url: targetUrl });
};
watch(
() => tabItems.value.length,
async () => {
await nextTick();
syncScrollState();
},
{ immediate: true },
);
onMounted(async () => {
if (!props.visible) {
return;
}
await tabbar.ensureLoaded(props.loadOptions || defaultLoadOptions);
await nextTick();
syncScrollState();
});
</script>
<style scoped>
.jls-bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 50;
height: 80px;
min-height: 80px;
background: rgba(255, 255, 255, 0.98);
border-top: 1px solid rgba(166, 121, 57, 0.12);
backdrop-filter: blur(6px);
overflow: hidden;
}
.jls-bottom-nav__panel {
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
}
.jls-bottom-nav__panel::-webkit-scrollbar {
display: none;
}
.jls-bottom-nav__content {
display: flex;
align-items: stretch;
justify-content: space-around;
box-sizing: border-box;
height: 100%;
padding: 8px 12px;
}
.jls-bottom-nav__content.is-scrollable {
justify-content: flex-start;
width: max-content;
min-width: 100%;
}
.jls-bottom-nav__item {
display: flex;
flex: 1;
min-width: 0;
align-items: stretch;
justify-content: center;
padding: 0;
border: 0;
background: transparent;
color: #8b95a7;
cursor: pointer;
}
.jls-bottom-nav__item.is-scrollable {
flex: 0 0 auto;
}
.jls-bottom-nav__item-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
gap: 5px;
border-radius: 10px;
color: inherit;
transition: color 0.2s ease, transform 0.2s ease;
}
.jls-bottom-nav__item.is-active {
color: #a67939;
}
.jls-bottom-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
line-height: 1;
}
.jls-bottom-nav__icon-font {
font-size: 18px;
line-height: 1;
}
.jls-bottom-nav__label {
font-size: 11px;
line-height: 1.2;
font-weight: 600;
}
.jls-bottom-nav__fade {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 48px;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.36) 42%,
rgba(255, 255, 255, 0.98) 100%
);
}
@media screen and (min-width: 768px) {
.jls-bottom-nav__content {
padding: 8px 16px;
}
.jls-bottom-nav__item-inner {
gap: 6px;
}
.jls-bottom-nav__icon {
width: 20px;
height: 20px;
}
.jls-bottom-nav__icon-font {
font-size: 20px;
}
.jls-bottom-nav__label {
font-size: 12px;
}
}
@media (hover: hover) {
.jls-bottom-nav__item:hover .jls-bottom-nav__item-inner {
transform: translateY(-1px);
}
}
</style>
# JlsBottomNav
面向小程序 WebView 场景的底部导航组件,H5 端保留当前 `80px` 高度体系,并同步小程序侧的底栏数据来源、显示规则和 icon 使用方式。
## 文件说明
- [JlsBottomNav.vue](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/JlsBottomNav.vue)
组件主文件,只负责渲染、激活态、高亮、跳转和滚动表现。
- [tabbar.api.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/tabbar.api.js)
底栏配置接口请求,当前使用 `/srv/?a=app_menu`
- [tabbar.utils.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/tabbar.utils.js)
底栏数据归一化、默认项、顺序、icon class 解析、目标地址解析。
- [useTabbar.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/useTabbar.js)
组件内状态和加载逻辑,支持 `api` / `mock` 两种加载模式。
- [nav-state.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/nav-state.js)
`activeTab` 的新旧 key 兼容规则。
- [iconfont.css](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/iconfont.css)
小程序侧 `iconfont` 字体定义。
- [preview.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/preview.js)
预览辅助层,用于把路由 query 解析成预览参数。
- [tabbar.mock.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/tabbar.mock.js)
预览用 mock 数据。
- [index.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/index.js)
目录入口。
## 正式接入
### 1. 引入组件
```js
import JlsBottomNav from '@/components/JlsBottomNav';
```
### 2. 页面里控制显示
```vue
<JlsBottomNav
:visible="shouldShowJlsBottomNav"
:load-options="{
useMock: false,
mockData: null,
}"
/>
```
`visible``true` 时组件才会渲染并尝试加载数据。
### 3. 激活态
组件通过当前路由的 `activeTab` 识别高亮项,兼容以下写法:
- `home`
- `message` / `news`
- `application` / `list`
- `mine` / `user`
例如:
```txt
?navMode=jls&activeTab=home
?navMode=jls&activeTab=news
?navMode=jls&activeTab=list
?navMode=jls&activeTab=user
```
## Icon 规则
组件和小程序保持一致:
- `fa-` 开头:按 `font-awesome` 渲染
- 其他 class:按 `iconfont` 渲染
示例:
```js
{ icon: 'fa-home' }
{ icon: 'icon-shijian' }
```
当前仓库已全局引入 `font-awesome`。如果移植到别的项目,对方项目也需要提供 `font-awesome`,或者自行改成别的图标方案。
## 预览与 Mock
如果只是本地看效果,可以配合 [preview.js](/Users/huyirui/program/itomix/git/map-demo/src/components/JlsBottomNav/preview.js) 使用。
当前项目里的用法是:
```js
import { getJlsTabbarPreviewOptions } from '@/components/JlsBottomNav/preview';
```
它会读取:
- `jlsTabbarPreview=1`
- `jlsTabbarMock=1`
并转换成组件需要的 `visible` / `loadOptions`
示例地址:
```txt
/#/checkin/?navMode=jls&activeTab=home&jlsTabbarPreview=1&jlsTabbarMock=1
```
## 移植建议
### 最小正式版本
如果对方项目不需要预览辅助,建议带走这些文件:
- `JlsBottomNav.vue`
- `tabbar.api.js`
- `tabbar.utils.js`
- `useTabbar.js`
- `nav-state.js`
- `iconfont.css`
- `index.js`
### 可选开发辅助
如果对方也希望保留本地预览,再额外带走:
- `preview.js`
- `tabbar.mock.js`
## 注意点
- 组件默认按小程序 WebView 跳转方式调用 `wx.miniProgram.redirectTo / reLaunch`
- 当前跳转逻辑是:
- `home` 默认跳回小程序页 `/pages/index/index`
- 其他项不会直接跳裸 H5 URL,而是优先使用接口返回的 `page_url`
- 如果接口没有返回 `page_url`,则会把 `webview_url` 包装成小程序承接页地址,例如 `/pages/webview-preview/index?url=...`
- 也就是说,当前实现仍然是“首页回小程序页,其他项走小程序承接页再打开 H5”,不是“其他项直接在当前 H5 中跳转到接口 URL”。
- 当前样式尺寸以 H5 版本为准,没有照搬小程序 `rpx` 布局。
@font-face {
font-family: 'iconfont';
src:
url('https://at.alicdn.com/t/c/font_4618760_7lrp6nlwv9y.woff2?t=1778124018667') format('woff2'),
url('https://at.alicdn.com/t/c/font_4618760_7lrp6nlwv9y.woff?t=1778124018667') format('woff'),
url('https://at.alicdn.com/t/c/font_4618760_7lrp6nlwv9y.ttf?t=1778124018667') format('truetype');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-zanwuneirong:before {
content: '\e64b';
}
.icon-zhiribiao:before {
content: '\e6c2';
}
.icon-yanzhengma:before {
content: '\e6c1';
}
.icon-changdigailan:before {
content: '\e6bf';
}
.icon-queren:before {
content: '\e6c0';
}
.icon-jichengmingdanguanli:before {
content: '\e6bb';
}
.icon-shenqingbiao:before {
content: '\e6bc';
}
.icon-weixintongzhimoban:before {
content: '\e6bd';
}
.icon-gangweihuizong:before {
content: '\e6be';
}
.icon-mima:before {
content: '\e6b5';
}
.icon-shoucang:before {
content: '\e6b6';
}
.icon-daiban:before {
content: '\e6b7';
}
.icon-faqi:before {
content: '\e6b8';
}
.icon-chaosong:before {
content: '\e6b9';
}
.icon-geren:before {
content: '\e6ba';
}
.icon-zhankai:before {
content: '\e6b0';
}
.icon-fanhui:before {
content: '\e6b1';
}
.icon-guanbi:before {
content: '\e6b2';
}
.icon-guan:before {
content: '\e6b3';
}
.icon-kai:before {
content: '\e6b4';
}
.icon-bianji:before {
content: '\e6a8';
}
.icon-tianjia:before {
content: '\e6a9';
}
.icon-gouwuche:before {
content: '\e6aa';
}
.icon-rili:before {
content: '\e6ab';
}
.icon-shanchu:before {
content: '\e6ac';
}
.icon-dianhua:before {
content: '\e6ad';
}
.icon-right:before {
content: '\e6ae';
}
.icon-dui:before {
content: '\e6af';
}
.icon-left:before {
content: '\e6a7';
}
.icon-nv-01:before {
content: '\e6a5';
}
.icon-nan-01:before {
content: '\e6a6';
}
.icon-sousuo:before {
content: '\e69c';
}
.icon-nianfogongxiu:before {
content: '\e69d';
}
.icon-qita:before {
content: '\e69e';
}
.icon-huodonghaibao:before {
content: '\e69f';
}
.icon-rusizhinan:before {
content: '\e6a0';
}
.icon-renyuan:before {
content: '\e6a1';
}
.icon-changdishenqing:before {
content: '\e6a2';
}
.icon-huodongguanli:before {
content: '\e6a3';
}
.icon-changguicanxue:before {
content: '\e6a4';
}
.icon-yingyong:before {
content: '\e68d';
}
.icon-renyuandingwei:before {
content: '\e68e';
}
.icon-xiaoxi:before {
content: '\e68f';
}
.icon-guiyigongxiu:before {
content: '\e690';
}
.icon-wodebaoming:before {
content: '\e691';
}
.icon-wodeyuyue:before {
content: '\e692';
}
.icon-shaotouxiang:before {
content: '\e693';
}
.icon-xinzhongmingdan:before {
content: '\e694';
}
.icon-a-3Dloucengdaolan:before {
content: '\e695';
}
.icon-huodongkanban:before {
content: '\e696';
}
.icon-wode:before {
content: '\e697';
}
.icon-dicangfahui:before {
content: '\e698';
}
.icon-shijian:before {
content: '\e699';
}
.icon-shouye:before {
content: '\e69a';
}
.icon-nianduhuodongbiao:before {
content: '\e69b';
}
.icon-yigongmingdan:before {
content: '\e67f';
}
.icon-jingjinfoqi:before {
content: '\e680';
}
.icon-yufojie:before {
content: '\e681';
}
.icon-zhongyuanjie:before {
content: '\e682';
}
.icon-shuhuazhan:before {
content: '\e683';
}
.icon-zuchang:before {
content: '\e684';
}
.icon-wuzishenqing:before {
content: '\e685';
}
.icon-zuchangtongxunlu:before {
content: '\e686';
}
.icon-zhusushenqing:before {
content: '\e687';
}
.icon-shezhi:before {
content: '\e688';
}
.icon-yijian:before {
content: '\e689';
}
.icon-zhongyaoshebeibaojing:before {
content: '\e68a';
}
.icon-yongcanshenqing:before {
content: '\e68b';
}
.icon-shengtaifangsheng:before {
content: '\e68c';
}
.icon-VRzhanshi:before {
content: '\e67e';
}
.icon-ditudaolan:before {
content: '\e677';
}
.icon-guanyindan:before {
content: '\e678';
}
.icon-chanxiuying:before {
content: '\e679';
}
.icon-sanguiwujie:before {
content: '\e67a';
}
.icon-baguanzhaijie:before {
content: '\e67b';
}
.icon-fangsheng:before {
content: '\e67c';
}
.icon-jingxiuying:before {
content: '\e67d';
}
export { default } from './JlsBottomNav.vue';
export const JLS_CHECKIN_ACTIVE_TAB = {
HOME: 'home',
MESSAGE: 'news',
APPLICATION: 'list',
MINE: 'user',
};
const JLS_CHECKIN_ACTIVE_TAB_ALIAS_MAP = {
home: JLS_CHECKIN_ACTIVE_TAB.HOME,
message: JLS_CHECKIN_ACTIVE_TAB.MESSAGE,
news: JLS_CHECKIN_ACTIVE_TAB.MESSAGE,
application: JLS_CHECKIN_ACTIVE_TAB.APPLICATION,
list: JLS_CHECKIN_ACTIVE_TAB.APPLICATION,
mine: JLS_CHECKIN_ACTIVE_TAB.MINE,
user: JLS_CHECKIN_ACTIVE_TAB.MINE,
};
export const resolveJlsCheckinActiveTab = (query = {}) => {
const rawTab = Array.isArray(query.activeTab) ? query.activeTab[0] : query.activeTab;
const normalizedTab = String(rawTab || '').trim().toLowerCase();
return JLS_CHECKIN_ACTIVE_TAB_ALIAS_MAP[normalizedTab] || '';
};
import { getJlsTabbarMockData } from './tabbar.mock';
const getQueryValue = (query = {}, key = '') => String(query?.[key] || '');
export const getJlsTabbarPreviewOptions = (query = {}) => {
const previewEnabled = getQueryValue(query, 'jlsTabbarPreview') === '1';
const mockEnabled = getQueryValue(query, 'jlsTabbarMock') === '1';
return {
previewEnabled,
loadOptions: mockEnabled
? {
useMock: true,
mockData: getJlsTabbarMockData(),
}
: {
useMock: false,
mockData: null,
},
};
};
import { fn, fetch } from '@/api/fn';
const Api = {
TABBAR_CONFIG: '/srv/?a=app_menu',
};
export const getTabbarConfigAPI = () => fn(fetch.get(Api.TABBAR_CONFIG));
export const getJlsTabbarMockData = () => ({
home: {
title: '首页',
icon: 'fa-home',
link: '',
},
news: {
title: '资讯',
icon: 'fa-newspaper-o',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
},
list: {
title: '应用',
icon: 'fa-th-large',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
},
user: {
title: '我的',
icon: 'fa-user',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
},
calendar: {
title: '日程',
icon: 'icon-shijian',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=schedule',
},
board: {
title: '看板',
icon: 'icon-huodongkanban',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=dashboard',
},
});
const TABBAR_KEY_ALIAS_MAP = {
message: 'news',
application: 'list',
mine: 'user',
};
const KNOWN_TABBAR_ITEM_MAP = {
home: {
key: 'home',
title: '首页',
class: 'fa-home',
visible: true,
page_url: '/pages/index/index',
webview_url: '',
webview_title: '首页',
},
news: {
key: 'news',
title: '资讯',
class: 'fa-newspaper-o',
visible: true,
page_url: '',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
webview_title: '资讯',
},
list: {
key: 'list',
title: '应用',
class: 'fa-th-large',
visible: true,
page_url: '',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '应用',
},
user: {
key: 'user',
title: '我的',
class: 'fa-user',
visible: true,
page_url: '',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '我的',
},
};
const TABBAR_ORDER = ['home', 'news', 'list', 'user'];
const DEFAULT_ICON_CLASS = 'fa-circle-o';
const normalizeVisibleValue = (rawValue, fallbackValue = true) => {
if (typeof rawValue === 'boolean') {
return rawValue;
}
if (rawValue === 1 || rawValue === '1') {
return true;
}
if (rawValue === 0 || rawValue === '0') {
return false;
}
return fallbackValue;
};
export const normalizeTabbarKey = (key) => {
const normalizedKey = String(key || '').trim().toLowerCase();
return TABBAR_KEY_ALIAS_MAP[normalizedKey] || normalizedKey;
};
export const buildWebviewPreviewUrl = (targetUrl = '', pageTitle = '') => {
const normalizedUrl = String(targetUrl || '').trim();
const normalizedTitle = String(pageTitle || '').trim();
if (!normalizedUrl) {
return '/pages/webview-preview/index';
}
const queryList = [`url=${encodeURIComponent(normalizedUrl)}`];
if (normalizedTitle) {
queryList.push(`title=${encodeURIComponent(normalizedTitle)}`);
}
return `/pages/webview-preview/index?${queryList.join('&')}`;
};
const createFallbackTabbarItem = (key, title = '') => {
const normalizedKey = normalizeTabbarKey(key);
return {
key: normalizedKey,
title: title || '栏目',
class: DEFAULT_ICON_CLASS,
visible: true,
page_url: normalizedKey === 'home' ? '/pages/index/index' : '',
webview_url: '',
webview_title: title || '栏目',
};
};
export const getDefaultTabbarItem = (key) => {
const normalizedKey = normalizeTabbarKey(key);
const fallbackItem =
KNOWN_TABBAR_ITEM_MAP[normalizedKey] || createFallbackTabbarItem(normalizedKey);
return {
...fallbackItem,
};
};
export const getDefaultTabbarItems = () =>
TABBAR_ORDER.map((key) => getDefaultTabbarItem(key)).filter(Boolean);
const buildTabbarPageUrl = (key, webviewUrl, webviewTitle, rawPageUrl = '') => {
if (key === 'home') {
return rawPageUrl || '/pages/index/index';
}
if (!webviewUrl) {
return '';
}
return buildWebviewPreviewUrl(webviewUrl, webviewTitle);
};
export const normalizeTabbarItem = (rawItem = {}) => {
const normalizedKey = normalizeTabbarKey(rawItem.key || rawItem.name);
const fallbackItem = getDefaultTabbarItem(normalizedKey || rawItem.title);
const title = String(rawItem.title || fallbackItem.title || '栏目');
const webviewUrl = String(
rawItem.webview_url ||
rawItem.link_url ||
rawItem.link ||
rawItem.url ||
fallbackItem.webview_url ||
'',
);
const webviewTitle = String(
rawItem.webview_title ||
rawItem.link_title ||
rawItem.title ||
fallbackItem.webview_title ||
title,
);
return {
...fallbackItem,
...rawItem,
key: fallbackItem.key,
title,
class: String(
rawItem.class ||
rawItem.icon_class ||
rawItem.icon ||
fallbackItem.class ||
DEFAULT_ICON_CLASS,
),
webview_url: webviewUrl,
webview_title: webviewTitle,
page_url: buildTabbarPageUrl(
fallbackItem.key,
webviewUrl,
webviewTitle,
String(rawItem.page_url || fallbackItem.page_url || ''),
),
visible: normalizeVisibleValue(
rawItem.visible ?? rawItem.is_show ?? rawItem.show,
fallbackItem.visible,
),
};
};
const sortTabbarObjectEntries = (entries = []) => {
const orderMap = TABBAR_ORDER.reduce((map, key, index) => {
map[key] = index;
return map;
}, {});
return entries
.map((entry, index) => ({ entry, index }))
.sort((left, right) => {
const leftKey = normalizeTabbarKey(left.entry[0]);
const rightKey = normalizeTabbarKey(right.entry[0]);
const leftOrder = orderMap[leftKey];
const rightOrder = orderMap[rightKey];
const leftKnown = Number.isInteger(leftOrder);
const rightKnown = Number.isInteger(rightOrder);
if (leftKnown && rightKnown) {
return leftOrder - rightOrder;
}
if (leftKnown) {
return -1;
}
if (rightKnown) {
return 1;
}
return left.index - right.index;
})
.map(({ entry }) => entry);
};
const normalizeTabbarObjectItems = (rawMap = {}) => {
const objectEntries = Object.entries(rawMap).filter(
([, value]) => value && typeof value === 'object' && !Array.isArray(value),
);
if (!objectEntries.length) {
return [];
}
return sortTabbarObjectEntries(objectEntries)
.map(([key, value]) =>
normalizeTabbarItem({
...value,
key,
}),
)
.filter(Boolean);
};
export const normalizeTabbarItems = (rawItems = []) => {
if (!Array.isArray(rawItems) || !rawItems.length) {
return getDefaultTabbarItems();
}
const normalizedItems = rawItems.map((item) => normalizeTabbarItem(item)).filter(Boolean);
return normalizedItems.length ? normalizedItems : getDefaultTabbarItems();
};
export const normalizeTabbarPayload = (rawPayload = null) => {
if (Array.isArray(rawPayload)) {
return normalizeTabbarItems(rawPayload);
}
const arrayPayload = rawPayload?.tab_items || rawPayload?.tabs || rawPayload?.menus;
if (Array.isArray(arrayPayload)) {
return normalizeTabbarItems(arrayPayload);
}
const objectPayload = rawPayload?.tab_items || rawPayload?.tabs || rawPayload?.menus || rawPayload;
const normalizedItems = normalizeTabbarObjectItems(objectPayload);
return normalizedItems.length ? normalizedItems : getDefaultTabbarItems();
};
export const resolveTabbarTargetUrl = (item = {}) => {
if (item?.page_url) {
return item.page_url;
}
if (normalizeTabbarKey(item?.key) === 'home') {
return '/pages/index/index';
}
return buildWebviewPreviewUrl(item?.webview_url, item?.webview_title || item?.title);
};
export const getTabbarIconClasses = (item = {}) => {
const icon = String(item?.class || item?.icon || DEFAULT_ICON_CLASS).trim() || DEFAULT_ICON_CLASS;
const fontClass = icon.startsWith('fa-') ? 'fa' : 'iconfont';
return [fontClass, icon];
};
import { computed, reactive, readonly } from 'vue';
import { getTabbarConfigAPI } from './tabbar.api';
import {
getDefaultTabbarItem,
getDefaultTabbarItems,
normalizeTabbarKey,
normalizeTabbarPayload,
resolveTabbarTargetUrl,
} from './tabbar.utils';
const state = reactive({
tabItems: getDefaultTabbarItems(),
loaded: false,
loading: false,
loadMode: 'api',
});
let tabbarRequestPromise = null;
const applyFallbackItems = () => {
state.tabItems = getDefaultTabbarItems();
};
const resolveLoadMode = (options = {}) => (options.useMock ? 'mock' : 'api');
export const fetchTabbarConfig = async (force = false, options = {}) => {
const loadMode = resolveLoadMode(options);
if (!force && state.loaded && state.loadMode === loadMode) {
return state.tabItems;
}
if (state.loading && tabbarRequestPromise) {
return tabbarRequestPromise;
}
state.loading = true;
tabbarRequestPromise = (async () => {
try {
if (loadMode === 'mock') {
state.tabItems = normalizeTabbarPayload(options.mockData);
state.loadMode = loadMode;
return state.tabItems;
}
const response = await getTabbarConfigAPI();
if (response?.code === 1) {
state.tabItems = normalizeTabbarPayload(response?.data);
} else {
applyFallbackItems();
}
state.loadMode = loadMode;
} catch (error) {
console.error('获取底部导航配置失败:', error);
applyFallbackItems();
state.loadMode = loadMode;
} finally {
state.loaded = true;
state.loading = false;
tabbarRequestPromise = null;
}
return state.tabItems;
})();
return tabbarRequestPromise;
};
export const ensureTabbarLoaded = (options = {}) => {
const loadMode = resolveLoadMode(options);
if (state.loaded && state.loadMode === loadMode) {
return Promise.resolve(state.tabItems);
}
return fetchTabbarConfig(false, options);
};
export const useJlsTabbar = () => {
const visibleTabItems = computed(() => state.tabItems.filter((item) => item.visible !== false));
const getTabItem = (key) => {
const normalizedKey = normalizeTabbarKey(key);
return state.tabItems.find((item) => item.key === normalizedKey) || getDefaultTabbarItem(normalizedKey);
};
return {
state: readonly(state),
visibleTabItems,
getTabItem,
ensureLoaded: ensureTabbarLoaded,
fetchTabbarConfig,
resolveTargetUrl: resolveTabbarTargetUrl,
};
};
......@@ -125,7 +125,11 @@
<!-- 底部导航组件 -->
<BottomNav v-if="shouldShowLegacyBottomNav" />
<JlsBottomNav v-else-if="shouldShowJlsBottomNav" />
<JlsBottomNav
v-else-if="shouldShowJlsBottomNav"
:visible="shouldShowJlsBottomNav"
:load-options="jlsBottomNavLoadOptions"
/>
</div>
</template>
......@@ -142,13 +146,14 @@ import wx from 'weixin-js-sdk'
import pageInfo from '@/views/checkin/info.vue'
import audioBackground1 from '@/components/audioBackground1.vue'
import BottomNav from '@/components/BottomNav.vue'
import JlsBottomNav from '@/components/JlsBottomNav.vue'
import JlsBottomNav from '@/components/JlsBottomNav'
import { mapState, mapActions } from 'pinia'
import { mainStore } from '@/store'
import { parseQueryString, getAdaptiveFontSize, getAdaptivePadding } from '@/utils/tools'
import AMapLoader from '@amap/amap-jsapi-loader'
import { mapAudioAPI } from '@/api/map.js'
import { CHECKIN_NAV_MODE, resolveCheckinNavMode, getCheckinMapHeight } from '@/views/checkin/nav-mode.js'
import { getJlsTabbarPreviewOptions } from '@/components/JlsBottomNav/preview'
// 地图缓存管理器
class MapCacheManager {
......@@ -345,6 +350,12 @@ export default {
isMiniProgramWebView() {
return navigator.userAgent.includes('miniProgram');
},
isJlsBottomNavPreview() {
return getJlsTabbarPreviewOptions(this.$route?.query || {}).previewEnabled;
},
jlsBottomNavLoadOptions() {
return getJlsTabbarPreviewOptions(this.$route?.query || {}).loadOptions;
},
currentCheckinNavMode() {
return resolveCheckinNavMode(this.$route?.query || {});
},
......@@ -352,7 +363,8 @@ export default {
return this.isMiniProgramWebView && this.currentCheckinNavMode === CHECKIN_NAV_MODE.LEGACY;
},
shouldShowJlsBottomNav() {
return this.isMiniProgramWebView && this.currentCheckinNavMode === CHECKIN_NAV_MODE.JLS;
return (this.isMiniProgramWebView || this.isJlsBottomNavPreview)
&& this.currentCheckinNavMode === CHECKIN_NAV_MODE.JLS;
},
/**
* 动态计算地图高度
......@@ -360,7 +372,7 @@ export default {
*/
mapHeight() {
return getCheckinMapHeight({
isMiniProgramWebView: this.isMiniProgramWebView,
isMiniProgramWebView: this.isMiniProgramWebView || this.isJlsBottomNavPreview,
navMode: this.currentCheckinNavMode,
});
},
......
import { JLS_CHECKIN_ACTIVE_TAB, resolveJlsCheckinActiveTab } from '@/components/JlsBottomNav/nav-state.js';
export const CHECKIN_NAV_MODE = {
LEGACY: 'legacy',
JLS: 'jls',
NONE: 'none',
};
export const CHECKIN_ACTIVE_TAB = {
HOME: 'home',
MESSAGE: 'message',
MINE: 'mine',
};
export const CHECKIN_ACTIVE_TAB = JLS_CHECKIN_ACTIVE_TAB;
const NAV_HEIGHT = '80px';
......@@ -40,20 +38,5 @@ export function getCheckinMapHeight({
}
export function resolveCheckinActiveTab(query = {}) {
const rawTab = Array.isArray(query.activeTab) ? query.activeTab[0] : query.activeTab;
const normalizedTab = String(rawTab || '').trim().toLowerCase();
if (normalizedTab === CHECKIN_ACTIVE_TAB.HOME) {
return CHECKIN_ACTIVE_TAB.HOME;
}
if (normalizedTab === CHECKIN_ACTIVE_TAB.MESSAGE) {
return CHECKIN_ACTIVE_TAB.MESSAGE;
}
if (normalizedTab === CHECKIN_ACTIVE_TAB.MINE) {
return CHECKIN_ACTIVE_TAB.MINE;
}
return '';
return resolveJlsCheckinActiveTab(query);
}
......