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: '我的备份',
}
<template>
<view class="mine-page">
<view class="header-bg">
<view class="header-pattern" />
</view>
<view class="page-content">
<view class="hero-banner">
<view class="hero-banner__ornament">
<view class="hero-banner__orb hero-banner__orb--lg" />
<view class="hero-banner__orb hero-banner__orb--sm" />
</view>
<view class="profile-panel" @tap="handleProfileTap">
<view class="profile-panel__avatar">
<view class="avatar-ring">
<button
class="avatar-btn"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
<image
class="avatar-img"
:src="avatarUrl || defaultAvatar"
mode="aspectFill"
/>
</button>
</view>
</view>
<view class="profile-panel__body">
<view class="profile-panel__head" @tap.stop="openNicknameDialog">
<view class="profile-panel__name-block">
<text class="nickname-text">{{ nickname || defaultNickname }}</text>
<text class="profile-panel__subtext">点击修改昵称</text>
</view>
<text class="profile-panel__action">›</text>
</view>
<text class="profile-panel__tip">
当前还没接微信昵称接口,先支持手动输入并直接显示在这里。
</text>
</view>
</view>
</view>
<view class="menu-section">
<view class="section-title">
<text class="section-title-text">常用服务</text>
</view>
<view class="menu-grid">
<view
v-for="item in menuItems"
:key="item.key"
class="menu-item"
@tap="handleMenuTap(item)"
>
<view class="menu-item__main">
<view class="menu-icon-wrap" :class="`menu-icon--${item.key}`">
<component
:is="item.icon"
size="20"
color="#a67939"
/>
</view>
<view class="menu-item__content">
<text class="menu-item-title">{{ item.title }}</text>
<text class="menu-item-desc">{{ item.desc }}</text>
</view>
</view>
<text class="menu-item-arrow">›</text>
</view>
</view>
</view>
<view class="menu-section">
<view class="section-title">
<text class="section-title-text">更多</text>
</view>
<view class="menu-list">
<view
v-for="(item, idx) in bottomMenuItems"
:key="item.key"
class="menu-list-item"
:class="{ 'menu-list-item--last': idx === bottomMenuItems.length - 1 }"
@tap="handleMenuTap(item)"
>
<view class="menu-list-icon" :class="`menu-icon--${item.key}`">
<component
:is="item.icon"
size="18"
color="#a67939"
/>
</view>
<view class="menu-list-content">
<text class="menu-list-title">{{ item.title }}</text>
<text class="menu-list-desc">{{ item.desc }}</text>
</view>
<text class="arrow-text arrow-text--light">›</text>
</view>
</view>
</view>
<view class="page-footer-tip">
<text class="page-footer-tip__text">山门清净,信息常新</text>
</view>
</view>
<view
v-if="nicknameDialogVisible"
class="nickname-dialog-mask"
@tap="closeNicknameDialog"
>
<view class="nickname-dialog" @tap.stop>
<text class="nickname-dialog__title">设置昵称</text>
<text class="nickname-dialog__desc">当前版本先手动输入昵称,留空时显示默认昵称。</text>
<input
class="nickname-dialog__input"
:value="nicknameDraft"
maxlength="20"
placeholder="请输入昵称"
focus
@input="onNicknameDraftInput"
/>
<view class="nickname-dialog__actions">
<view
class="nickname-dialog__btn nickname-dialog__btn--ghost"
@tap="closeNicknameDialog"
>
取消
</view>
<view
class="nickname-dialog__btn"
@tap="confirmNickname"
>
确定
</view>
</view>
</view>
</view>
<AppTabbar current="mine" />
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import AppTabbar from '@/components/AppTabbar.vue'
import {
Footprint,
Location,
Notice,
Order,
Service,
Setting,
Star,
} from '@nutui/icons-vue-taro'
const avatarUrl = ref('')
const nickname = ref('')
const nicknameDraft = ref('')
const nicknameDialogVisible = ref(false)
const defaultNickname = '觉林寺访客'
const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgdmlld0JveD0iMCAwIDEyMCAxMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMTgiIHkxPSIxNCIgeDI9IjEwMSIgeTI9IjEwNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNDRkE4NUEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM5RTcxMkQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgcng9IjYwIiBmaWxsPSJ1cmwoI2cpIi8+PGNpcmNsZSBjeD0iNjAiIGN5PSI0MiIgcj0iMTgiIGZpbGw9IiNGRkY3RTYiIGZpbGwtb3BhY2l0eT0iMC45NiIvPjxwYXRoIGQ9Ik0yNSA5MEMyNSA3NC41MzYgMzcuNTM2IDYyIDUzIDYySDY3QzgyLjQ2NCA2MiA5NSA3NC41MzYgOTUgOTBWOTJIMjVWOTBaIiBmaWxsPSIjRkZGN0U2IiBmaWxsLW9wYWNpdHk9IjAuOTYiLz48Y2lyY2xlIGN4PSI4OSIgY3k9IjI5IiByPSIxMCIgZmlsbD0iI0Y3REU5RCIgZmlsbC1vcGFjaXR5PSIwLjcyIi8+PC9zdmc+'
const menuItems = [
{ key: 'orders', title: '我的订单', desc: '查看法物订单', icon: Order },
{ key: 'favorites', title: '我的收藏', desc: '收藏的法物', icon: Star },
{ key: 'history', title: '浏览记录', desc: '最近浏览', icon: Footprint },
{ key: 'address', title: '收货地址', desc: '管理地址', icon: Location },
]
const bottomMenuItems = [
{ key: 'settings', title: '设置', desc: '通知与隐私', icon: Setting },
{ key: 'about', title: '关于觉林寺', desc: '了解寺院历史', icon: Service },
{ key: 'help', title: '联系客堂', desc: '在线咨询', icon: Notice },
]
const onChooseAvatar = (e) => {
const { avatarUrl: url } = e.detail
avatarUrl.value = url
}
const openNicknameDialog = () => {
nicknameDraft.value = nickname.value
nicknameDialogVisible.value = true
}
const closeNicknameDialog = () => {
nicknameDialogVisible.value = false
}
const onNicknameDraftInput = (e) => {
nicknameDraft.value = e.detail.value
}
const confirmNickname = () => {
nickname.value = nicknameDraft.value.trim()
closeNicknameDialog()
}
const handleProfileTap = () => {}
const handleMenuTap = ({ title }) => {
Taro.showToast({ title: `${title}功能开发中`, icon: 'none' })
}
</script>
<style lang="less">
.mine-page {
min-height: 100vh;
background:
radial-gradient(circle at top right, rgba(166, 121, 57, 0.1), transparent 32%),
linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
position: relative;
.header-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 304rpx;
background: linear-gradient(180deg, rgba(214, 184, 124, 0.18) 0%, rgba(214, 184, 124, 0.04) 72%, rgba(214, 184, 124, 0) 100%);
border-radius: 0 0 48rpx 48rpx;
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
radial-gradient(circle at 80% 60%, rgba(255, 255, 255, 0.04) 0%, transparent 40%);
}
.page-content {
position: relative;
padding: 32rpx 28rpx 176rpx;
box-sizing: border-box;
z-index: 1;
}
.hero-banner {
position: relative;
margin-top: 10rpx;
padding: 28rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, rgba(255, 251, 246, 0.72), rgba(255, 255, 255, 0.52));
border: 2rpx solid rgba(214, 184, 124, 0.08);
box-shadow:
inset 0 2rpx 0 rgba(255, 255, 255, 0.22),
0 12rpx 36rpx rgba(15, 23, 42, 0.03);
backdrop-filter: blur(20rpx) saturate(118%);
overflow: hidden;
box-sizing: border-box;
&__ornament {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 200rpx;
pointer-events: none;
}
&__orb {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, rgba(214, 184, 124, 0.12) 0%, rgba(214, 184, 124, 0.02) 70%, transparent 100%);
&--lg {
width: 180rpx;
height: 180rpx;
top: -24rpx;
right: -22rpx;
}
&--sm {
width: 92rpx;
height: 92rpx;
right: 42rpx;
bottom: 22rpx;
}
}
}
.profile-panel {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 24rpx;
padding: 30rpx 28rpx;
min-height: 176rpx;
box-sizing: border-box;
&__avatar {
flex-shrink: 0;
}
&__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12rpx;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
&__action {
flex-shrink: 0;
font-size: 30rpx;
line-height: 1;
color: rgba(125, 109, 82, 0.64);
}
&__name-block {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
&__subtext {
font-size: 22rpx;
line-height: 1.4;
color: #948b7d;
}
&__tip {
font-size: 22rpx;
line-height: 1.6;
color: #948b7d;
}
}
.avatar-ring {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
padding: 4rpx;
background: linear-gradient(135deg, #e6dcc1, #cdb991);
}
.avatar-btn {
padding: 0;
margin: 0;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
background: transparent;
border: none;
line-height: 1;
&::after {
border: none;
}
}
.avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: block;
}
.profile-info {
display: none;
}
.nickname-text {
font-size: 36rpx;
font-weight: 700;
color: #3b352d;
line-height: 1.4;
}
.arrow-text {
font-size: 28rpx;
color: #b9ac96;
line-height: 1;
&--light {
color: #d1c9b5;
}
}
.section-title {
padding: 34rpx 8rpx 18rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title-text {
font-size: 26rpx;
font-weight: 600;
color: #847b70;
letter-spacing: 2rpx;
position: relative;
padding-left: 18rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
bottom: 8rpx;
width: 6rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #d8c7a4, #b8a489);
}
}
.menu-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
background: rgba(255, 255, 255, 0.98);
border-radius: 24rpx;
border: 2rpx solid rgba(166, 121, 57, 0.05);
box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
padding: 22rpx;
box-sizing: border-box;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx 20rpx;
min-height: 0;
border-radius: 20rpx;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 249, 246, 0.96) 100%);
border: 1rpx solid rgba(166, 121, 57, 0.05);
box-sizing: border-box;
}
.menu-item__main {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 16rpx;
}
.menu-item__content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.menu-item-arrow {
flex-shrink: 0;
font-size: 26rpx;
color: #c0b8ac;
line-height: 1;
}
.menu-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 2rpx 0 rgba(255, 255, 255, 0.45);
&.menu-icon--orders {
background: linear-gradient(135deg, #faf4ea, #f4ead7);
}
&.menu-icon--favorites {
background: linear-gradient(135deg, #faf1ec, #f3e4da);
}
&.menu-icon--history {
background: linear-gradient(135deg, #f0f5f0, #e3ece3);
}
&.menu-icon--address {
background: linear-gradient(135deg, #eff2f6, #e4e9ef);
}
&.menu-icon--settings {
background: linear-gradient(135deg, #f3f3f3, #e9e9e9);
}
&.menu-icon--about {
background: linear-gradient(135deg, #faf4ea, #f4ead7);
}
&.menu-icon--help {
background: linear-gradient(135deg, #f0f5f0, #e3ece3);
}
}
.menu-item-title {
font-size: 24rpx;
font-weight: 600;
color: #45403a;
line-height: 1.2;
}
.menu-item-desc {
font-size: 20rpx;
color: #9a9184;
line-height: 1.3;
}
.menu-list {
background: rgba(255, 255, 255, 0.98);
border-radius: 24rpx;
border: 2rpx solid rgba(166, 121, 57, 0.05);
box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
overflow: hidden;
}
.menu-list-item {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
gap: 24rpx;
position: relative;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 247, 0.98));
&::after {
content: '';
position: absolute;
bottom: 0;
left: 100rpx;
right: 32rpx;
height: 1rpx;
background: rgba(0, 0, 0, 0.05);
}
&--last::after {
display: none;
}
}
.menu-list-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-list-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.menu-list-title {
font-size: 28rpx;
font-weight: 500;
color: #45403a;
line-height: 1.3;
}
.menu-list-desc {
font-size: 22rpx;
color: #9a9184;
line-height: 1.2;
}
.page-footer-tip {
display: flex;
justify-content: center;
padding: 28rpx 0 8rpx;
&__text {
font-size: 22rpx;
letter-spacing: 4rpx;
color: rgba(147, 141, 131, 0.9);
}
}
.nickname-dialog-mask {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(33, 24, 12, 0.32);
box-sizing: border-box;
}
.nickname-dialog {
width: 100%;
padding: 34rpx 30rpx 30rpx;
border-radius: 28rpx;
background: #fffefd;
box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.12);
box-sizing: border-box;
&__title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #3b352d;
line-height: 1.3;
}
&__desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #948b7d;
}
&__input {
margin-top: 24rpx;
height: 88rpx;
padding: 0 24rpx;
border-radius: 18rpx;
background: #f7f5f1;
border: 1rpx solid rgba(166, 121, 57, 0.08);
font-size: 28rpx;
color: #3b352d;
box-sizing: border-box;
}
&__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 28rpx;
}
&__btn {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 18rpx;
font-size: 28rpx;
font-weight: 600;
color: #fffefd;
background: linear-gradient(135deg, #ac9a7d, #948269);
&--ghost {
color: #847b70;
background: #f4f2ee;
}
}
}
}
</style>
<template>
<view class="mine-page">
<!-- 顶部装饰背景 -->
<view class="header-bg">
<view class="header-pattern" />
<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">
当前“我的”按钮已经切到 WebView 承接页,但接口还没有返回可用地址。
</text>
</view>
<view class="page-content">
<view class="hero-banner">
<view class="hero-banner__ornament">
<view class="hero-banner__orb hero-banner__orb--lg" />
<view class="hero-banner__orb hero-banner__orb--sm" />
</view>
<view class="profile-panel" @tap="handleProfileTap">
<view class="profile-panel__avatar">
<view class="avatar-ring">
<button
class="avatar-btn"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
<image
class="avatar-img"
:src="avatarUrl || defaultAvatar"
mode="aspectFill"
/>
</button>
</view>
</view>
<view class="profile-panel__body">
<view class="profile-panel__head" @tap.stop="openNicknameDialog">
<view class="profile-panel__name-block">
<text class="nickname-text">{{ nickname || defaultNickname }}</text>
<text class="profile-panel__subtext">点击修改昵称</text>
</view>
<text class="profile-panel__action">›</text>
</view>
<text class="profile-panel__tip">
当前还没接微信昵称接口,先支持手动输入并直接显示在这里。
</text>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="section-title">
<text class="section-title-text">常用服务</text>
</view>
<view class="menu-grid">
<view
v-for="item in menuItems"
:key="item.key"
class="menu-item"
@tap="handleMenuTap(item)"
>
<view class="menu-item__main">
<view class="menu-icon-wrap" :class="`menu-icon--${item.key}`">
<component
:is="item.icon"
size="20"
color="#a67939"
/>
</view>
<view class="menu-item__content">
<text class="menu-item-title">{{ item.title }}</text>
<text class="menu-item-desc">{{ item.desc }}</text>
</view>
</view>
<text class="menu-item-arrow">›</text>
</view>
</view>
</view>
<!-- 更多菜单 -->
<view class="menu-section">
<view class="section-title">
<text class="section-title-text">更多</text>
</view>
<view class="menu-list">
<view
v-for="(item, idx) in bottomMenuItems"
:key="item.key"
class="menu-list-item"
:class="{ 'menu-list-item--last': idx === bottomMenuItems.length - 1 }"
@tap="handleMenuTap(item)"
>
<view class="menu-list-icon" :class="`menu-icon--${item.key}`">
<component
:is="item.icon"
size="18"
color="#a67939"
/>
</view>
<view class="menu-list-content">
<text class="menu-list-title">{{ item.title }}</text>
<text class="menu-list-desc">{{ item.desc }}</text>
</view>
<text class="arrow-text arrow-text--light">›</text>
</view>
</view>
</view>
<view class="page-footer-tip">
<text class="page-footer-tip__text">山门清净,信息常新</text>
</view>
</view>
<view
v-if="nicknameDialogVisible"
class="nickname-dialog-mask"
@tap="closeNicknameDialog"
>
<view class="nickname-dialog" @tap.stop>
<text class="nickname-dialog__title">设置昵称</text>
<text class="nickname-dialog__desc">当前版本先手动输入昵称,留空时显示默认昵称。</text>
<input
class="nickname-dialog__input"
:value="nicknameDraft"
maxlength="20"
placeholder="请输入昵称"
focus
@input="onNicknameDraftInput"
/>
<view class="nickname-dialog__actions">
<view
class="nickname-dialog__btn nickname-dialog__btn--ghost"
@tap="closeNicknameDialog"
>
取消
</view>
<view
class="nickname-dialog__btn"
@tap="confirmNickname"
>
确定
</view>
</view>
</view>
</view>
<AppTabbar current="mine" />
</view>
</template>
<script setup>
import { ref } from 'vue'
import Taro from '@tarojs/taro'
import AppTabbar from '@/components/AppTabbar.vue'
import {
Footprint,
Location,
Notice,
Order,
Service,
Setting,
Star,
} from '@nutui/icons-vue-taro'
const avatarUrl = ref('')
const nickname = ref('')
const nicknameDraft = ref('')
const nicknameDialogVisible = ref(false)
const defaultNickname = '觉林寺访客'
const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgdmlld0JveD0iMCAwIDEyMCAxMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMTgiIHkxPSIxNCIgeDI9IjEwMSIgeTI9IjEwNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNDRkE4NUEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM5RTcxMkQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgcng9IjYwIiBmaWxsPSJ1cmwoI2cpIi8+PGNpcmNsZSBjeD0iNjAiIGN5PSI0MiIgcj0iMTgiIGZpbGw9IiNGRkY3RTYiIGZpbGwtb3BhY2l0eT0iMC45NiIvPjxwYXRoIGQ9Ik0yNSA5MEMyNSA3NC41MzYgMzcuNTM2IDYyIDUzIDYySDY3QzgyLjQ2NCA2MiA5NSA3NC41MzYgOTUgOTBWOTJIMjVWOTBaIiBmaWxsPSIjRkZGN0U2IiBmaWxsLW9wYWNpdHk9IjAuOTYiLz48Y2lyY2xlIGN4PSI4OSIgY3k9IjI5IiByPSIxMCIgZmlsbD0iI0Y3REU5RCIgZmlsbC1vcGFjaXR5PSIwLjcyIi8+PC9zdmc+'
const menuItems = [
{ key: 'orders', title: '我的订单', desc: '查看法物订单', icon: Order },
{ key: 'favorites', title: '我的收藏', desc: '收藏的法物', icon: Star },
{ key: 'history', title: '浏览记录', desc: '最近浏览', icon: Footprint },
{ key: 'address', title: '收货地址', desc: '管理地址', icon: Location },
]
import Taro, { useLoad } from '@tarojs/taro'
import { useTabbarStore } from '@/stores/tabbar'
const bottomMenuItems = [
{ key: 'settings', title: '设置', desc: '通知与隐私', icon: Setting },
{ key: 'about', title: '关于觉林寺', desc: '了解寺院历史', icon: Service },
{ key: 'help', title: '联系客堂', desc: '在线咨询', icon: Notice },
]
const tabbarStore = useTabbarStore()
const preview_url = ref('')
const default_page_title = '我的'
const onChooseAvatar = (e) => {
const { avatarUrl: url } = e.detail
avatarUrl.value = url
}
const openNicknameDialog = () => {
nicknameDraft.value = nickname.value
nicknameDialogVisible.value = true
}
const initPage = async () => {
await tabbarStore.ensureLoaded()
const closeNicknameDialog = () => {
nicknameDialogVisible.value = false
}
const currentTab = tabbarStore.getTabItem('mine')
preview_url.value = currentTab?.webview_url || ''
const onNicknameDraftInput = (e) => {
nicknameDraft.value = e.detail.value
}
Taro.setNavigationBarTitle({
title: currentTab?.webview_title || currentTab?.title || default_page_title,
})
const confirmNickname = () => {
nickname.value = nicknameDraft.value.trim()
closeNicknameDialog()
if (!preview_url.value) {
Taro.showToast({
title: '暂未配置我的页地址',
icon: 'none',
})
}
}
const handleProfileTap = () => {}
const handleMenuTap = ({ title }) => {
Taro.showToast({ title: `${title}功能开发中`, icon: 'none' })
}
useLoad(() => {
initPage()
})
</script>
<style lang="less">
.mine-page {
.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.1), transparent 32%),
radial-gradient(circle at top right, rgba(166, 121, 57, 0.16), transparent 30%),
linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
position: relative;
// 顶部装饰背景
.header-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 304rpx;
background: linear-gradient(180deg, rgba(214, 184, 124, 0.18) 0%, rgba(214, 184, 124, 0.04) 72%, rgba(214, 184, 124, 0) 100%);
border-radius: 0 0 48rpx 48rpx;
overflow: hidden;
}
.header-pattern {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
radial-gradient(circle at 80% 60%, rgba(255, 255, 255, 0.04) 0%, transparent 40%);
}
.page-content {
position: relative;
padding: 32rpx 28rpx 176rpx;
box-sizing: border-box;
z-index: 1;
}
.hero-banner {
position: relative;
margin-top: 10rpx;
padding: 28rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, rgba(255, 251, 246, 0.72), rgba(255, 255, 255, 0.52));
border: 2rpx solid rgba(214, 184, 124, 0.08);
box-shadow:
inset 0 2rpx 0 rgba(255, 255, 255, 0.22),
0 12rpx 36rpx rgba(15, 23, 42, 0.03);
backdrop-filter: blur(20rpx) saturate(118%);
overflow: hidden;
box-sizing: border-box;
&__ornament {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 200rpx;
pointer-events: none;
}
&__orb {
position: absolute;
border-radius: 50%;
background: radial-gradient(circle, rgba(214, 184, 124, 0.12) 0%, rgba(214, 184, 124, 0.02) 70%, transparent 100%);
&--lg {
width: 180rpx;
height: 180rpx;
top: -24rpx;
right: -22rpx;
}
&--sm {
width: 92rpx;
height: 92rpx;
right: 42rpx;
bottom: 22rpx;
}
}
}
.profile-panel {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 24rpx;
padding: 30rpx 28rpx;
min-height: 176rpx;
box-sizing: border-box;
&__avatar {
flex-shrink: 0;
}
&__body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 12rpx;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
&__action {
flex-shrink: 0;
font-size: 30rpx;
line-height: 1;
color: rgba(125, 109, 82, 0.64);
}
&__name-block {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
&__subtext {
font-size: 22rpx;
line-height: 1.4;
color: #948b7d;
}
&__tip {
font-size: 22rpx;
line-height: 1.6;
color: #948b7d;
}
}
.avatar-ring {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
padding: 4rpx;
background: linear-gradient(135deg, #e6dcc1, #cdb991);
}
.avatar-btn {
padding: 0;
margin: 0;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
overflow: hidden;
background: transparent;
border: none;
line-height: 1;
&::after {
border: none;
}
}
.avatar-img {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
display: block;
}
.profile-info {
display: none;
}
.nickname-text {
font-size: 36rpx;
font-weight: 700;
color: #3b352d;
line-height: 1.4;
}
.arrow-text {
font-size: 28rpx;
color: #b9ac96;
line-height: 1;
&--light {
color: #d1c9b5;
}
}
// 菜单分区
.section-title {
padding: 34rpx 8rpx 18rpx;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title-text {
font-size: 26rpx;
font-weight: 600;
color: #847b70;
letter-spacing: 2rpx;
position: relative;
padding-left: 18rpx;
&::before {
content: '';
position: absolute;
left: 0;
top: 8rpx;
bottom: 8rpx;
width: 6rpx;
border-radius: 999rpx;
background: linear-gradient(180deg, #d8c7a4, #b8a489);
}
}
// 网格菜单(常用服务)
.menu-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
background: rgba(255, 255, 255, 0.98);
border-radius: 24rpx;
border: 2rpx solid rgba(166, 121, 57, 0.05);
box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
padding: 22rpx;
box-sizing: border-box;
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 22rpx 20rpx;
min-height: 0;
border-radius: 20rpx;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 249, 246, 0.96) 100%);
border: 1rpx solid rgba(166, 121, 57, 0.05);
box-sizing: border-box;
}
.menu-item__main {
min-width: 0;
flex: 1;
display: flex;
align-items: center;
gap: 16rpx;
}
.menu-item__content {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.menu-item-arrow {
flex-shrink: 0;
font-size: 26rpx;
color: #c0b8ac;
line-height: 1;
}
.menu-icon-wrap {
width: 72rpx;
height: 72rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 2rpx 0 rgba(255, 255, 255, 0.45);
&.menu-icon--orders {
background: linear-gradient(135deg, #faf4ea, #f4ead7);
}
&.menu-icon--favorites {
background: linear-gradient(135deg, #faf1ec, #f3e4da);
}
&.menu-icon--history {
background: linear-gradient(135deg, #f0f5f0, #e3ece3);
}
&.menu-icon--address {
background: linear-gradient(135deg, #eff2f6, #e4e9ef);
}
&.menu-icon--settings {
background: linear-gradient(135deg, #f3f3f3, #e9e9e9);
}
&.menu-icon--about {
background: linear-gradient(135deg, #faf4ea, #f4ead7);
}
&.menu-icon--help {
background: linear-gradient(135deg, #f0f5f0, #e3ece3);
}
}
.menu-item-title {
font-size: 24rpx;
font-weight: 600;
color: #45403a;
line-height: 1.2;
}
.menu-item-desc {
font-size: 20rpx;
color: #9a9184;
line-height: 1.3;
}
// 列表菜单(更多)
.menu-list {
background: rgba(255, 255, 255, 0.98);
border-radius: 24rpx;
border: 2rpx solid rgba(166, 121, 57, 0.05);
box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
overflow: hidden;
}
.menu-list-item {
display: flex;
align-items: center;
padding: 28rpx 32rpx;
gap: 24rpx;
position: relative;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 247, 0.98));
&::after {
content: '';
position: absolute;
bottom: 0;
left: 100rpx;
right: 32rpx;
height: 1rpx;
background: rgba(0, 0, 0, 0.05);
}
&--last::after {
display: none;
}
}
.menu-list-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-list-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6rpx;
}
.menu-list-title {
font-size: 28rpx;
font-weight: 500;
color: #45403a;
line-height: 1.3;
}
.menu-list-desc {
font-size: 22rpx;
color: #9a9184;
line-height: 1.2;
}
.page-footer-tip {
display: flex;
justify-content: center;
padding: 28rpx 0 8rpx;
&__text {
font-size: 22rpx;
letter-spacing: 4rpx;
color: rgba(147, 141, 131, 0.9);
}
}
.nickname-dialog-mask {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: 32rpx;
background: rgba(33, 24, 12, 0.32);
box-sizing: border-box;
}
.nickname-dialog {
width: 100%;
padding: 34rpx 30rpx 30rpx;
border-radius: 28rpx;
background: #fffefd;
box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.12);
box-sizing: border-box;
&__title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #3b352d;
line-height: 1.3;
}
&__desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.6;
color: #948b7d;
}
&__input {
margin-top: 24rpx;
height: 88rpx;
padding: 0 24rpx;
border-radius: 18rpx;
background: #f7f5f1;
border: 1rpx solid rgba(166, 121, 57, 0.08);
font-size: 28rpx;
color: #3b352d;
box-sizing: border-box;
}
}
&__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18rpx;
margin-top: 28rpx;
}
.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);
}
&__btn {
display: flex;
align-items: center;
justify-content: center;
height: 84rpx;
border-radius: 18rpx;
font-size: 28rpx;
font-weight: 600;
color: #fffefd;
background: linear-gradient(135deg, #ac9a7d, #948269);
.empty-title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: #111827;
}
&--ghost {
color: #847b70;
background: #f4f2ee;
}
}
}
.empty-desc {
display: block;
margin-top: 16rpx;
font-size: 26rpx;
line-height: 1.7;
color: #6b7280;
}
</style>
......
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()
}