hookehuyr

feat(tabbar): 支持后端 API 变更并重构配置处理逻辑

重构 tabbar 配置处理以适配新的后端 API 端点 `/srv/?a=app_menu`,该端点返回对象结构而非数组。
新增 `normalizeTabbarPayload` 函数统一处理数组、对象及嵌套数据结构,增强数据兼容性。
优化 tabbar 组件支持水平滚动,当项目超过 4 个时自动启用滚动布局并添加滚动提示动画。
标准化 tabbar 键名映射,统一处理 `message`、`application`、`mine` 等旧键名到新键名 `news`、`list`、`user`。
改进页面 URL 生成逻辑,非首页项目优先使用 webview 预览链接。
import { fn, fetch } from './fn'
const Api = {
TABBAR_CONFIG: '/srv/?a=tabbar&t=config',
TABBAR_CONFIG: '/srv/?a=app_menu',
}
export const getTabbarConfigAPI = () => fn(fetch.get(Api.TABBAR_CONFIG))
......
......@@ -3,12 +3,22 @@
<view class="app-tabbar__placeholder" />
<view class="app-tabbar__wrap">
<view class="app-tabbar__panel">
<scroll-view
class="app-tabbar__panel"
:scrollX="true"
:enhanced="true"
:showScrollbar="false"
:mpScrollLeft="scrollLeft"
:animated="true"
@scroll="handlePanelScroll"
>
<view class="app-tabbar__content" :class="{ 'is-scrollable': isScrollable }" :style="contentStyle">
<view
v-for="item in tabItems"
:key="item.key"
class="app-tabbar__item"
:class="{ 'is-active': isActive(item.key) }"
:class="{ 'is-active': isActive(item.key), 'is-scrollable': isScrollable }"
:style="itemStyle"
@tap="handleTabClick(item)"
>
<view class="app-tabbar__item-inner">
......@@ -22,14 +32,21 @@
</view>
</view>
</view>
</scroll-view>
<view v-if="showScrollHint" class="app-tabbar__fade">
<view v-if="showScrollArrow" class="app-tabbar__hint-arrow" />
</view>
</view>
</view>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Taro from '@tarojs/taro'
import { useTabbarStore } from '@/stores/tabbar'
import { buildWebviewPreviewUrl } from '@/utils/webview'
import { normalizeTabbarKey } from '@/utils/tabbar'
const props = defineProps({
current: {
......@@ -38,14 +55,48 @@ const props = defineProps({
},
})
const activeColor = '#a67939'
const inactiveColor = '#8b95a7'
const defaultIcon = 'fa-circle-o'
const scrollHintStorageKey = 'app_tabbar_scroll_hint_seen_v1'
const scrollHintOffset = 56
const scrollableItemWidth = 168
const scrollableSidePadding = 60
const tabbarStore = useTabbarStore()
const tabItems = computed(() => tabbarStore.visibleTabItems)
const currentKey = computed(() => normalizeTabbarKey(props.current))
const isScrollable = computed(() => tabItems.value.length > 4)
const showScrollHint = computed(() => isScrollable.value)
const showScrollArrow = computed(() => showScrollHint.value && !hasUserScrolled.value)
const contentStyle = computed(() => {
if (!isScrollable.value) {
return null
}
return {
width: `${(tabItems.value.length * scrollableItemWidth) + scrollableSidePadding}rpx`,
}
})
const itemStyle = computed(() => {
if (!isScrollable.value) {
return null
}
const isActive = (name) => name === props.current
const width = `${scrollableItemWidth}rpx`
return {
width,
minWidth: width,
}
})
const scrollLeft = ref(0)
const hasPlayedScrollHint = ref(false)
const hasUserScrolled = ref(false)
const isPlayingAutoScrollHint = ref(false)
let scrollHintForwardTimer = null
let scrollHintResetTimer = null
const isActive = (key) => key === currentKey.value
const getIconClass = (item) => {
const icon = String(item?.class || item?.icon || defaultIcon).trim() || defaultIcon
......@@ -54,15 +105,79 @@ const getIconClass = (item) => {
}
const handleTabClick = (item) => {
if (!item?.page_url || item.key === props.current) {
const targetUrl = item?.page_url
|| (item?.key !== 'home' ? buildWebviewPreviewUrl(item?.webview_url, item?.webview_title || item?.title) : '')
if (!targetUrl || isActive(item?.key)) {
return
}
Taro.redirectTo({ url: targetUrl })
}
const clearScrollHintTimers = () => {
if (scrollHintForwardTimer) {
clearTimeout(scrollHintForwardTimer)
scrollHintForwardTimer = null
}
if (scrollHintResetTimer) {
clearTimeout(scrollHintResetTimer)
scrollHintResetTimer = null
}
isPlayingAutoScrollHint.value = false
}
const handlePanelScroll = (event) => {
if (isPlayingAutoScrollHint.value || hasUserScrolled.value) {
return
}
Taro.redirectTo({ url: item.page_url })
const currentScrollLeft = Number(event?.detail?.scrollLeft || 0)
if (currentScrollLeft > 6) {
hasUserScrolled.value = true
}
}
onMounted(() => {
tabbarStore.ensureLoaded()
const playScrollHint = async () => {
if (!showScrollHint.value || hasPlayedScrollHint.value) {
return
}
const hasSeenHint = Taro.getStorageSync(scrollHintStorageKey)
if (hasSeenHint) {
hasPlayedScrollHint.value = true
return
}
hasPlayedScrollHint.value = true
await nextTick()
isPlayingAutoScrollHint.value = true
scrollHintForwardTimer = setTimeout(() => {
scrollLeft.value = scrollHintOffset
}, 240)
scrollHintResetTimer = setTimeout(() => {
scrollLeft.value = 0
isPlayingAutoScrollHint.value = false
Taro.setStorageSync(scrollHintStorageKey, 1)
}, 1080)
}
watch(showScrollHint, (value) => {
if (value) {
playScrollHint()
}
}, { immediate: true })
onMounted(async () => {
await tabbarStore.ensureLoaded()
})
onBeforeUnmount(() => {
clearScrollHintTimers()
})
</script>
......@@ -80,24 +195,43 @@ onMounted(() => {
bottom: 0;
z-index: 100;
box-sizing: border-box;
overflow: hidden;
}
.app-tabbar__panel {
display: flex;
align-items: stretch;
min-height: 132rpx;
padding: 16rpx 24rpx;
height: 132rpx;
border-top: 2rpx solid rgba(166, 121, 57, 0.12);
background: rgba(255, 255, 255, 0.98);
box-sizing: border-box;
backdrop-filter: blur(12rpx);
}
.app-tabbar__content {
display: flex;
align-items: stretch;
height: 132rpx;
padding: 16rpx 28rpx 16rpx 52rpx;
box-sizing: border-box;
}
.app-tabbar__content.is-scrollable {
display: inline-block;
white-space: nowrap;
}
.app-tabbar__item {
flex: 1;
min-width: 0;
}
.app-tabbar__item.is-scrollable {
display: inline-flex;
vertical-align: top;
padding-right: 4rpx;
box-sizing: border-box;
white-space: normal;
}
.app-tabbar__item-inner {
display: flex;
flex-direction: column;
......@@ -136,5 +270,33 @@ onMounted(() => {
.app-tabbar__item.is-active .app-tabbar__icon {
color: #a67939;
}
.app-tabbar__fade {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 84rpx;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 26%,
rgba(255, 255, 255, 0.74) 68%,
rgba(255, 255, 255, 0.98) 100%
);
}
.app-tabbar__hint-arrow {
width: 12rpx;
height: 12rpx;
margin-right: 12rpx;
border-top: 2rpx solid rgba(166, 121, 57, 0.32);
border-right: 2rpx solid rgba(166, 121, 57, 0.32);
transform: rotate(45deg);
}
}
</style>
......
export const getTabbarConfigFixture = () => ({
tab_items: [
{
key: 'home',
home: {
title: '首页',
class: 'fa-home',
visible: true,
icon: 'fa-home',
link: '',
},
{
key: 'message',
news: {
title: '资讯',
class: 'fa-newspaper-o',
visible: true,
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
webview_title: '资讯',
icon: 'fa-newspaper-o',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
},
{
key: 'application',
list: {
title: '应用',
class: 'fa-th-large',
visible: true,
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '应用',
icon: 'fa-th-large',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
},
{
key: 'mine',
user: {
title: '我的',
class: 'fa-user',
visible: true,
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '我的',
icon: 'fa-user',
link: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
},
],
})
......
......@@ -3,8 +3,7 @@ import { buildMockSuccess } from '../shared/response'
export const tabbarMockHandlers = [
{
action: 'tabbar',
type: 'config',
action: 'app_menu',
method: 'GET',
handle: () => buildMockSuccess(getTabbarConfigFixture(), '底部导航配置获取成功 (mock)'),
},
......
......@@ -3,7 +3,8 @@ import { getTabbarConfigAPI } from '@/api/tabbar'
import {
getDefaultTabbarItem,
getDefaultTabbarItems,
normalizeTabbarItems,
normalizeTabbarKey,
normalizeTabbarPayload,
} from '@/utils/tabbar'
let tabbarRequestPromise = null
......@@ -17,7 +18,7 @@ export const useTabbarStore = defineStore('tabbar', {
getters: {
visibleTabItems: (state) => state.tabItems.filter((item) => item.visible !== false),
getTabItem: (state) => (key) => {
const normalizedKey = String(key || '').trim()
const normalizedKey = normalizeTabbarKey(key)
return state.tabItems.find((item) => item.key === normalizedKey) || getDefaultTabbarItem(normalizedKey)
},
},
......@@ -36,10 +37,9 @@ export const useTabbarStore = defineStore('tabbar', {
tabbarRequestPromise = (async () => {
try {
const response = await getTabbarConfigAPI()
const rawTabItems = response?.data?.tab_items || response?.data?.tabs || response?.data?.list || []
if (response?.code === 1) {
this.tabItems = normalizeTabbarItems(rawTabItems)
this.tabItems = normalizeTabbarPayload(response?.data)
} else {
this.tabItems = getDefaultTabbarItems()
}
......
const defaultTabbarItemMap = {
import { buildWebviewPreviewUrl } from '@/utils/webview'
const TABBAR_KEY_ALIAS_MAP = {
message: 'news',
application: 'list',
mine: 'user',
}
const KNOWN_TABBAR_ITEM_MAP = {
home: {
key: 'home',
title: '首页',
......@@ -8,36 +16,37 @@ const defaultTabbarItemMap = {
webview_url: '',
webview_title: '首页',
},
message: {
key: 'message',
news: {
key: 'news',
title: '资讯',
class: 'fa-newspaper-o',
visible: true,
page_url: '/pages/message/index',
page_url: '',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=news_list',
webview_title: '资讯',
},
application: {
key: 'application',
list: {
key: 'list',
title: '应用',
class: 'fa-th-large',
visible: true,
page_url: '/pages/application/index',
page_url: '',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '应用',
},
mine: {
key: 'mine',
user: {
key: 'user',
title: '我的',
class: 'fa-user',
visible: true,
page_url: '/pages/mine/index',
page_url: '',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '我的',
},
}
export const TABBAR_ORDER = ['home', 'message', 'application', 'mine']
export const TABBAR_ORDER = ['home', 'news', 'list', 'user']
const DEFAULT_ICON_CLASS = 'fa-circle-o'
const normalizeVisibleValue = (rawValue, fallbackValue = true) => {
if (typeof rawValue === 'boolean') {
......@@ -55,13 +64,28 @@ const normalizeVisibleValue = (rawValue, fallbackValue = true) => {
return fallbackValue
}
export const getDefaultTabbarItem = (key) => {
export const normalizeTabbarKey = (key) => {
const normalizedKey = String(key || '').trim()
const fallbackItem = defaultTabbarItemMap[normalizedKey]
return TABBAR_KEY_ALIAS_MAP[normalizedKey] || normalizedKey
}
const createFallbackTabbarItem = (key, title = '') => {
const normalizedKey = normalizeTabbarKey(key)
if (!fallbackItem) {
return null
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,
......@@ -74,23 +98,52 @@ export const getDefaultTabbarItems = () => (
.filter(Boolean)
)
export const normalizeTabbarItem = (rawItem = {}) => {
const normalizedKey = String(rawItem.key || rawItem.name || '').trim()
const fallbackItem = getDefaultTabbarItem(normalizedKey)
const buildTabbarPageUrl = (key, webviewUrl, webviewTitle, rawPageUrl = '') => {
if (key === 'home') {
return rawPageUrl || '/pages/index/index'
}
if (!fallbackItem) {
return null
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: String(rawItem.title || fallbackItem.title),
class: String(rawItem.class || rawItem.icon_class || rawItem.icon || fallbackItem.class || ''),
page_url: String(rawItem.page_url || fallbackItem.page_url || ''),
webview_url: String(rawItem.webview_url || rawItem.link_url || rawItem.url || fallbackItem.webview_url || ''),
webview_title: String(rawItem.webview_title || rawItem.link_title || rawItem.title || fallbackItem.webview_title || ''),
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,
......@@ -98,6 +151,56 @@ export const normalizeTabbarItem = (rawItem = {}) => {
}
}
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()
......@@ -109,3 +212,19 @@ export const normalizeTabbarItems = (rawItems = []) => {
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()
}
......