hookehuyr

feat(tabbar): 实现动态底部导航及页面配置

- 新增应用页和我的备份页,支持 WebView 模式
- 重构我的页面为 WebView 承接页,移除静态 UI
- 新增底部导航配置 API、Mock 数据及 Pinia 状态管理
- 重构 AppTabbar 组件以使用动态配置,支持图标映射和页面路由
- 在应用启动时预加载导航配置,优化用户体验
- 将“消息”统一更名为“资讯”,更新相关页面文案
import { fn, fetch } from './fn'
const Api = {
TABBAR_CONFIG: '/srv/?a=tabbar&t=config',
}
export const getTabbarConfigAPI = () => fn(fetch.get(Api.TABBAR_CONFIG))
......@@ -4,7 +4,9 @@ export default {
'pages/map-guide/index',
'pages/message/index',
'pages/message-detail/index',
'pages/application/index',
'pages/mine/index',
'pages/mine-backup/index',
'pages/pay-test/index',
'pages/pay-bridge/index',
'pages/webview-preview/index',
......
......@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
import './utils/polyfill'
import './app.less'
import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
import { useTabbarStore } from '@/stores/tabbar'
const pinia = createPinia()
const App = createApp({
async onLaunch(options) {
......@@ -26,13 +29,21 @@ const App = createApp({
} catch (error) {
console.error('静默授权失败:', error)
navigateToAuth(full_path || undefined)
return
}
}
try {
const tabbarStore = useTabbarStore(pinia)
await tabbarStore.ensureLoaded()
} catch (error) {
console.error('预加载底部导航配置失败:', error)
}
},
onShow() {
},
})
App.use(createPinia())
App.use(pinia)
export default App
......
......@@ -6,9 +6,9 @@
<view class="app-tabbar__panel">
<view
v-for="item in tabItems"
:key="item.name"
:key="item.key"
class="app-tabbar__item"
:class="{ 'is-active': isActive(item.name) }"
:class="{ 'is-active': isActive(item.key) }"
@tap="handleTabClick(item)"
>
<view class="app-tabbar__item-inner">
......@@ -16,7 +16,7 @@
<component
:is="item.icon"
size="18"
:color="isActive(item.name) ? activeColor : inactiveColor"
:color="isActive(item.key) ? activeColor : inactiveColor"
/>
</view>
<text class="app-tabbar__label">{{ item.title }}</text>
......@@ -28,8 +28,10 @@
</template>
<script setup>
import { computed, onMounted } from 'vue'
import Taro from '@tarojs/taro'
import { Home, Message, My } from '@nutui/icons-vue-taro'
import { Category, Home, My, Notice, Service } from '@nutui/icons-vue-taro'
import { useTabbarStore } from '@/stores/tabbar'
const props = defineProps({
current: {
......@@ -41,36 +43,35 @@ const props = defineProps({
const activeColor = '#a67939'
const inactiveColor = '#8b95a7'
const tabItems = [
{
name: 'home',
title: '首页',
icon: Home,
url: '/pages/index/index',
},
{
name: 'message',
title: '消息',
icon: Message,
url: '/pages/message/index',
},
{
name: 'mine',
title: '我的',
icon: My,
url: '/pages/mine/index',
},
]
const tabbarStore = useTabbarStore()
const tabIconMap = {
home: Home,
message: Notice,
application: Category,
mine: My,
}
const tabItems = computed(() => (
tabbarStore.visibleTabItems.map((item) => ({
...item,
icon: tabIconMap[item.key] || Service,
}))
))
const isActive = (name) => name === props.current
const handleTabClick = (item) => {
if (!item?.url || item.name === props.current) {
if (!item?.page_url || item.key === props.current) {
return
}
Taro.redirectTo({ url: item.url })
Taro.redirectTo({ url: item.page_url })
}
onMounted(() => {
tabbarStore.ensureLoaded()
})
</script>
<style lang="less">
......
export const getTabbarConfigFixture = () => ({
tab_items: [
{
key: 'home',
title: '首页',
visible: true,
},
{
key: 'message',
title: '资讯',
visible: true,
},
{
key: 'application',
title: '应用',
visible: true,
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '应用',
},
{
key: 'mine',
title: '我的',
visible: true,
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '我的',
},
],
})
......@@ -3,6 +3,7 @@ import { paymentMockHandlers } from './payment.mock'
import { commonMockHandlers } from './common.mock'
import { messageMockHandlers } from './message.mock'
import { homeMockHandlers } from './home.mock'
import { tabbarMockHandlers } from './tabbar.mock'
export const mockHandlers = [
...authMockHandlers,
......@@ -10,4 +11,5 @@ export const mockHandlers = [
...commonMockHandlers,
...messageMockHandlers,
...homeMockHandlers,
...tabbarMockHandlers,
]
......
import { getTabbarConfigFixture } from '../fixtures/tabbar.fixture'
import { buildMockSuccess } from '../shared/response'
export const tabbarMockHandlers = [
{
action: 'tabbar',
type: 'config',
method: 'GET',
handle: () => buildMockSuccess(getTabbarConfigFixture(), '底部导航配置获取成功 (mock)'),
},
]
export default {
navigationBarTitleText: '应用',
}
<template>
<web-view v-if="preview_url" :src="preview_url" />
<view v-else class="tab-webview-page">
<view class="empty-card">
<text class="empty-title">暂未配置应用地址</text>
<text class="empty-desc">
当前应用页还没有收到可用的外部链接,请先检查底部导航配置接口返回。
</text>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import { useTabbarStore } from '@/stores/tabbar'
const tabbarStore = useTabbarStore()
const preview_url = ref('')
const default_page_title = '应用'
const initPage = async () => {
await tabbarStore.ensureLoaded()
const currentTab = tabbarStore.getTabItem('application')
preview_url.value = currentTab?.webview_url || ''
Taro.setNavigationBarTitle({
title: currentTab?.webview_title || currentTab?.title || default_page_title,
})
if (!preview_url.value) {
Taro.showToast({
title: '暂未配置应用地址',
icon: 'none',
})
}
}
useLoad(() => {
initPage()
})
</script>
<style lang="less">
.tab-webview-page {
min-height: 100vh;
padding: 32rpx 24rpx;
box-sizing: border-box;
background:
radial-gradient(circle at top right, rgba(166, 121, 57, 0.16), transparent 30%),
linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
}
.empty-card {
background: rgba(255, 255, 255, 0.94);
border: 2rpx solid rgba(166, 121, 57, 0.08);
border-radius: 28rpx;
padding: 32rpx;
box-sizing: border-box;
box-shadow: 0 20rpx 60rpx rgba(15, 23, 42, 0.06);
}
.empty-title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: #111827;
}
.empty-desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #6b7280;
}
</style>
export default {
navigationBarTitleText: '消息详情',
navigationBarTitleText: '资讯详情',
}
......
......@@ -2,15 +2,15 @@
<view class="message-detail-page">
<view class="page-content">
<view class="hero-card">
<text class="hero-title">消息详情</text>
<text class="hero-title">资讯详情</text>
<text class="hero-desc">
进入详情页后,当前消息会在 mock 中自动标记为已读,返回列表即可看到状态变化。
进入详情页后,当前资讯会在 mock 中自动标记为已读,返回列表即可看到状态变化。
</text>
</view>
<view v-if="loading" class="placeholder-card">
<text class="section-title">加载中</text>
<text class="section-desc">正在获取消息详情...</text>
<text class="section-desc">正在获取资讯详情...</text>
</view>
<view v-else-if="detail" class="detail-card">
......@@ -23,13 +23,13 @@
</view>
<view v-else class="placeholder-card">
<text class="section-title">未找到消息</text>
<text class="section-title">未找到资讯</text>
<text class="section-desc">
当前消息不存在,或者真实接口还没有返回这条详情数据。
当前资讯不存在,或者真实接口还没有返回这条详情数据。
</text>
</view>
<button class="back-btn" @tap="goBack">返回消息列表</button>
<button class="back-btn" @tap="goBack">返回资讯列表</button>
</view>
</view>
</template>
......@@ -54,11 +54,11 @@ const fetchMessageDetail = async (id) => {
detail.value = null
Taro.showToast({
title: response?.msg || '获取详情失败',
title: response?.msg || '获取资讯详情失败',
icon: 'none',
})
} catch (error) {
console.error('获取消息详情失败:', error)
console.error('获取资讯详情失败:', error)
detail.value = null
} finally {
loading.value = false
......
export default {
navigationBarTitleText: '消息',
navigationBarTitleText: '资讯',
}
......
......@@ -3,7 +3,7 @@
<view class="page-content">
<view class="hero-card">
<view class="hero-head">
<text class="hero-title">消息</text>
<text class="hero-title">资讯</text>
<text class="env-tag" :class="{ mock: current_env_use_mock }">
{{ current_env_label }}
</text>
......@@ -17,7 +17,7 @@
<view>
<text class="section-title">列表状态</text>
<text class="section-desc">
共 {{ total }} 条消息,未读 {{ unread_count }} 条。进入详情页后,当前消息会在 mock 中自动变成已读。
共 {{ total }} 条资讯,未读 {{ unread_count }} 条。进入详情页后,当前资讯会在 mock 中自动变成已读。
</text>
</view>
<button class="refresh-btn" :loading="loading" @tap="handleRefresh">
......@@ -28,14 +28,14 @@
<view v-if="loading && !message_list.length" class="placeholder-card">
<text class="section-title">加载中</text>
<text class="section-desc">
正在拉取消息列表...
正在拉取资讯列表...
</text>
</view>
<view v-else-if="!message_list.length" class="placeholder-card">
<text class="section-title">暂无消息</text>
<text class="section-title">暂无资讯</text>
<text class="section-desc">
当前接口还没有返回消息内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。
当前接口还没有返回资讯内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。
</text>
</view>
......@@ -110,7 +110,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => {
if (response?.code !== 1) {
Taro.showToast({
title: response?.msg || '获取消息失败',
title: response?.msg || '获取资讯失败',
icon: 'none',
})
return
......@@ -122,7 +122,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => {
has_more.value = !!response?.data?.has_more
page.value = nextPage
} catch (error) {
console.error('获取消息列表失败:', error)
console.error('获取资讯列表失败:', error)
} finally {
loading.value = false
loading_more.value = false
......
export default {
navigationBarTitleText: '我的备份',
}
This diff is collapsed. Click to expand it.
This diff is collapsed. Click to expand it.
import { defineStore } from 'pinia'
import { getTabbarConfigAPI } from '@/api/tabbar'
import {
getDefaultTabbarItem,
getDefaultTabbarItems,
normalizeTabbarItems,
} from '@/utils/tabbar'
let tabbarRequestPromise = null
export const useTabbarStore = defineStore('tabbar', {
state: () => ({
tabItems: getDefaultTabbarItems(),
loaded: false,
loading: false,
}),
getters: {
visibleTabItems: (state) => state.tabItems.filter((item) => item.visible !== false),
getTabItem: (state) => (key) => {
const normalizedKey = String(key || '').trim()
return state.tabItems.find((item) => item.key === normalizedKey) || getDefaultTabbarItem(normalizedKey)
},
},
actions: {
async fetchTabbarConfig(force = false) {
if (!force && this.loaded) {
return this.tabItems
}
if (this.loading && tabbarRequestPromise) {
return tabbarRequestPromise
}
this.loading = true
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)
} else {
this.tabItems = getDefaultTabbarItems()
}
} catch (error) {
console.error('获取底部导航配置失败:', error)
this.tabItems = getDefaultTabbarItems()
} finally {
this.loaded = true
this.loading = false
tabbarRequestPromise = null
}
return this.tabItems
})()
return tabbarRequestPromise
},
async ensureLoaded() {
if (this.loaded) {
return this.tabItems
}
return this.fetchTabbarConfig()
},
},
})
const defaultTabbarItemMap = {
home: {
key: 'home',
title: '首页',
visible: true,
page_url: '/pages/index/index',
webview_url: '',
webview_title: '首页',
},
message: {
key: 'message',
title: '资讯',
visible: true,
page_url: '/pages/message/index',
webview_url: '',
webview_title: '资讯',
},
application: {
key: 'application',
title: '应用',
visible: true,
page_url: '/pages/application/index',
webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
webview_title: '应用',
},
mine: {
key: 'mine',
title: '我的',
visible: true,
page_url: '/pages/mine/index',
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']
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 getDefaultTabbarItem = (key) => {
const normalizedKey = String(key || '').trim()
const fallbackItem = defaultTabbarItemMap[normalizedKey]
if (!fallbackItem) {
return null
}
return {
...fallbackItem,
}
}
export const getDefaultTabbarItems = () => (
TABBAR_ORDER
.map((key) => getDefaultTabbarItem(key))
.filter(Boolean)
)
export const normalizeTabbarItem = (rawItem = {}) => {
const normalizedKey = String(rawItem.key || rawItem.name || '').trim()
const fallbackItem = getDefaultTabbarItem(normalizedKey)
if (!fallbackItem) {
return null
}
return {
...fallbackItem,
...rawItem,
key: fallbackItem.key,
title: String(rawItem.title || fallbackItem.title),
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 || ''),
visible: normalizeVisibleValue(
rawItem.visible ?? rawItem.is_show ?? rawItem.show,
fallbackItem.visible,
),
}
}
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()
}