hookehuyr

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

- 新增应用页和我的备份页,支持 WebView 模式
- 重构我的页面为 WebView 承接页,移除静态 UI
- 新增底部导航配置 API、Mock 数据及 Pinia 状态管理
- 重构 AppTabbar 组件以使用动态配置,支持图标映射和页面路由
- 在应用启动时预加载导航配置,优化用户体验
- 将“消息”统一更名为“资讯”,更新相关页面文案
1 +import { fn, fetch } from './fn'
2 +
3 +const Api = {
4 + TABBAR_CONFIG: '/srv/?a=tabbar&t=config',
5 +}
6 +
7 +export const getTabbarConfigAPI = () => fn(fetch.get(Api.TABBAR_CONFIG))
...@@ -4,7 +4,9 @@ export default { ...@@ -4,7 +4,9 @@ export default {
4 'pages/map-guide/index', 4 'pages/map-guide/index',
5 'pages/message/index', 5 'pages/message/index',
6 'pages/message-detail/index', 6 'pages/message-detail/index',
7 + 'pages/application/index',
7 'pages/mine/index', 8 'pages/mine/index',
9 + 'pages/mine-backup/index',
8 'pages/pay-test/index', 10 'pages/pay-test/index',
9 'pages/pay-bridge/index', 11 'pages/pay-bridge/index',
10 'pages/webview-preview/index', 12 'pages/webview-preview/index',
......
...@@ -3,6 +3,9 @@ import { createPinia } from 'pinia' ...@@ -3,6 +3,9 @@ import { createPinia } from 'pinia'
3 import './utils/polyfill' 3 import './utils/polyfill'
4 import './app.less' 4 import './app.less'
5 import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect' 5 import { saveCurrentPagePath, hasAuth, silentAuth, navigateToAuth } from '@/utils/authRedirect'
6 +import { useTabbarStore } from '@/stores/tabbar'
7 +
8 +const pinia = createPinia()
6 9
7 const App = createApp({ 10 const App = createApp({
8 async onLaunch(options) { 11 async onLaunch(options) {
...@@ -26,13 +29,21 @@ const App = createApp({ ...@@ -26,13 +29,21 @@ const App = createApp({
26 } catch (error) { 29 } catch (error) {
27 console.error('静默授权失败:', error) 30 console.error('静默授权失败:', error)
28 navigateToAuth(full_path || undefined) 31 navigateToAuth(full_path || undefined)
32 + return
29 } 33 }
30 } 34 }
35 +
36 + try {
37 + const tabbarStore = useTabbarStore(pinia)
38 + await tabbarStore.ensureLoaded()
39 + } catch (error) {
40 + console.error('预加载底部导航配置失败:', error)
41 + }
31 }, 42 },
32 onShow() { 43 onShow() {
33 }, 44 },
34 }) 45 })
35 46
36 -App.use(createPinia()) 47 +App.use(pinia)
37 48
38 export default App 49 export default App
......
...@@ -6,9 +6,9 @@ ...@@ -6,9 +6,9 @@
6 <view class="app-tabbar__panel"> 6 <view class="app-tabbar__panel">
7 <view 7 <view
8 v-for="item in tabItems" 8 v-for="item in tabItems"
9 - :key="item.name" 9 + :key="item.key"
10 class="app-tabbar__item" 10 class="app-tabbar__item"
11 - :class="{ 'is-active': isActive(item.name) }" 11 + :class="{ 'is-active': isActive(item.key) }"
12 @tap="handleTabClick(item)" 12 @tap="handleTabClick(item)"
13 > 13 >
14 <view class="app-tabbar__item-inner"> 14 <view class="app-tabbar__item-inner">
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
16 <component 16 <component
17 :is="item.icon" 17 :is="item.icon"
18 size="18" 18 size="18"
19 - :color="isActive(item.name) ? activeColor : inactiveColor" 19 + :color="isActive(item.key) ? activeColor : inactiveColor"
20 /> 20 />
21 </view> 21 </view>
22 <text class="app-tabbar__label">{{ item.title }}</text> 22 <text class="app-tabbar__label">{{ item.title }}</text>
...@@ -28,8 +28,10 @@ ...@@ -28,8 +28,10 @@
28 </template> 28 </template>
29 29
30 <script setup> 30 <script setup>
31 +import { computed, onMounted } from 'vue'
31 import Taro from '@tarojs/taro' 32 import Taro from '@tarojs/taro'
32 -import { Home, Message, My } from '@nutui/icons-vue-taro' 33 +import { Category, Home, My, Notice, Service } from '@nutui/icons-vue-taro'
34 +import { useTabbarStore } from '@/stores/tabbar'
33 35
34 const props = defineProps({ 36 const props = defineProps({
35 current: { 37 current: {
...@@ -41,36 +43,35 @@ const props = defineProps({ ...@@ -41,36 +43,35 @@ const props = defineProps({
41 const activeColor = '#a67939' 43 const activeColor = '#a67939'
42 const inactiveColor = '#8b95a7' 44 const inactiveColor = '#8b95a7'
43 45
44 -const tabItems = [ 46 +const tabbarStore = useTabbarStore()
45 - { 47 +
46 - name: 'home', 48 +const tabIconMap = {
47 - title: '首页', 49 + home: Home,
48 - icon: Home, 50 + message: Notice,
49 - url: '/pages/index/index', 51 + application: Category,
50 - }, 52 + mine: My,
51 - { 53 +}
52 - name: 'message', 54 +
53 - title: '消息', 55 +const tabItems = computed(() => (
54 - icon: Message, 56 + tabbarStore.visibleTabItems.map((item) => ({
55 - url: '/pages/message/index', 57 + ...item,
56 - }, 58 + icon: tabIconMap[item.key] || Service,
57 - { 59 + }))
58 - name: 'mine', 60 +))
59 - title: '我的',
60 - icon: My,
61 - url: '/pages/mine/index',
62 - },
63 -]
64 61
65 const isActive = (name) => name === props.current 62 const isActive = (name) => name === props.current
66 63
67 const handleTabClick = (item) => { 64 const handleTabClick = (item) => {
68 - if (!item?.url || item.name === props.current) { 65 + if (!item?.page_url || item.key === props.current) {
69 return 66 return
70 } 67 }
71 68
72 - Taro.redirectTo({ url: item.url }) 69 + Taro.redirectTo({ url: item.page_url })
73 } 70 }
71 +
72 +onMounted(() => {
73 + tabbarStore.ensureLoaded()
74 +})
74 </script> 75 </script>
75 76
76 <style lang="less"> 77 <style lang="less">
......
1 +export const getTabbarConfigFixture = () => ({
2 + tab_items: [
3 + {
4 + key: 'home',
5 + title: '首页',
6 + visible: true,
7 + },
8 + {
9 + key: 'message',
10 + title: '资讯',
11 + visible: true,
12 + },
13 + {
14 + key: 'application',
15 + title: '应用',
16 + visible: true,
17 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
18 + webview_title: '应用',
19 + },
20 + {
21 + key: 'mine',
22 + title: '我的',
23 + visible: true,
24 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
25 + webview_title: '我的',
26 + },
27 + ],
28 +})
...@@ -3,6 +3,7 @@ import { paymentMockHandlers } from './payment.mock' ...@@ -3,6 +3,7 @@ import { paymentMockHandlers } from './payment.mock'
3 import { commonMockHandlers } from './common.mock' 3 import { commonMockHandlers } from './common.mock'
4 import { messageMockHandlers } from './message.mock' 4 import { messageMockHandlers } from './message.mock'
5 import { homeMockHandlers } from './home.mock' 5 import { homeMockHandlers } from './home.mock'
6 +import { tabbarMockHandlers } from './tabbar.mock'
6 7
7 export const mockHandlers = [ 8 export const mockHandlers = [
8 ...authMockHandlers, 9 ...authMockHandlers,
...@@ -10,4 +11,5 @@ export const mockHandlers = [ ...@@ -10,4 +11,5 @@ export const mockHandlers = [
10 ...commonMockHandlers, 11 ...commonMockHandlers,
11 ...messageMockHandlers, 12 ...messageMockHandlers,
12 ...homeMockHandlers, 13 ...homeMockHandlers,
14 + ...tabbarMockHandlers,
13 ] 15 ]
......
1 +import { getTabbarConfigFixture } from '../fixtures/tabbar.fixture'
2 +import { buildMockSuccess } from '../shared/response'
3 +
4 +export const tabbarMockHandlers = [
5 + {
6 + action: 'tabbar',
7 + type: 'config',
8 + method: 'GET',
9 + handle: () => buildMockSuccess(getTabbarConfigFixture(), '底部导航配置获取成功 (mock)'),
10 + },
11 +]
1 +export default {
2 + navigationBarTitleText: '应用',
3 +}
1 +<template>
2 + <web-view v-if="preview_url" :src="preview_url" />
3 + <view v-else class="tab-webview-page">
4 + <view class="empty-card">
5 + <text class="empty-title">暂未配置应用地址</text>
6 + <text class="empty-desc">
7 + 当前应用页还没有收到可用的外部链接,请先检查底部导航配置接口返回。
8 + </text>
9 + </view>
10 + </view>
11 +</template>
12 +
13 +<script setup>
14 +import { ref } from 'vue'
15 +import Taro, { useLoad } from '@tarojs/taro'
16 +import { useTabbarStore } from '@/stores/tabbar'
17 +
18 +const tabbarStore = useTabbarStore()
19 +const preview_url = ref('')
20 +const default_page_title = '应用'
21 +
22 +const initPage = async () => {
23 + await tabbarStore.ensureLoaded()
24 +
25 + const currentTab = tabbarStore.getTabItem('application')
26 + preview_url.value = currentTab?.webview_url || ''
27 +
28 + Taro.setNavigationBarTitle({
29 + title: currentTab?.webview_title || currentTab?.title || default_page_title,
30 + })
31 +
32 + if (!preview_url.value) {
33 + Taro.showToast({
34 + title: '暂未配置应用地址',
35 + icon: 'none',
36 + })
37 + }
38 +}
39 +
40 +useLoad(() => {
41 + initPage()
42 +})
43 +</script>
44 +
45 +<style lang="less">
46 +.tab-webview-page {
47 + min-height: 100vh;
48 + padding: 32rpx 24rpx;
49 + box-sizing: border-box;
50 + background:
51 + radial-gradient(circle at top right, rgba(166, 121, 57, 0.16), transparent 30%),
52 + linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
53 +}
54 +
55 +.empty-card {
56 + background: rgba(255, 255, 255, 0.94);
57 + border: 2rpx solid rgba(166, 121, 57, 0.08);
58 + border-radius: 28rpx;
59 + padding: 32rpx;
60 + box-sizing: border-box;
61 + box-shadow: 0 20rpx 60rpx rgba(15, 23, 42, 0.06);
62 +}
63 +
64 +.empty-title {
65 + display: block;
66 + font-size: 36rpx;
67 + font-weight: 700;
68 + color: #111827;
69 +}
70 +
71 +.empty-desc {
72 + display: block;
73 + margin-top: 16rpx;
74 + font-size: 26rpx;
75 + line-height: 1.7;
76 + color: #6b7280;
77 +}
78 +</style>
1 export default { 1 export default {
2 - navigationBarTitleText: '消息详情', 2 + navigationBarTitleText: '资讯详情',
3 } 3 }
......
...@@ -2,15 +2,15 @@ ...@@ -2,15 +2,15 @@
2 <view class="message-detail-page"> 2 <view class="message-detail-page">
3 <view class="page-content"> 3 <view class="page-content">
4 <view class="hero-card"> 4 <view class="hero-card">
5 - <text class="hero-title">消息详情</text> 5 + <text class="hero-title">资讯详情</text>
6 <text class="hero-desc"> 6 <text class="hero-desc">
7 - 进入详情页后,当前消息会在 mock 中自动标记为已读,返回列表即可看到状态变化。 7 + 进入详情页后,当前资讯会在 mock 中自动标记为已读,返回列表即可看到状态变化。
8 </text> 8 </text>
9 </view> 9 </view>
10 10
11 <view v-if="loading" class="placeholder-card"> 11 <view v-if="loading" class="placeholder-card">
12 <text class="section-title">加载中</text> 12 <text class="section-title">加载中</text>
13 - <text class="section-desc">正在获取消息详情...</text> 13 + <text class="section-desc">正在获取资讯详情...</text>
14 </view> 14 </view>
15 15
16 <view v-else-if="detail" class="detail-card"> 16 <view v-else-if="detail" class="detail-card">
...@@ -23,13 +23,13 @@ ...@@ -23,13 +23,13 @@
23 </view> 23 </view>
24 24
25 <view v-else class="placeholder-card"> 25 <view v-else class="placeholder-card">
26 - <text class="section-title">未找到消息</text> 26 + <text class="section-title">未找到资讯</text>
27 <text class="section-desc"> 27 <text class="section-desc">
28 - 当前消息不存在,或者真实接口还没有返回这条详情数据。 28 + 当前资讯不存在,或者真实接口还没有返回这条详情数据。
29 </text> 29 </text>
30 </view> 30 </view>
31 31
32 - <button class="back-btn" @tap="goBack">返回消息列表</button> 32 + <button class="back-btn" @tap="goBack">返回资讯列表</button>
33 </view> 33 </view>
34 </view> 34 </view>
35 </template> 35 </template>
...@@ -54,11 +54,11 @@ const fetchMessageDetail = async (id) => { ...@@ -54,11 +54,11 @@ const fetchMessageDetail = async (id) => {
54 54
55 detail.value = null 55 detail.value = null
56 Taro.showToast({ 56 Taro.showToast({
57 - title: response?.msg || '获取详情失败', 57 + title: response?.msg || '获取资讯详情失败',
58 icon: 'none', 58 icon: 'none',
59 }) 59 })
60 } catch (error) { 60 } catch (error) {
61 - console.error('获取消息详情失败:', error) 61 + console.error('获取资讯详情失败:', error)
62 detail.value = null 62 detail.value = null
63 } finally { 63 } finally {
64 loading.value = false 64 loading.value = false
......
1 export default { 1 export default {
2 - navigationBarTitleText: '消息', 2 + navigationBarTitleText: '资讯',
3 } 3 }
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
3 <view class="page-content"> 3 <view class="page-content">
4 <view class="hero-card"> 4 <view class="hero-card">
5 <view class="hero-head"> 5 <view class="hero-head">
6 - <text class="hero-title">消息</text> 6 + <text class="hero-title">资讯</text>
7 <text class="env-tag" :class="{ mock: current_env_use_mock }"> 7 <text class="env-tag" :class="{ mock: current_env_use_mock }">
8 {{ current_env_label }} 8 {{ current_env_label }}
9 </text> 9 </text>
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
17 <view> 17 <view>
18 <text class="section-title">列表状态</text> 18 <text class="section-title">列表状态</text>
19 <text class="section-desc"> 19 <text class="section-desc">
20 - 共 {{ total }} 条消息,未读 {{ unread_count }} 条。进入详情页后,当前消息会在 mock 中自动变成已读。 20 + 共 {{ total }} 条资讯,未读 {{ unread_count }} 条。进入详情页后,当前资讯会在 mock 中自动变成已读。
21 </text> 21 </text>
22 </view> 22 </view>
23 <button class="refresh-btn" :loading="loading" @tap="handleRefresh"> 23 <button class="refresh-btn" :loading="loading" @tap="handleRefresh">
...@@ -28,14 +28,14 @@ ...@@ -28,14 +28,14 @@
28 <view v-if="loading && !message_list.length" class="placeholder-card"> 28 <view v-if="loading && !message_list.length" class="placeholder-card">
29 <text class="section-title">加载中</text> 29 <text class="section-title">加载中</text>
30 <text class="section-desc"> 30 <text class="section-desc">
31 - 正在拉取消息列表... 31 + 正在拉取资讯列表...
32 </text> 32 </text>
33 </view> 33 </view>
34 34
35 <view v-else-if="!message_list.length" class="placeholder-card"> 35 <view v-else-if="!message_list.length" class="placeholder-card">
36 - <text class="section-title">暂无消息</text> 36 + <text class="section-title">暂无资讯</text>
37 <text class="section-desc"> 37 <text class="section-desc">
38 - 当前接口还没有返回消息内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。 38 + 当前接口还没有返回资讯内容,可以先在 mock 里补结构,再回到这个页面验证展示效果。
39 </text> 39 </text>
40 </view> 40 </view>
41 41
...@@ -110,7 +110,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => { ...@@ -110,7 +110,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => {
110 110
111 if (response?.code !== 1) { 111 if (response?.code !== 1) {
112 Taro.showToast({ 112 Taro.showToast({
113 - title: response?.msg || '获取消息失败', 113 + title: response?.msg || '获取资讯失败',
114 icon: 'none', 114 icon: 'none',
115 }) 115 })
116 return 116 return
...@@ -122,7 +122,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => { ...@@ -122,7 +122,7 @@ const fetchMessageList = async (nextPage = 0, append = false) => {
122 has_more.value = !!response?.data?.has_more 122 has_more.value = !!response?.data?.has_more
123 page.value = nextPage 123 page.value = nextPage
124 } catch (error) { 124 } catch (error) {
125 - console.error('获取消息列表失败:', error) 125 + console.error('获取资讯列表失败:', error)
126 } finally { 126 } finally {
127 loading.value = false 127 loading.value = false
128 loading_more.value = false 128 loading_more.value = false
......
1 +export default {
2 + navigationBarTitleText: '我的备份',
3 +}
1 +<template>
2 + <view class="mine-page">
3 + <view class="header-bg">
4 + <view class="header-pattern" />
5 + </view>
6 +
7 + <view class="page-content">
8 + <view class="hero-banner">
9 + <view class="hero-banner__ornament">
10 + <view class="hero-banner__orb hero-banner__orb--lg" />
11 + <view class="hero-banner__orb hero-banner__orb--sm" />
12 + </view>
13 + <view class="profile-panel" @tap="handleProfileTap">
14 + <view class="profile-panel__avatar">
15 + <view class="avatar-ring">
16 + <button
17 + class="avatar-btn"
18 + open-type="chooseAvatar"
19 + @chooseavatar="onChooseAvatar"
20 + >
21 + <image
22 + class="avatar-img"
23 + :src="avatarUrl || defaultAvatar"
24 + mode="aspectFill"
25 + />
26 + </button>
27 + </view>
28 + </view>
29 + <view class="profile-panel__body">
30 + <view class="profile-panel__head" @tap.stop="openNicknameDialog">
31 + <view class="profile-panel__name-block">
32 + <text class="nickname-text">{{ nickname || defaultNickname }}</text>
33 + <text class="profile-panel__subtext">点击修改昵称</text>
34 + </view>
35 + <text class="profile-panel__action">›</text>
36 + </view>
37 + <text class="profile-panel__tip">
38 + 当前还没接微信昵称接口,先支持手动输入并直接显示在这里。
39 + </text>
40 + </view>
41 + </view>
42 + </view>
43 +
44 + <view class="menu-section">
45 + <view class="section-title">
46 + <text class="section-title-text">常用服务</text>
47 + </view>
48 + <view class="menu-grid">
49 + <view
50 + v-for="item in menuItems"
51 + :key="item.key"
52 + class="menu-item"
53 + @tap="handleMenuTap(item)"
54 + >
55 + <view class="menu-item__main">
56 + <view class="menu-icon-wrap" :class="`menu-icon--${item.key}`">
57 + <component
58 + :is="item.icon"
59 + size="20"
60 + color="#a67939"
61 + />
62 + </view>
63 + <view class="menu-item__content">
64 + <text class="menu-item-title">{{ item.title }}</text>
65 + <text class="menu-item-desc">{{ item.desc }}</text>
66 + </view>
67 + </view>
68 + <text class="menu-item-arrow">›</text>
69 + </view>
70 + </view>
71 + </view>
72 +
73 + <view class="menu-section">
74 + <view class="section-title">
75 + <text class="section-title-text">更多</text>
76 + </view>
77 + <view class="menu-list">
78 + <view
79 + v-for="(item, idx) in bottomMenuItems"
80 + :key="item.key"
81 + class="menu-list-item"
82 + :class="{ 'menu-list-item--last': idx === bottomMenuItems.length - 1 }"
83 + @tap="handleMenuTap(item)"
84 + >
85 + <view class="menu-list-icon" :class="`menu-icon--${item.key}`">
86 + <component
87 + :is="item.icon"
88 + size="18"
89 + color="#a67939"
90 + />
91 + </view>
92 + <view class="menu-list-content">
93 + <text class="menu-list-title">{{ item.title }}</text>
94 + <text class="menu-list-desc">{{ item.desc }}</text>
95 + </view>
96 + <text class="arrow-text arrow-text--light">›</text>
97 + </view>
98 + </view>
99 + </view>
100 +
101 + <view class="page-footer-tip">
102 + <text class="page-footer-tip__text">山门清净,信息常新</text>
103 + </view>
104 + </view>
105 +
106 + <view
107 + v-if="nicknameDialogVisible"
108 + class="nickname-dialog-mask"
109 + @tap="closeNicknameDialog"
110 + >
111 + <view class="nickname-dialog" @tap.stop>
112 + <text class="nickname-dialog__title">设置昵称</text>
113 + <text class="nickname-dialog__desc">当前版本先手动输入昵称,留空时显示默认昵称。</text>
114 + <input
115 + class="nickname-dialog__input"
116 + :value="nicknameDraft"
117 + maxlength="20"
118 + placeholder="请输入昵称"
119 + focus
120 + @input="onNicknameDraftInput"
121 + />
122 + <view class="nickname-dialog__actions">
123 + <view
124 + class="nickname-dialog__btn nickname-dialog__btn--ghost"
125 + @tap="closeNicknameDialog"
126 + >
127 + 取消
128 + </view>
129 + <view
130 + class="nickname-dialog__btn"
131 + @tap="confirmNickname"
132 + >
133 + 确定
134 + </view>
135 + </view>
136 + </view>
137 + </view>
138 +
139 + <AppTabbar current="mine" />
140 + </view>
141 +</template>
142 +
143 +<script setup>
144 +import { ref } from 'vue'
145 +import Taro from '@tarojs/taro'
146 +import AppTabbar from '@/components/AppTabbar.vue'
147 +import {
148 + Footprint,
149 + Location,
150 + Notice,
151 + Order,
152 + Service,
153 + Setting,
154 + Star,
155 +} from '@nutui/icons-vue-taro'
156 +
157 +const avatarUrl = ref('')
158 +const nickname = ref('')
159 +const nicknameDraft = ref('')
160 +const nicknameDialogVisible = ref(false)
161 +const defaultNickname = '觉林寺访客'
162 +const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgdmlld0JveD0iMCAwIDEyMCAxMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMTgiIHkxPSIxNCIgeDI9IjEwMSIgeTI9IjEwNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNDRkE4NUEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM5RTcxMkQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgcng9IjYwIiBmaWxsPSJ1cmwoI2cpIi8+PGNpcmNsZSBjeD0iNjAiIGN5PSI0MiIgcj0iMTgiIGZpbGw9IiNGRkY3RTYiIGZpbGwtb3BhY2l0eT0iMC45NiIvPjxwYXRoIGQ9Ik0yNSA5MEMyNSA3NC41MzYgMzcuNTM2IDYyIDUzIDYySDY3QzgyLjQ2NCA2MiA5NSA3NC41MzYgOTUgOTBWOTJIMjVWOTBaIiBmaWxsPSIjRkZGN0U2IiBmaWxsLW9wYWNpdHk9IjAuOTYiLz48Y2lyY2xlIGN4PSI4OSIgY3k9IjI5IiByPSIxMCIgZmlsbD0iI0Y3REU5RCIgZmlsbC1vcGFjaXR5PSIwLjcyIi8+PC9zdmc+'
163 +
164 +const menuItems = [
165 + { key: 'orders', title: '我的订单', desc: '查看法物订单', icon: Order },
166 + { key: 'favorites', title: '我的收藏', desc: '收藏的法物', icon: Star },
167 + { key: 'history', title: '浏览记录', desc: '最近浏览', icon: Footprint },
168 + { key: 'address', title: '收货地址', desc: '管理地址', icon: Location },
169 +]
170 +
171 +const bottomMenuItems = [
172 + { key: 'settings', title: '设置', desc: '通知与隐私', icon: Setting },
173 + { key: 'about', title: '关于觉林寺', desc: '了解寺院历史', icon: Service },
174 + { key: 'help', title: '联系客堂', desc: '在线咨询', icon: Notice },
175 +]
176 +
177 +const onChooseAvatar = (e) => {
178 + const { avatarUrl: url } = e.detail
179 + avatarUrl.value = url
180 +}
181 +
182 +const openNicknameDialog = () => {
183 + nicknameDraft.value = nickname.value
184 + nicknameDialogVisible.value = true
185 +}
186 +
187 +const closeNicknameDialog = () => {
188 + nicknameDialogVisible.value = false
189 +}
190 +
191 +const onNicknameDraftInput = (e) => {
192 + nicknameDraft.value = e.detail.value
193 +}
194 +
195 +const confirmNickname = () => {
196 + nickname.value = nicknameDraft.value.trim()
197 + closeNicknameDialog()
198 +}
199 +
200 +const handleProfileTap = () => {}
201 +
202 +const handleMenuTap = ({ title }) => {
203 + Taro.showToast({ title: `${title}功能开发中`, icon: 'none' })
204 +}
205 +</script>
206 +
207 +<style lang="less">
208 +.mine-page {
209 + min-height: 100vh;
210 + background:
211 + radial-gradient(circle at top right, rgba(166, 121, 57, 0.1), transparent 32%),
212 + linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
213 + position: relative;
214 +
215 + .header-bg {
216 + position: absolute;
217 + top: 0;
218 + left: 0;
219 + right: 0;
220 + height: 304rpx;
221 + background: linear-gradient(180deg, rgba(214, 184, 124, 0.18) 0%, rgba(214, 184, 124, 0.04) 72%, rgba(214, 184, 124, 0) 100%);
222 + border-radius: 0 0 48rpx 48rpx;
223 + overflow: hidden;
224 + }
225 +
226 + .header-pattern {
227 + position: absolute;
228 + top: 0;
229 + left: 0;
230 + right: 0;
231 + bottom: 0;
232 + background-image:
233 + radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
234 + radial-gradient(circle at 80% 60%, rgba(255, 255, 255, 0.04) 0%, transparent 40%);
235 + }
236 +
237 + .page-content {
238 + position: relative;
239 + padding: 32rpx 28rpx 176rpx;
240 + box-sizing: border-box;
241 + z-index: 1;
242 + }
243 +
244 + .hero-banner {
245 + position: relative;
246 + margin-top: 10rpx;
247 + padding: 28rpx;
248 + border-radius: 32rpx;
249 + background: linear-gradient(135deg, rgba(255, 251, 246, 0.72), rgba(255, 255, 255, 0.52));
250 + border: 2rpx solid rgba(214, 184, 124, 0.08);
251 + box-shadow:
252 + inset 0 2rpx 0 rgba(255, 255, 255, 0.22),
253 + 0 12rpx 36rpx rgba(15, 23, 42, 0.03);
254 + backdrop-filter: blur(20rpx) saturate(118%);
255 + overflow: hidden;
256 + box-sizing: border-box;
257 +
258 + &__ornament {
259 + position: absolute;
260 + top: 0;
261 + right: 0;
262 + bottom: 0;
263 + width: 200rpx;
264 + pointer-events: none;
265 + }
266 +
267 + &__orb {
268 + position: absolute;
269 + border-radius: 50%;
270 + background: radial-gradient(circle, rgba(214, 184, 124, 0.12) 0%, rgba(214, 184, 124, 0.02) 70%, transparent 100%);
271 +
272 + &--lg {
273 + width: 180rpx;
274 + height: 180rpx;
275 + top: -24rpx;
276 + right: -22rpx;
277 + }
278 +
279 + &--sm {
280 + width: 92rpx;
281 + height: 92rpx;
282 + right: 42rpx;
283 + bottom: 22rpx;
284 + }
285 + }
286 + }
287 +
288 + .profile-panel {
289 + position: relative;
290 + z-index: 1;
291 + display: flex;
292 + align-items: center;
293 + gap: 24rpx;
294 + padding: 30rpx 28rpx;
295 + min-height: 176rpx;
296 + box-sizing: border-box;
297 +
298 + &__avatar {
299 + flex-shrink: 0;
300 + }
301 +
302 + &__body {
303 + flex: 1;
304 + min-width: 0;
305 + display: flex;
306 + flex-direction: column;
307 + gap: 12rpx;
308 + }
309 +
310 + &__head {
311 + display: flex;
312 + align-items: center;
313 + justify-content: space-between;
314 + gap: 20rpx;
315 + }
316 +
317 + &__action {
318 + flex-shrink: 0;
319 + font-size: 30rpx;
320 + line-height: 1;
321 + color: rgba(125, 109, 82, 0.64);
322 + }
323 +
324 + &__name-block {
325 + min-width: 0;
326 + display: flex;
327 + flex-direction: column;
328 + gap: 6rpx;
329 + }
330 +
331 + &__subtext {
332 + font-size: 22rpx;
333 + line-height: 1.4;
334 + color: #948b7d;
335 + }
336 +
337 + &__tip {
338 + font-size: 22rpx;
339 + line-height: 1.6;
340 + color: #948b7d;
341 + }
342 + }
343 +
344 + .avatar-ring {
345 + width: 128rpx;
346 + height: 128rpx;
347 + border-radius: 50%;
348 + padding: 4rpx;
349 + background: linear-gradient(135deg, #e6dcc1, #cdb991);
350 + }
351 +
352 + .avatar-btn {
353 + padding: 0;
354 + margin: 0;
355 + width: 120rpx;
356 + height: 120rpx;
357 + border-radius: 50%;
358 + overflow: hidden;
359 + background: transparent;
360 + border: none;
361 + line-height: 1;
362 +
363 + &::after {
364 + border: none;
365 + }
366 + }
367 +
368 + .avatar-img {
369 + width: 120rpx;
370 + height: 120rpx;
371 + border-radius: 50%;
372 + display: block;
373 + }
374 +
375 + .profile-info {
376 + display: none;
377 + }
378 +
379 + .nickname-text {
380 + font-size: 36rpx;
381 + font-weight: 700;
382 + color: #3b352d;
383 + line-height: 1.4;
384 + }
385 +
386 + .arrow-text {
387 + font-size: 28rpx;
388 + color: #b9ac96;
389 + line-height: 1;
390 +
391 + &--light {
392 + color: #d1c9b5;
393 + }
394 + }
395 +
396 + .section-title {
397 + padding: 34rpx 8rpx 18rpx;
398 + display: flex;
399 + align-items: center;
400 + justify-content: space-between;
401 + }
402 +
403 + .section-title-text {
404 + font-size: 26rpx;
405 + font-weight: 600;
406 + color: #847b70;
407 + letter-spacing: 2rpx;
408 + position: relative;
409 + padding-left: 18rpx;
410 +
411 + &::before {
412 + content: '';
413 + position: absolute;
414 + left: 0;
415 + top: 8rpx;
416 + bottom: 8rpx;
417 + width: 6rpx;
418 + border-radius: 999rpx;
419 + background: linear-gradient(180deg, #d8c7a4, #b8a489);
420 + }
421 + }
422 +
423 + .menu-grid {
424 + display: grid;
425 + grid-template-columns: repeat(2, minmax(0, 1fr));
426 + gap: 18rpx;
427 + background: rgba(255, 255, 255, 0.98);
428 + border-radius: 24rpx;
429 + border: 2rpx solid rgba(166, 121, 57, 0.05);
430 + box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
431 + padding: 22rpx;
432 + box-sizing: border-box;
433 + }
434 +
435 + .menu-item {
436 + display: flex;
437 + align-items: center;
438 + justify-content: space-between;
439 + gap: 16rpx;
440 + padding: 22rpx 20rpx;
441 + min-height: 0;
442 + border-radius: 20rpx;
443 + background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 249, 246, 0.96) 100%);
444 + border: 1rpx solid rgba(166, 121, 57, 0.05);
445 + box-sizing: border-box;
446 + }
447 +
448 + .menu-item__main {
449 + min-width: 0;
450 + flex: 1;
451 + display: flex;
452 + align-items: center;
453 + gap: 16rpx;
454 + }
455 +
456 + .menu-item__content {
457 + min-width: 0;
458 + display: flex;
459 + flex-direction: column;
460 + gap: 6rpx;
461 + }
462 +
463 + .menu-item-arrow {
464 + flex-shrink: 0;
465 + font-size: 26rpx;
466 + color: #c0b8ac;
467 + line-height: 1;
468 + }
469 +
470 + .menu-icon-wrap {
471 + width: 72rpx;
472 + height: 72rpx;
473 + border-radius: 20rpx;
474 + display: flex;
475 + align-items: center;
476 + justify-content: center;
477 + box-shadow: inset 0 2rpx 0 rgba(255, 255, 255, 0.45);
478 +
479 + &.menu-icon--orders {
480 + background: linear-gradient(135deg, #faf4ea, #f4ead7);
481 + }
482 + &.menu-icon--favorites {
483 + background: linear-gradient(135deg, #faf1ec, #f3e4da);
484 + }
485 + &.menu-icon--history {
486 + background: linear-gradient(135deg, #f0f5f0, #e3ece3);
487 + }
488 + &.menu-icon--address {
489 + background: linear-gradient(135deg, #eff2f6, #e4e9ef);
490 + }
491 + &.menu-icon--settings {
492 + background: linear-gradient(135deg, #f3f3f3, #e9e9e9);
493 + }
494 + &.menu-icon--about {
495 + background: linear-gradient(135deg, #faf4ea, #f4ead7);
496 + }
497 + &.menu-icon--help {
498 + background: linear-gradient(135deg, #f0f5f0, #e3ece3);
499 + }
500 + }
501 +
502 + .menu-item-title {
503 + font-size: 24rpx;
504 + font-weight: 600;
505 + color: #45403a;
506 + line-height: 1.2;
507 + }
508 +
509 + .menu-item-desc {
510 + font-size: 20rpx;
511 + color: #9a9184;
512 + line-height: 1.3;
513 + }
514 +
515 + .menu-list {
516 + background: rgba(255, 255, 255, 0.98);
517 + border-radius: 24rpx;
518 + border: 2rpx solid rgba(166, 121, 57, 0.05);
519 + box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
520 + overflow: hidden;
521 + }
522 +
523 + .menu-list-item {
524 + display: flex;
525 + align-items: center;
526 + padding: 28rpx 32rpx;
527 + gap: 24rpx;
528 + position: relative;
529 + background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 247, 0.98));
530 +
531 + &::after {
532 + content: '';
533 + position: absolute;
534 + bottom: 0;
535 + left: 100rpx;
536 + right: 32rpx;
537 + height: 1rpx;
538 + background: rgba(0, 0, 0, 0.05);
539 + }
540 +
541 + &--last::after {
542 + display: none;
543 + }
544 + }
545 +
546 + .menu-list-icon {
547 + width: 64rpx;
548 + height: 64rpx;
549 + border-radius: 16rpx;
550 + display: flex;
551 + align-items: center;
552 + justify-content: center;
553 + flex-shrink: 0;
554 + }
555 +
556 + .menu-list-content {
557 + flex: 1;
558 + min-width: 0;
559 + display: flex;
560 + flex-direction: column;
561 + gap: 6rpx;
562 + }
563 +
564 + .menu-list-title {
565 + font-size: 28rpx;
566 + font-weight: 500;
567 + color: #45403a;
568 + line-height: 1.3;
569 + }
570 +
571 + .menu-list-desc {
572 + font-size: 22rpx;
573 + color: #9a9184;
574 + line-height: 1.2;
575 + }
576 +
577 + .page-footer-tip {
578 + display: flex;
579 + justify-content: center;
580 + padding: 28rpx 0 8rpx;
581 +
582 + &__text {
583 + font-size: 22rpx;
584 + letter-spacing: 4rpx;
585 + color: rgba(147, 141, 131, 0.9);
586 + }
587 + }
588 +
589 + .nickname-dialog-mask {
590 + position: fixed;
591 + inset: 0;
592 + z-index: 20;
593 + display: flex;
594 + align-items: center;
595 + justify-content: center;
596 + padding: 32rpx;
597 + background: rgba(33, 24, 12, 0.32);
598 + box-sizing: border-box;
599 + }
600 +
601 + .nickname-dialog {
602 + width: 100%;
603 + padding: 34rpx 30rpx 30rpx;
604 + border-radius: 28rpx;
605 + background: #fffefd;
606 + box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.12);
607 + box-sizing: border-box;
608 +
609 + &__title {
610 + display: block;
611 + font-size: 32rpx;
612 + font-weight: 600;
613 + color: #3b352d;
614 + line-height: 1.3;
615 + }
616 +
617 + &__desc {
618 + display: block;
619 + margin-top: 12rpx;
620 + font-size: 24rpx;
621 + line-height: 1.6;
622 + color: #948b7d;
623 + }
624 +
625 + &__input {
626 + margin-top: 24rpx;
627 + height: 88rpx;
628 + padding: 0 24rpx;
629 + border-radius: 18rpx;
630 + background: #f7f5f1;
631 + border: 1rpx solid rgba(166, 121, 57, 0.08);
632 + font-size: 28rpx;
633 + color: #3b352d;
634 + box-sizing: border-box;
635 + }
636 +
637 + &__actions {
638 + display: grid;
639 + grid-template-columns: repeat(2, minmax(0, 1fr));
640 + gap: 18rpx;
641 + margin-top: 28rpx;
642 + }
643 +
644 + &__btn {
645 + display: flex;
646 + align-items: center;
647 + justify-content: center;
648 + height: 84rpx;
649 + border-radius: 18rpx;
650 + font-size: 28rpx;
651 + font-weight: 600;
652 + color: #fffefd;
653 + background: linear-gradient(135deg, #ac9a7d, #948269);
654 +
655 + &--ghost {
656 + color: #847b70;
657 + background: #f4f2ee;
658 + }
659 + }
660 + }
661 +}
662 +</style>
1 <template> 1 <template>
2 - <view class="mine-page"> 2 + <web-view v-if="preview_url" :src="preview_url" />
3 - <!-- 顶部装饰背景 --> 3 + <view v-else class="tab-webview-page">
4 - <view class="header-bg"> 4 + <view class="empty-card">
5 - <view class="header-pattern" /> 5 + <text class="empty-title">暂未配置我的页地址</text>
6 + <text class="empty-desc">
7 + 当前“我的”按钮已经切到 WebView 承接页,但接口还没有返回可用地址。
8 + </text>
6 </view> 9 </view>
7 -
8 - <view class="page-content">
9 - <view class="hero-banner">
10 - <view class="hero-banner__ornament">
11 - <view class="hero-banner__orb hero-banner__orb--lg" />
12 - <view class="hero-banner__orb hero-banner__orb--sm" />
13 - </view>
14 - <view class="profile-panel" @tap="handleProfileTap">
15 - <view class="profile-panel__avatar">
16 - <view class="avatar-ring">
17 - <button
18 - class="avatar-btn"
19 - open-type="chooseAvatar"
20 - @chooseavatar="onChooseAvatar"
21 - >
22 - <image
23 - class="avatar-img"
24 - :src="avatarUrl || defaultAvatar"
25 - mode="aspectFill"
26 - />
27 - </button>
28 - </view>
29 - </view>
30 - <view class="profile-panel__body">
31 - <view class="profile-panel__head" @tap.stop="openNicknameDialog">
32 - <view class="profile-panel__name-block">
33 - <text class="nickname-text">{{ nickname || defaultNickname }}</text>
34 - <text class="profile-panel__subtext">点击修改昵称</text>
35 - </view>
36 - <text class="profile-panel__action">›</text>
37 - </view>
38 - <text class="profile-panel__tip">
39 - 当前还没接微信昵称接口,先支持手动输入并直接显示在这里。
40 - </text>
41 - </view>
42 - </view>
43 - </view>
44 -
45 - <!-- 功能菜单 -->
46 - <view class="menu-section">
47 - <view class="section-title">
48 - <text class="section-title-text">常用服务</text>
49 - </view>
50 - <view class="menu-grid">
51 - <view
52 - v-for="item in menuItems"
53 - :key="item.key"
54 - class="menu-item"
55 - @tap="handleMenuTap(item)"
56 - >
57 - <view class="menu-item__main">
58 - <view class="menu-icon-wrap" :class="`menu-icon--${item.key}`">
59 - <component
60 - :is="item.icon"
61 - size="20"
62 - color="#a67939"
63 - />
64 - </view>
65 - <view class="menu-item__content">
66 - <text class="menu-item-title">{{ item.title }}</text>
67 - <text class="menu-item-desc">{{ item.desc }}</text>
68 - </view>
69 - </view>
70 - <text class="menu-item-arrow">›</text>
71 - </view>
72 - </view>
73 - </view>
74 -
75 - <!-- 更多菜单 -->
76 - <view class="menu-section">
77 - <view class="section-title">
78 - <text class="section-title-text">更多</text>
79 - </view>
80 - <view class="menu-list">
81 - <view
82 - v-for="(item, idx) in bottomMenuItems"
83 - :key="item.key"
84 - class="menu-list-item"
85 - :class="{ 'menu-list-item--last': idx === bottomMenuItems.length - 1 }"
86 - @tap="handleMenuTap(item)"
87 - >
88 - <view class="menu-list-icon" :class="`menu-icon--${item.key}`">
89 - <component
90 - :is="item.icon"
91 - size="18"
92 - color="#a67939"
93 - />
94 - </view>
95 - <view class="menu-list-content">
96 - <text class="menu-list-title">{{ item.title }}</text>
97 - <text class="menu-list-desc">{{ item.desc }}</text>
98 - </view>
99 - <text class="arrow-text arrow-text--light">›</text>
100 - </view>
101 - </view>
102 - </view>
103 -
104 - <view class="page-footer-tip">
105 - <text class="page-footer-tip__text">山门清净,信息常新</text>
106 - </view>
107 - </view>
108 -
109 - <view
110 - v-if="nicknameDialogVisible"
111 - class="nickname-dialog-mask"
112 - @tap="closeNicknameDialog"
113 - >
114 - <view class="nickname-dialog" @tap.stop>
115 - <text class="nickname-dialog__title">设置昵称</text>
116 - <text class="nickname-dialog__desc">当前版本先手动输入昵称,留空时显示默认昵称。</text>
117 - <input
118 - class="nickname-dialog__input"
119 - :value="nicknameDraft"
120 - maxlength="20"
121 - placeholder="请输入昵称"
122 - focus
123 - @input="onNicknameDraftInput"
124 - />
125 - <view class="nickname-dialog__actions">
126 - <view
127 - class="nickname-dialog__btn nickname-dialog__btn--ghost"
128 - @tap="closeNicknameDialog"
129 - >
130 - 取消
131 - </view>
132 - <view
133 - class="nickname-dialog__btn"
134 - @tap="confirmNickname"
135 - >
136 - 确定
137 - </view>
138 - </view>
139 - </view>
140 - </view>
141 -
142 - <AppTabbar current="mine" />
143 </view> 10 </view>
144 </template> 11 </template>
145 12
146 <script setup> 13 <script setup>
147 import { ref } from 'vue' 14 import { ref } from 'vue'
148 -import Taro from '@tarojs/taro' 15 +import Taro, { useLoad } from '@tarojs/taro'
149 -import AppTabbar from '@/components/AppTabbar.vue' 16 +import { useTabbarStore } from '@/stores/tabbar'
150 -import {
151 - Footprint,
152 - Location,
153 - Notice,
154 - Order,
155 - Service,
156 - Setting,
157 - Star,
158 -} from '@nutui/icons-vue-taro'
159 -
160 -const avatarUrl = ref('')
161 -const nickname = ref('')
162 -const nicknameDraft = ref('')
163 -const nicknameDialogVisible = ref(false)
164 -const defaultNickname = '觉林寺访客'
165 -const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgdmlld0JveD0iMCAwIDEyMCAxMjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMTgiIHkxPSIxNCIgeDI9IjEwMSIgeTI9IjEwNiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiNDRkE4NUEiLz48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiM5RTcxMkQiLz48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48cmVjdCB3aWR0aD0iMTIwIiBoZWlnaHQ9IjEyMCIgcng9IjYwIiBmaWxsPSJ1cmwoI2cpIi8+PGNpcmNsZSBjeD0iNjAiIGN5PSI0MiIgcj0iMTgiIGZpbGw9IiNGRkY3RTYiIGZpbGwtb3BhY2l0eT0iMC45NiIvPjxwYXRoIGQ9Ik0yNSA5MEMyNSA3NC41MzYgMzcuNTM2IDYyIDUzIDYySDY3QzgyLjQ2NCA2MiA5NSA3NC41MzYgOTUgOTBWOTJIMjVWOTBaIiBmaWxsPSIjRkZGN0U2IiBmaWxsLW9wYWNpdHk9IjAuOTYiLz48Y2lyY2xlIGN4PSI4OSIgY3k9IjI5IiByPSIxMCIgZmlsbD0iI0Y3REU5RCIgZmlsbC1vcGFjaXR5PSIwLjcyIi8+PC9zdmc+'
166 -
167 -const menuItems = [
168 - { key: 'orders', title: '我的订单', desc: '查看法物订单', icon: Order },
169 - { key: 'favorites', title: '我的收藏', desc: '收藏的法物', icon: Star },
170 - { key: 'history', title: '浏览记录', desc: '最近浏览', icon: Footprint },
171 - { key: 'address', title: '收货地址', desc: '管理地址', icon: Location },
172 -]
173 17
174 -const bottomMenuItems = [ 18 +const tabbarStore = useTabbarStore()
175 - { key: 'settings', title: '设置', desc: '通知与隐私', icon: Setting }, 19 +const preview_url = ref('')
176 - { key: 'about', title: '关于觉林寺', desc: '了解寺院历史', icon: Service }, 20 +const default_page_title = '我的'
177 - { key: 'help', title: '联系客堂', desc: '在线咨询', icon: Notice },
178 -]
179 21
180 -const onChooseAvatar = (e) => { 22 +const initPage = async () => {
181 - const { avatarUrl: url } = e.detail 23 + await tabbarStore.ensureLoaded()
182 - avatarUrl.value = url
183 -}
184 -
185 -const openNicknameDialog = () => {
186 - nicknameDraft.value = nickname.value
187 - nicknameDialogVisible.value = true
188 -}
189 24
190 -const closeNicknameDialog = () => { 25 + const currentTab = tabbarStore.getTabItem('mine')
191 - nicknameDialogVisible.value = false 26 + preview_url.value = currentTab?.webview_url || ''
192 -}
193 27
194 -const onNicknameDraftInput = (e) => { 28 + Taro.setNavigationBarTitle({
195 - nicknameDraft.value = e.detail.value 29 + title: currentTab?.webview_title || currentTab?.title || default_page_title,
196 -} 30 + })
197 31
198 -const confirmNickname = () => { 32 + if (!preview_url.value) {
199 - nickname.value = nicknameDraft.value.trim() 33 + Taro.showToast({
200 - closeNicknameDialog() 34 + title: '暂未配置我的页地址',
35 + icon: 'none',
36 + })
37 + }
201 } 38 }
202 39
203 -const handleProfileTap = () => {} 40 +useLoad(() => {
204 - 41 + initPage()
205 -const handleMenuTap = ({ title }) => { 42 +})
206 - Taro.showToast({ title: `${title}功能开发中`, icon: 'none' })
207 -}
208 </script> 43 </script>
209 44
210 <style lang="less"> 45 <style lang="less">
211 -.mine-page { 46 +.tab-webview-page {
212 min-height: 100vh; 47 min-height: 100vh;
48 + padding: 32rpx 24rpx;
49 + box-sizing: border-box;
213 background: 50 background:
214 - radial-gradient(circle at top right, rgba(166, 121, 57, 0.1), transparent 32%), 51 + radial-gradient(circle at top right, rgba(166, 121, 57, 0.16), transparent 30%),
215 linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%); 52 linear-gradient(180deg, #fffaf3 0%, #f6f7fb 100%);
216 - position: relative; 53 +}
217 -
218 - // 顶部装饰背景
219 - .header-bg {
220 - position: absolute;
221 - top: 0;
222 - left: 0;
223 - right: 0;
224 - height: 304rpx;
225 - background: linear-gradient(180deg, rgba(214, 184, 124, 0.18) 0%, rgba(214, 184, 124, 0.04) 72%, rgba(214, 184, 124, 0) 100%);
226 - border-radius: 0 0 48rpx 48rpx;
227 - overflow: hidden;
228 - }
229 -
230 - .header-pattern {
231 - position: absolute;
232 - top: 0;
233 - left: 0;
234 - right: 0;
235 - bottom: 0;
236 - background-image:
237 - radial-gradient(circle at 20% 30%, rgba(255, 255, 255, 0.05) 0%, transparent 50%),
238 - radial-gradient(circle at 80% 60%, rgba(255, 255, 255, 0.04) 0%, transparent 40%);
239 - }
240 -
241 - .page-content {
242 - position: relative;
243 - padding: 32rpx 28rpx 176rpx;
244 - box-sizing: border-box;
245 - z-index: 1;
246 - }
247 -
248 - .hero-banner {
249 - position: relative;
250 - margin-top: 10rpx;
251 - padding: 28rpx;
252 - border-radius: 32rpx;
253 - background: linear-gradient(135deg, rgba(255, 251, 246, 0.72), rgba(255, 255, 255, 0.52));
254 - border: 2rpx solid rgba(214, 184, 124, 0.08);
255 - box-shadow:
256 - inset 0 2rpx 0 rgba(255, 255, 255, 0.22),
257 - 0 12rpx 36rpx rgba(15, 23, 42, 0.03);
258 - backdrop-filter: blur(20rpx) saturate(118%);
259 - overflow: hidden;
260 - box-sizing: border-box;
261 -
262 - &__ornament {
263 - position: absolute;
264 - top: 0;
265 - right: 0;
266 - bottom: 0;
267 - width: 200rpx;
268 - pointer-events: none;
269 - }
270 -
271 - &__orb {
272 - position: absolute;
273 - border-radius: 50%;
274 - background: radial-gradient(circle, rgba(214, 184, 124, 0.12) 0%, rgba(214, 184, 124, 0.02) 70%, transparent 100%);
275 -
276 - &--lg {
277 - width: 180rpx;
278 - height: 180rpx;
279 - top: -24rpx;
280 - right: -22rpx;
281 - }
282 -
283 - &--sm {
284 - width: 92rpx;
285 - height: 92rpx;
286 - right: 42rpx;
287 - bottom: 22rpx;
288 - }
289 - }
290 - }
291 -
292 - .profile-panel {
293 - position: relative;
294 - z-index: 1;
295 - display: flex;
296 - align-items: center;
297 - gap: 24rpx;
298 - padding: 30rpx 28rpx;
299 - min-height: 176rpx;
300 - box-sizing: border-box;
301 -
302 - &__avatar {
303 - flex-shrink: 0;
304 - }
305 -
306 - &__body {
307 - flex: 1;
308 - min-width: 0;
309 - display: flex;
310 - flex-direction: column;
311 - gap: 12rpx;
312 - }
313 -
314 - &__head {
315 - display: flex;
316 - align-items: center;
317 - justify-content: space-between;
318 - gap: 20rpx;
319 - }
320 -
321 - &__action {
322 - flex-shrink: 0;
323 - font-size: 30rpx;
324 - line-height: 1;
325 - color: rgba(125, 109, 82, 0.64);
326 - }
327 -
328 - &__name-block {
329 - min-width: 0;
330 - display: flex;
331 - flex-direction: column;
332 - gap: 6rpx;
333 - }
334 -
335 - &__subtext {
336 - font-size: 22rpx;
337 - line-height: 1.4;
338 - color: #948b7d;
339 - }
340 -
341 - &__tip {
342 - font-size: 22rpx;
343 - line-height: 1.6;
344 - color: #948b7d;
345 - }
346 - }
347 -
348 - .avatar-ring {
349 - width: 128rpx;
350 - height: 128rpx;
351 - border-radius: 50%;
352 - padding: 4rpx;
353 - background: linear-gradient(135deg, #e6dcc1, #cdb991);
354 - }
355 -
356 - .avatar-btn {
357 - padding: 0;
358 - margin: 0;
359 - width: 120rpx;
360 - height: 120rpx;
361 - border-radius: 50%;
362 - overflow: hidden;
363 - background: transparent;
364 - border: none;
365 - line-height: 1;
366 -
367 - &::after {
368 - border: none;
369 - }
370 - }
371 -
372 - .avatar-img {
373 - width: 120rpx;
374 - height: 120rpx;
375 - border-radius: 50%;
376 - display: block;
377 - }
378 -
379 - .profile-info {
380 - display: none;
381 - }
382 -
383 - .nickname-text {
384 - font-size: 36rpx;
385 - font-weight: 700;
386 - color: #3b352d;
387 - line-height: 1.4;
388 - }
389 -
390 - .arrow-text {
391 - font-size: 28rpx;
392 - color: #b9ac96;
393 - line-height: 1;
394 -
395 - &--light {
396 - color: #d1c9b5;
397 - }
398 - }
399 -
400 - // 菜单分区
401 - .section-title {
402 - padding: 34rpx 8rpx 18rpx;
403 - display: flex;
404 - align-items: center;
405 - justify-content: space-between;
406 - }
407 -
408 - .section-title-text {
409 - font-size: 26rpx;
410 - font-weight: 600;
411 - color: #847b70;
412 - letter-spacing: 2rpx;
413 - position: relative;
414 - padding-left: 18rpx;
415 -
416 - &::before {
417 - content: '';
418 - position: absolute;
419 - left: 0;
420 - top: 8rpx;
421 - bottom: 8rpx;
422 - width: 6rpx;
423 - border-radius: 999rpx;
424 - background: linear-gradient(180deg, #d8c7a4, #b8a489);
425 - }
426 - }
427 -
428 - // 网格菜单(常用服务)
429 - .menu-grid {
430 - display: grid;
431 - grid-template-columns: repeat(2, minmax(0, 1fr));
432 - gap: 18rpx;
433 - background: rgba(255, 255, 255, 0.98);
434 - border-radius: 24rpx;
435 - border: 2rpx solid rgba(166, 121, 57, 0.05);
436 - box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
437 - padding: 22rpx;
438 - box-sizing: border-box;
439 - }
440 -
441 - .menu-item {
442 - display: flex;
443 - align-items: center;
444 - justify-content: space-between;
445 - gap: 16rpx;
446 - padding: 22rpx 20rpx;
447 - min-height: 0;
448 - border-radius: 20rpx;
449 - background: linear-gradient(180deg, rgba(255, 255, 255, 0.96) 0%, rgba(250, 249, 246, 0.96) 100%);
450 - border: 1rpx solid rgba(166, 121, 57, 0.05);
451 - box-sizing: border-box;
452 - }
453 -
454 - .menu-item__main {
455 - min-width: 0;
456 - flex: 1;
457 - display: flex;
458 - align-items: center;
459 - gap: 16rpx;
460 - }
461 -
462 - .menu-item__content {
463 - min-width: 0;
464 - display: flex;
465 - flex-direction: column;
466 - gap: 6rpx;
467 - }
468 -
469 - .menu-item-arrow {
470 - flex-shrink: 0;
471 - font-size: 26rpx;
472 - color: #c0b8ac;
473 - line-height: 1;
474 - }
475 -
476 - .menu-icon-wrap {
477 - width: 72rpx;
478 - height: 72rpx;
479 - border-radius: 20rpx;
480 - display: flex;
481 - align-items: center;
482 - justify-content: center;
483 - box-shadow: inset 0 2rpx 0 rgba(255, 255, 255, 0.45);
484 -
485 - &.menu-icon--orders {
486 - background: linear-gradient(135deg, #faf4ea, #f4ead7);
487 - }
488 - &.menu-icon--favorites {
489 - background: linear-gradient(135deg, #faf1ec, #f3e4da);
490 - }
491 - &.menu-icon--history {
492 - background: linear-gradient(135deg, #f0f5f0, #e3ece3);
493 - }
494 - &.menu-icon--address {
495 - background: linear-gradient(135deg, #eff2f6, #e4e9ef);
496 - }
497 - &.menu-icon--settings {
498 - background: linear-gradient(135deg, #f3f3f3, #e9e9e9);
499 - }
500 - &.menu-icon--about {
501 - background: linear-gradient(135deg, #faf4ea, #f4ead7);
502 - }
503 - &.menu-icon--help {
504 - background: linear-gradient(135deg, #f0f5f0, #e3ece3);
505 - }
506 - }
507 -
508 - .menu-item-title {
509 - font-size: 24rpx;
510 - font-weight: 600;
511 - color: #45403a;
512 - line-height: 1.2;
513 - }
514 -
515 - .menu-item-desc {
516 - font-size: 20rpx;
517 - color: #9a9184;
518 - line-height: 1.3;
519 - }
520 -
521 - // 列表菜单(更多)
522 - .menu-list {
523 - background: rgba(255, 255, 255, 0.98);
524 - border-radius: 24rpx;
525 - border: 2rpx solid rgba(166, 121, 57, 0.05);
526 - box-shadow: 0 14rpx 36rpx rgba(15, 23, 42, 0.04);
527 - overflow: hidden;
528 - }
529 -
530 - .menu-list-item {
531 - display: flex;
532 - align-items: center;
533 - padding: 28rpx 32rpx;
534 - gap: 24rpx;
535 - position: relative;
536 - background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(249, 249, 247, 0.98));
537 -
538 - &::after {
539 - content: '';
540 - position: absolute;
541 - bottom: 0;
542 - left: 100rpx;
543 - right: 32rpx;
544 - height: 1rpx;
545 - background: rgba(0, 0, 0, 0.05);
546 - }
547 -
548 - &--last::after {
549 - display: none;
550 - }
551 - }
552 -
553 - .menu-list-icon {
554 - width: 64rpx;
555 - height: 64rpx;
556 - border-radius: 16rpx;
557 - display: flex;
558 - align-items: center;
559 - justify-content: center;
560 - flex-shrink: 0;
561 - }
562 -
563 - .menu-list-content {
564 - flex: 1;
565 - min-width: 0;
566 - display: flex;
567 - flex-direction: column;
568 - gap: 6rpx;
569 - }
570 -
571 - .menu-list-title {
572 - font-size: 28rpx;
573 - font-weight: 500;
574 - color: #45403a;
575 - line-height: 1.3;
576 - }
577 -
578 - .menu-list-desc {
579 - font-size: 22rpx;
580 - color: #9a9184;
581 - line-height: 1.2;
582 - }
583 -
584 - .page-footer-tip {
585 - display: flex;
586 - justify-content: center;
587 - padding: 28rpx 0 8rpx;
588 -
589 - &__text {
590 - font-size: 22rpx;
591 - letter-spacing: 4rpx;
592 - color: rgba(147, 141, 131, 0.9);
593 - }
594 - }
595 -
596 - .nickname-dialog-mask {
597 - position: fixed;
598 - inset: 0;
599 - z-index: 20;
600 - display: flex;
601 - align-items: center;
602 - justify-content: center;
603 - padding: 32rpx;
604 - background: rgba(33, 24, 12, 0.32);
605 - box-sizing: border-box;
606 - }
607 -
608 - .nickname-dialog {
609 - width: 100%;
610 - padding: 34rpx 30rpx 30rpx;
611 - border-radius: 28rpx;
612 - background: #fffefd;
613 - box-shadow: 0 24rpx 64rpx rgba(15, 23, 42, 0.12);
614 - box-sizing: border-box;
615 -
616 - &__title {
617 - display: block;
618 - font-size: 32rpx;
619 - font-weight: 600;
620 - color: #3b352d;
621 - line-height: 1.3;
622 - }
623 -
624 - &__desc {
625 - display: block;
626 - margin-top: 12rpx;
627 - font-size: 24rpx;
628 - line-height: 1.6;
629 - color: #948b7d;
630 - }
631 -
632 - &__input {
633 - margin-top: 24rpx;
634 - height: 88rpx;
635 - padding: 0 24rpx;
636 - border-radius: 18rpx;
637 - background: #f7f5f1;
638 - border: 1rpx solid rgba(166, 121, 57, 0.08);
639 - font-size: 28rpx;
640 - color: #3b352d;
641 - box-sizing: border-box;
642 - }
643 54
644 - &__actions { 55 +.empty-card {
645 - display: grid; 56 + background: rgba(255, 255, 255, 0.94);
646 - grid-template-columns: repeat(2, minmax(0, 1fr)); 57 + border: 2rpx solid rgba(166, 121, 57, 0.08);
647 - gap: 18rpx; 58 + border-radius: 28rpx;
648 - margin-top: 28rpx; 59 + padding: 32rpx;
649 - } 60 + box-sizing: border-box;
61 + box-shadow: 0 20rpx 60rpx rgba(15, 23, 42, 0.06);
62 +}
650 63
651 - &__btn { 64 +.empty-title {
652 - display: flex; 65 + display: block;
653 - align-items: center; 66 + font-size: 36rpx;
654 - justify-content: center; 67 + font-weight: 700;
655 - height: 84rpx; 68 + color: #111827;
656 - border-radius: 18rpx; 69 +}
657 - font-size: 28rpx;
658 - font-weight: 600;
659 - color: #fffefd;
660 - background: linear-gradient(135deg, #ac9a7d, #948269);
661 70
662 - &--ghost { 71 +.empty-desc {
663 - color: #847b70; 72 + display: block;
664 - background: #f4f2ee; 73 + margin-top: 16rpx;
665 - } 74 + font-size: 26rpx;
666 - } 75 + line-height: 1.7;
667 - } 76 + color: #6b7280;
668 } 77 }
669 </style> 78 </style>
......
1 +import { defineStore } from 'pinia'
2 +import { getTabbarConfigAPI } from '@/api/tabbar'
3 +import {
4 + getDefaultTabbarItem,
5 + getDefaultTabbarItems,
6 + normalizeTabbarItems,
7 +} from '@/utils/tabbar'
8 +
9 +let tabbarRequestPromise = null
10 +
11 +export const useTabbarStore = defineStore('tabbar', {
12 + state: () => ({
13 + tabItems: getDefaultTabbarItems(),
14 + loaded: false,
15 + loading: false,
16 + }),
17 + getters: {
18 + visibleTabItems: (state) => state.tabItems.filter((item) => item.visible !== false),
19 + getTabItem: (state) => (key) => {
20 + const normalizedKey = String(key || '').trim()
21 + return state.tabItems.find((item) => item.key === normalizedKey) || getDefaultTabbarItem(normalizedKey)
22 + },
23 + },
24 + actions: {
25 + async fetchTabbarConfig(force = false) {
26 + if (!force && this.loaded) {
27 + return this.tabItems
28 + }
29 +
30 + if (this.loading && tabbarRequestPromise) {
31 + return tabbarRequestPromise
32 + }
33 +
34 + this.loading = true
35 +
36 + tabbarRequestPromise = (async () => {
37 + try {
38 + const response = await getTabbarConfigAPI()
39 + const rawTabItems = response?.data?.tab_items || response?.data?.tabs || response?.data?.list || []
40 +
41 + if (response?.code === 1) {
42 + this.tabItems = normalizeTabbarItems(rawTabItems)
43 + } else {
44 + this.tabItems = getDefaultTabbarItems()
45 + }
46 + } catch (error) {
47 + console.error('获取底部导航配置失败:', error)
48 + this.tabItems = getDefaultTabbarItems()
49 + } finally {
50 + this.loaded = true
51 + this.loading = false
52 + tabbarRequestPromise = null
53 + }
54 +
55 + return this.tabItems
56 + })()
57 +
58 + return tabbarRequestPromise
59 + },
60 + async ensureLoaded() {
61 + if (this.loaded) {
62 + return this.tabItems
63 + }
64 +
65 + return this.fetchTabbarConfig()
66 + },
67 + },
68 +})
1 +const defaultTabbarItemMap = {
2 + home: {
3 + key: 'home',
4 + title: '首页',
5 + visible: true,
6 + page_url: '/pages/index/index',
7 + webview_url: '',
8 + webview_title: '首页',
9 + },
10 + message: {
11 + key: 'message',
12 + title: '资讯',
13 + visible: true,
14 + page_url: '/pages/message/index',
15 + webview_url: '',
16 + webview_title: '资讯',
17 + },
18 + application: {
19 + key: 'application',
20 + title: '应用',
21 + visible: true,
22 + page_url: '/pages/application/index',
23 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
24 + webview_title: '应用',
25 + },
26 + mine: {
27 + key: 'mine',
28 + title: '我的',
29 + visible: true,
30 + page_url: '/pages/mine/index',
31 + webview_url: 'https://oa-dev.onwall.cn/f/futian_home/?f=f&p=futian_list',
32 + webview_title: '我的',
33 + },
34 +}
35 +
36 +export const TABBAR_ORDER = ['home', 'message', 'application', 'mine']
37 +
38 +const normalizeVisibleValue = (rawValue, fallbackValue = true) => {
39 + if (typeof rawValue === 'boolean') {
40 + return rawValue
41 + }
42 +
43 + if (rawValue === 1 || rawValue === '1') {
44 + return true
45 + }
46 +
47 + if (rawValue === 0 || rawValue === '0') {
48 + return false
49 + }
50 +
51 + return fallbackValue
52 +}
53 +
54 +export const getDefaultTabbarItem = (key) => {
55 + const normalizedKey = String(key || '').trim()
56 + const fallbackItem = defaultTabbarItemMap[normalizedKey]
57 +
58 + if (!fallbackItem) {
59 + return null
60 + }
61 +
62 + return {
63 + ...fallbackItem,
64 + }
65 +}
66 +
67 +export const getDefaultTabbarItems = () => (
68 + TABBAR_ORDER
69 + .map((key) => getDefaultTabbarItem(key))
70 + .filter(Boolean)
71 +)
72 +
73 +export const normalizeTabbarItem = (rawItem = {}) => {
74 + const normalizedKey = String(rawItem.key || rawItem.name || '').trim()
75 + const fallbackItem = getDefaultTabbarItem(normalizedKey)
76 +
77 + if (!fallbackItem) {
78 + return null
79 + }
80 +
81 + return {
82 + ...fallbackItem,
83 + ...rawItem,
84 + key: fallbackItem.key,
85 + title: String(rawItem.title || fallbackItem.title),
86 + page_url: String(rawItem.page_url || fallbackItem.page_url || ''),
87 + webview_url: String(rawItem.webview_url || rawItem.link_url || rawItem.url || fallbackItem.webview_url || ''),
88 + webview_title: String(rawItem.webview_title || rawItem.link_title || rawItem.title || fallbackItem.webview_title || ''),
89 + visible: normalizeVisibleValue(
90 + rawItem.visible ?? rawItem.is_show ?? rawItem.show,
91 + fallbackItem.visible,
92 + ),
93 + }
94 +}
95 +
96 +export const normalizeTabbarItems = (rawItems = []) => {
97 + if (!Array.isArray(rawItems) || !rawItems.length) {
98 + return getDefaultTabbarItems()
99 + }
100 +
101 + const normalizedItems = rawItems
102 + .map((item) => normalizeTabbarItem(item))
103 + .filter(Boolean)
104 +
105 + return normalizedItems.length ? normalizedItems : getDefaultTabbarItems()
106 +}