hookehuyr

feat(JlsBottomNav): 优化底部导航,新增自定义激活色与滚动提示

- 移除未使用的ElMessage自动导入配置
- 调整导航栏默认高度从80px改为88px
- 新增颜色处理工具函数,支持读取接口或mock数据中的激活态颜色
- 重构组件模板与样式,优化滚动布局并添加滚动提示动画
- 完善小程序跳转逻辑,区分首页与其他页面的跳转方式
- 更新组件文档与mock测试数据
{
"globals": {
"EffectScope": true,
"ElMessage": true,
"computed": true,
"createApp": true,
"customRef": true,
......
......@@ -2,7 +2,6 @@
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const ElMessage: typeof import('element-plus/es')['ElMessage']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
......
<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 }">
<div class="jls-bottom-nav__placeholder" />
<div class="jls-bottom-nav__wrap">
<div ref="panelRef" class="jls-bottom-nav__panel" @scroll="handlePanelScroll">
<div
class="jls-bottom-nav__content"
:class="{ 'is-scrollable': isScrollable }"
:style="contentStyle"
>
<button
v-for="item in tabItems"
:key="item.key"
......@@ -11,7 +18,7 @@
:style="itemStyle"
@click="navigate(item)"
>
<span class="jls-bottom-nav__item-inner">
<span class="jls-bottom-nav__item-inner" :style="getItemInnerStyle(item)">
<span class="jls-bottom-nav__icon">
<i class="jls-bottom-nav__icon-font" :class="getIconClasses(item)" aria-hidden="true"></i>
</span>
......@@ -21,12 +28,15 @@
</div>
</div>
<div v-if="showScrollFade" class="jls-bottom-nav__fade" />
<div v-if="showScrollHint" class="jls-bottom-nav__fade">
<div v-if="showScrollArrow" class="jls-bottom-nav__hint-arrow" />
</div>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import wx from 'weixin-js-sdk';
import './iconfont.css';
......@@ -34,7 +44,10 @@ import { useJlsTabbar } from './useTabbar';
import { getTabbarIconClasses } from './tabbar.utils';
import { resolveJlsCheckinActiveTab } from './nav-state';
const SCROLLABLE_ITEM_WIDTH = 92;
const SCROLLABLE_ITEM_WIDTH = 108;
const SCROLLABLE_SIDE_PADDING = 24;
const SCROLL_HINT_STORAGE_KEY = 'jls_bottom_nav_scroll_hint_seen_v2';
const SCROLL_HINT_OFFSET = 36;
const defaultLoadOptions = Object.freeze({
useMock: false,
mockData: null,
......@@ -57,10 +70,25 @@ const props = defineProps({
const route = useRoute();
const panelRef = ref(null);
const isScrolledToEnd = ref(false);
const hasPlayedScrollHint = ref(false);
const hasUserScrolled = ref(false);
const isPlayingAutoScrollHint = ref(false);
const tabbar = useJlsTabbar();
const tabItems = computed(() => tabbar.visibleTabItems.value);
const activeColor = computed(() => tabbar.activeColor.value);
const isScrollable = computed(() => tabItems.value.length > 4);
const showScrollHint = computed(() => isScrollable.value && !isScrolledToEnd.value);
const showScrollArrow = computed(() => showScrollHint.value && !hasUserScrolled.value);
const contentStyle = computed(() => {
if (!isScrollable.value) {
return null;
}
return {
width: `${(tabItems.value.length * SCROLLABLE_ITEM_WIDTH) + SCROLLABLE_SIDE_PADDING}px`,
};
});
const itemStyle = computed(() => {
if (!isScrollable.value) {
return null;
......@@ -71,13 +99,39 @@ const itemStyle = computed(() => {
minWidth: `${SCROLLABLE_ITEM_WIDTH}px`,
};
});
const showScrollFade = computed(() => isScrollable.value && !isScrolledToEnd.value);
const currentActiveTab = computed(() => resolveJlsCheckinActiveTab(route.query || {}));
let scrollHintForwardTimer = null;
let scrollHintResetTimer = null;
const isActive = (key) => key === currentActiveTab.value;
const getIconClasses = (item) => getTabbarIconClasses(item);
const getItemInnerStyle = (item) => {
if (!isActive(item?.key)) {
return null;
}
return {
color: activeColor.value,
};
};
const clearScrollHintTimers = () => {
if (scrollHintForwardTimer) {
clearTimeout(scrollHintForwardTimer);
scrollHintForwardTimer = null;
}
if (scrollHintResetTimer) {
clearTimeout(scrollHintResetTimer);
scrollHintResetTimer = null;
}
isPlayingAutoScrollHint.value = false;
};
const syncScrollState = () => {
const panel = panelRef.value;
......@@ -90,6 +144,62 @@ const syncScrollState = () => {
isScrolledToEnd.value = panel.scrollLeft >= maxScrollLeft - 4;
};
const handlePanelScroll = () => {
const panel = panelRef.value;
if (!panel) {
isScrolledToEnd.value = false;
return;
}
syncScrollState();
if (isPlayingAutoScrollHint.value || hasUserScrolled.value) {
return;
}
if (panel.scrollLeft > 6) {
hasUserScrolled.value = true;
}
};
const playScrollHint = async () => {
if (!showScrollHint.value || hasPlayedScrollHint.value || typeof window === 'undefined') {
return;
}
if (window.localStorage?.getItem(SCROLL_HINT_STORAGE_KEY)) {
hasPlayedScrollHint.value = true;
return;
}
hasPlayedScrollHint.value = true;
await nextTick();
const panel = panelRef.value;
if (!panel) {
return;
}
isPlayingAutoScrollHint.value = true;
scrollHintForwardTimer = window.setTimeout(() => {
panel.scrollTo({
left: SCROLL_HINT_OFFSET,
behavior: 'smooth',
});
}, 240);
scrollHintResetTimer = window.setTimeout(() => {
panel.scrollTo({
left: 0,
behavior: 'smooth',
});
isPlayingAutoScrollHint.value = false;
window.localStorage?.setItem(SCROLL_HINT_STORAGE_KEY, '1');
}, 1080);
};
const navigate = (item) => {
const targetUrl = tabbar.resolveTargetUrl(item);
......@@ -97,7 +207,7 @@ const navigate = (item) => {
return;
}
if (wx?.miniProgram?.redirectTo) {
if (item?.key === 'home' && wx?.miniProgram?.redirectTo) {
wx.miniProgram.redirectTo({
url: targetUrl,
fail: () => {
......@@ -107,6 +217,21 @@ const navigate = (item) => {
return;
}
if (wx?.miniProgram?.navigateTo) {
wx.miniProgram.navigateTo({
url: targetUrl,
fail: () => {
wx?.miniProgram?.redirectTo?.({
url: targetUrl,
fail: () => {
wx?.miniProgram?.reLaunch?.({ url: targetUrl });
},
});
},
});
return;
}
wx?.miniProgram?.reLaunch?.({ url: targetUrl });
};
......@@ -114,11 +239,17 @@ watch(
() => tabItems.value.length,
async () => {
await nextTick();
syncScrollState();
handlePanelScroll();
},
{ immediate: true },
);
watch(showScrollHint, (value) => {
if (value) {
playScrollHint();
}
}, { immediate: true });
onMounted(async () => {
if (!props.visible) {
return;
......@@ -126,27 +257,38 @@ onMounted(async () => {
await tabbar.ensureLoaded(props.loadOptions || defaultLoadOptions);
await nextTick();
syncScrollState();
handlePanelScroll();
});
onBeforeUnmount(() => {
clearScrollHintTimers();
});
</script>
<style scoped>
.jls-bottom-nav {
--jls-bottom-nav-height: 88px;
}
.jls-bottom-nav__placeholder {
height: var(--jls-bottom-nav-height);
flex-shrink: 0;
}
.jls-bottom-nav__wrap {
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%;
height: var(--jls-bottom-nav-height);
background: rgba(255, 255, 255, 0.98);
border-top: 1px solid rgba(166, 121, 57, 0.12);
backdrop-filter: blur(8px);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
......@@ -160,16 +302,14 @@ onMounted(async () => {
.jls-bottom-nav__content {
display: flex;
align-items: stretch;
justify-content: space-around;
box-sizing: border-box;
height: 100%;
padding: 8px 12px;
padding: 10px 12px;
}
.jls-bottom-nav__content.is-scrollable {
justify-content: flex-start;
width: max-content;
min-width: 100%;
display: inline-flex;
white-space: nowrap;
}
.jls-bottom-nav__item {
......@@ -181,12 +321,13 @@ onMounted(async () => {
padding: 0;
border: 0;
background: transparent;
color: #8b95a7;
cursor: pointer;
}
.jls-bottom-nav__item.is-scrollable {
flex: 0 0 auto;
padding-right: 4px;
box-sizing: border-box;
}
.jls-bottom-nav__item-inner {
......@@ -195,34 +336,29 @@ onMounted(async () => {
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;
min-height: 64px;
gap: 8px;
border-radius: 12px;
color: #8b95a7;
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.jls-bottom-nav__icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
line-height: 1;
color: inherit;
}
.jls-bottom-nav__icon-font {
font-size: 18px;
font-size: 24px;
line-height: 1;
}
.jls-bottom-nav__label {
font-size: 11px;
line-height: 1.2;
font-size: 13px;
line-height: 1.25;
font-weight: 600;
}
......@@ -231,36 +367,48 @@ onMounted(async () => {
top: 0;
right: 0;
bottom: 0;
width: 48px;
pointer-events: none;
width: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.36) 42%,
rgba(255, 255, 255, 0.2) 26%,
rgba(255, 255, 255, 0.74) 68%,
rgba(255, 255, 255, 0.98) 100%
);
pointer-events: none;
}
.jls-bottom-nav__hint-arrow {
width: 8px;
height: 8px;
margin-right: 8px;
border-top: 1px solid rgba(166, 121, 57, 0.32);
border-right: 1px solid rgba(166, 121, 57, 0.32);
transform: rotate(45deg);
}
@media screen and (min-width: 768px) {
.jls-bottom-nav__content {
padding: 8px 16px;
.jls-bottom-nav {
--jls-bottom-nav-height: 92px;
}
.jls-bottom-nav__item-inner {
gap: 6px;
.jls-bottom-nav__content {
padding: 10px 16px;
}
.jls-bottom-nav__icon {
width: 20px;
height: 20px;
.jls-bottom-nav__item-inner {
gap: 9px;
}
.jls-bottom-nav__icon-font {
font-size: 20px;
font-size: 26px;
}
.jls-bottom-nav__label {
font-size: 12px;
font-size: 14px;
}
}
......
......@@ -78,6 +78,7 @@ import JlsBottomNav from '@/components/JlsBottomNav';
- `fa-` 开头:按 `font-awesome` 渲染
- 其他 class:按 `iconfont` 渲染
- 激活态颜色优先读取接口根级 `color`
示例:
......@@ -134,7 +135,9 @@ import { getJlsTabbarPreviewOptions } from '@/components/JlsBottomNav/preview';
## 注意点
- 组件默认按小程序 WebView 跳转方式调用 `wx.miniProgram.redirectTo / reLaunch`
- 组件默认按当前小程序规则跳转:
- `home` 优先走 `wx.miniProgram.redirectTo`
-`home` 优先走 `wx.miniProgram.navigateTo`,失败后再降级
- 底栏显示兜底规则是“只保留首页”:
- 只有接口成功并返回有效菜单时,才显示首页之外的其他项
- 接口空数组、失败、异常、无法归一化时,都只显示 `home`
......@@ -143,4 +146,5 @@ import { getJlsTabbarPreviewOptions } from '@/components/JlsBottomNav/preview';
- 其他项不会直接跳裸 H5 URL,而是优先使用接口返回的 `page_url`
- 如果接口没有返回 `page_url`,则会把 `webview_url` 包装成小程序承接页地址,例如 `/pages/webview-preview/index?url=...`
- 也就是说,当前实现仍然是“首页回小程序页,其他项走小程序承接页再打开 H5”,不是“其他项直接在当前 H5 中跳转到接口 URL”。
- 非首页保留页面栈的原因和小程序一致:进入 `webview-preview` 后,需要保住微信原生左上角返回按钮。
- 当前样式尺寸以 H5 版本为准,没有照搬小程序 `rpx` 布局。
......
export const getJlsTabbarMockData = () => ({
color: '#86190C',
home: {
title: '首页',
icon: 'fa-home',
......
......@@ -45,6 +45,7 @@ const KNOWN_TABBAR_ITEM_MAP = {
const TABBAR_ORDER = ['home', 'news', 'list', 'user'];
const DEFAULT_ICON_CLASS = 'fa-circle-o';
const DEFAULT_TABBAR_ACTIVE_COLOR = '#a67939';
const normalizeVisibleValue = (rawValue, fallbackValue = true) => {
if (typeof rawValue === 'boolean') {
......@@ -113,6 +114,19 @@ export const getDefaultTabbarItems = () =>
export const getHomeOnlyTabbarItems = () => [getDefaultTabbarItem('home')].filter(Boolean);
export const getDefaultTabbarActiveColor = () => DEFAULT_TABBAR_ACTIVE_COLOR;
export const normalizeTabbarColor = (rawPayload = null) => {
const rawColor = rawPayload?.color;
if (typeof rawColor !== 'string') {
return DEFAULT_TABBAR_ACTIVE_COLOR;
}
const normalizedColor = rawColor.trim();
return normalizedColor || DEFAULT_TABBAR_ACTIVE_COLOR;
};
const buildTabbarPageUrl = (key, webviewUrl, webviewTitle, rawPageUrl = '') => {
if (key === 'home') {
return rawPageUrl || '/pages/index/index';
......
import { computed, reactive, readonly } from 'vue';
import { getTabbarConfigAPI } from './tabbar.api';
import {
getDefaultTabbarActiveColor,
getDefaultTabbarItem,
getHomeOnlyTabbarItems,
normalizeTabbarColor,
normalizeTabbarKey,
normalizeTabbarPayload,
resolveTabbarTargetUrl,
......@@ -10,6 +12,7 @@ import {
const state = reactive({
tabItems: getHomeOnlyTabbarItems(),
activeColor: getDefaultTabbarActiveColor(),
loaded: false,
loading: false,
loadMode: 'api',
......@@ -19,6 +22,7 @@ let tabbarRequestPromise = null;
const applyFallbackItems = () => {
state.tabItems = getHomeOnlyTabbarItems();
state.activeColor = getDefaultTabbarActiveColor();
};
const resolveLoadMode = (options = {}) => (options.useMock ? 'mock' : 'api');
......@@ -40,6 +44,7 @@ export const fetchTabbarConfig = async (force = false, options = {}) => {
try {
if (loadMode === 'mock') {
state.tabItems = normalizeTabbarPayload(options.mockData);
state.activeColor = normalizeTabbarColor(options.mockData);
state.loadMode = loadMode;
return state.tabItems;
}
......@@ -48,6 +53,7 @@ export const fetchTabbarConfig = async (force = false, options = {}) => {
if (response?.code === 1) {
state.tabItems = normalizeTabbarPayload(response?.data);
state.activeColor = normalizeTabbarColor(response?.data);
} else {
applyFallbackItems();
}
......@@ -91,6 +97,7 @@ export const useJlsTabbar = () => {
state: readonly(state),
visibleTabItems,
getTabItem,
activeColor: computed(() => state.activeColor),
ensureLoaded: ensureTabbarLoaded,
fetchTabbarConfig,
resolveTargetUrl: resolveTabbarTargetUrl,
......
......@@ -8,7 +8,7 @@ export const CHECKIN_NAV_MODE = {
export const CHECKIN_ACTIVE_TAB = JLS_CHECKIN_ACTIVE_TAB;
const NAV_HEIGHT = '80px';
const NAV_HEIGHT = '88px';
export function resolveCheckinNavMode(query = {}) {
const rawMode = Array.isArray(query.navMode) ? query.navMode[0] : query.navMode;
......