refactor(messages): 替换NutUI标签组件为自定义实现并优化动画效果
移除NutUI的Tabs和TabPane组件,改用自定义的状态筛选标签 添加标签切换时的淡入淡出动画效果 优化消息列表项的入场动画
Showing
2 changed files
with
151 additions
and
142 deletions
| ... | @@ -30,8 +30,6 @@ declare module 'vue' { | ... | @@ -30,8 +30,6 @@ declare module 'vue' { |
| 30 | NutSticky: typeof import('@nutui/nutui-taro')['Sticky'] | 30 | NutSticky: typeof import('@nutui/nutui-taro')['Sticky'] |
| 31 | NutSwiper: typeof import('@nutui/nutui-taro')['Swiper'] | 31 | NutSwiper: typeof import('@nutui/nutui-taro')['Swiper'] |
| 32 | NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem'] | 32 | NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem'] |
| 33 | - NutTabPane: typeof import('@nutui/nutui-taro')['TabPane'] | ||
| 34 | - NutTabs: typeof import('@nutui/nutui-taro')['Tabs'] | ||
| 35 | NutTextarea: typeof import('@nutui/nutui-taro')['Textarea'] | 33 | NutTextarea: typeof import('@nutui/nutui-taro')['Textarea'] |
| 36 | NutToast: typeof import('@nutui/nutui-taro')['Toast'] | 34 | NutToast: typeof import('@nutui/nutui-taro')['Toast'] |
| 37 | PayCard: typeof import('./src/components/payCard.vue')['default'] | 35 | PayCard: typeof import('./src/components/payCard.vue')['default'] | ... | ... |
| 1 | <template> | 1 | <template> |
| 2 | <view class="messages-page"> | 2 | <view class="messages-page"> |
| 3 | - <nut-sticky> | ||
| 4 | <view class="flex flex-col"> | 3 | <view class="flex flex-col"> |
| 5 | <view id="page-header" class="bg-orange-400 p-4 pt-4 pb-4"> | 4 | <view id="page-header" class="bg-orange-400 p-4 pt-4 pb-4"> |
| 6 | <nut-row type="flex" justify="center" align="center"> | 5 | <nut-row type="flex" justify="center" align="center"> |
| ... | @@ -9,7 +8,8 @@ | ... | @@ -9,7 +8,8 @@ |
| 9 | </nut-col> | 8 | </nut-col> |
| 10 | <nut-col span="20"> | 9 | <nut-col span="20"> |
| 11 | <!-- Search Bar --> | 10 | <!-- Search Bar --> |
| 12 | - <nut-searchbar v-model="searchValue" placeholder="搜索消息" @blur="onBlurSearch" shape="round" background="transparent" input-background="#ffffff"> | 11 | + <nut-searchbar v-model="searchValue" placeholder="搜索消息" @blur="onBlurSearch" shape="round" |
| 12 | + background="transparent" input-background="#ffffff"> | ||
| 13 | <template #leftin> | 13 | <template #leftin> |
| 14 | <Search2 /> | 14 | <Search2 /> |
| 15 | </template> | 15 | </template> |
| ... | @@ -18,127 +18,30 @@ | ... | @@ -18,127 +18,30 @@ |
| 18 | </nut-row> | 18 | </nut-row> |
| 19 | </view> | 19 | </view> |
| 20 | </view> | 20 | </view> |
| 21 | - </nut-sticky> | ||
| 22 | - | ||
| 23 | - <!-- Tab Navigation --> | ||
| 24 | - <nut-tabs id="page-filter" v-model="activeTab" @click="onTabClick"> | ||
| 25 | - <nut-tab-pane title="全部" pane-key="all"> | ||
| 26 | - <scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore" | ||
| 27 | - @scroll="scroll" :lower-threshold="50" :enable-flex="false"> | ||
| 28 | - <view v-for="conversation in filteredConversations" :key="conversation.id" | ||
| 29 | - class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2" | ||
| 30 | - @click="onConversationClick(conversation)"> | ||
| 31 | - <nut-row> | ||
| 32 | - <nut-col :span="6" class="avatar-container"> | ||
| 33 | - <view class="relative"> | ||
| 34 | - <image v-if="conversation.avatar" :src="conversation.avatar" | ||
| 35 | - class="w-12 h-12 rounded-full object-cover" mode="aspectFill" /> | ||
| 36 | - <view v-else | ||
| 37 | - class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> | ||
| 38 | - <component :is="conversation.icon" /> | ||
| 39 | - </view> | ||
| 40 | - </view> | ||
| 41 | - </nut-col> | ||
| 42 | - <nut-col :span="20" class="content-container"> | ||
| 43 | - <view class="flex justify-between items-center mb-1"> | ||
| 44 | - <text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text> | ||
| 45 | - <view class="flex items-center flex-shrink-0"> | ||
| 46 | - <text class="text-xs text-gray-500">{{ conversation.time }}</text> | ||
| 47 | - <view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full"> | ||
| 48 | - </view> | ||
| 49 | - </view> | ||
| 50 | - </view> | ||
| 51 | - <text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text> | ||
| 52 | - </nut-col> | ||
| 53 | - </nut-row> | ||
| 54 | - </view> | ||
| 55 | - | ||
| 56 | - <!-- Loading indicator --> | ||
| 57 | - <view v-if="loading" class="loading-container"> | ||
| 58 | - <text class="loading-text">加载中...</text> | ||
| 59 | - </view> | ||
| 60 | - </scroll-view> | ||
| 61 | - </nut-tab-pane> | ||
| 62 | - | ||
| 63 | - <nut-tab-pane title="未读" pane-key="unread"> | ||
| 64 | - <scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore" | ||
| 65 | - :lower-threshold="50" :enable-flex="false"> | ||
| 66 | - <view v-for="conversation in filteredConversations" :key="conversation.id" | ||
| 67 | - class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2" | ||
| 68 | - @click="onConversationClick(conversation)"> | ||
| 69 | - <nut-row> | ||
| 70 | - <nut-col :span="6" class="avatar-container"> | ||
| 71 | - <view class="relative"> | ||
| 72 | - <image v-if="conversation.avatar" :src="conversation.avatar" | ||
| 73 | - class="w-12 h-12 rounded-full object-cover" mode="aspectFill" /> | ||
| 74 | - <view v-else | ||
| 75 | - class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> | ||
| 76 | - <component :is="conversation.icon" /> | ||
| 77 | - </view> | ||
| 78 | - </view> | ||
| 79 | - </nut-col> | ||
| 80 | - <nut-col :span="19" class="content-container"> | ||
| 81 | - <view class="flex justify-between items-center mb-1"> | ||
| 82 | - <text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text> | ||
| 83 | - <view class="flex items-center flex-shrink-0"> | ||
| 84 | - <text class="text-xs text-gray-500">{{ conversation.time }}</text> | ||
| 85 | - <view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full"> | ||
| 86 | - </view> | ||
| 87 | - </view> | ||
| 88 | - </view> | ||
| 89 | - <text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text> | ||
| 90 | - </nut-col> | ||
| 91 | - </nut-row> | ||
| 92 | - </view> | ||
| 93 | 21 | ||
| 94 | - <!-- Loading indicator --> | ||
| 95 | - <view v-if="loading" class="loading-container"> | ||
| 96 | - <text class="loading-text">加载中...</text> | ||
| 97 | - </view> | ||
| 98 | - </scroll-view> | ||
| 99 | - </nut-tab-pane> | ||
| 100 | 22 | ||
| 101 | - <nut-tab-pane title="通知" pane-key="notification"> | 23 | + <nut-sticky> |
| 102 | - <scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore" | 24 | + <!-- 状态筛选标签 --> |
| 103 | - :lower-threshold="50" :enable-flex="false"> | 25 | + <view id="status-tabs" class="status-tabs"> |
| 104 | - <view v-for="conversation in filteredConversations" :key="conversation.id" | 26 | + <view class="tab-item" :class="{ active: activeTab === 'all' }" @click="setActiveTab('all')"> |
| 105 | - class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2" | 27 | + 全部 |
| 106 | - @click="onConversationClick(conversation)"> | ||
| 107 | - <nut-row> | ||
| 108 | - <nut-col :span="6" class="avatar-container"> | ||
| 109 | - <view class="relative"> | ||
| 110 | - <image v-if="conversation.avatar" :src="conversation.avatar" | ||
| 111 | - class="w-12 h-12 rounded-full object-cover" mode="aspectFill" /> | ||
| 112 | - <view v-else | ||
| 113 | - class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> | ||
| 114 | - <component :is="conversation.icon" /> | ||
| 115 | - </view> | ||
| 116 | - </view> | ||
| 117 | - </nut-col> | ||
| 118 | - <nut-col :span="19" class="content-container"> | ||
| 119 | - <view class="flex justify-between items-center mb-1"> | ||
| 120 | - <text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text> | ||
| 121 | - <view class="flex items-center flex-shrink-0"> | ||
| 122 | - <text class="text-xs text-gray-500">{{ conversation.time }}</text> | ||
| 123 | - <view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full"> | ||
| 124 | </view> | 28 | </view> |
| 29 | + <view class="tab-item" :class="{ active: activeTab === 'unread' }" @click="setActiveTab('unread')"> | ||
| 30 | + 未读 | ||
| 125 | </view> | 31 | </view> |
| 32 | + <view class="tab-item" :class="{ active: activeTab === 'notification' }" | ||
| 33 | + @click="setActiveTab('notification')"> | ||
| 34 | + 通知 | ||
| 126 | </view> | 35 | </view> |
| 127 | - <text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text> | 36 | + <view class="tab-item" :class="{ active: activeTab === 'message' }" @click="setActiveTab('message')"> |
| 128 | - </nut-col> | 37 | + 留言 |
| 129 | - </nut-row> | ||
| 130 | </view> | 38 | </view> |
| 131 | - | ||
| 132 | - <!-- Loading indicator --> | ||
| 133 | - <view v-if="loading" class="loading-container"> | ||
| 134 | - <text class="loading-text">加载中...</text> | ||
| 135 | </view> | 39 | </view> |
| 136 | - </scroll-view> | 40 | + </nut-sticky> |
| 137 | - </nut-tab-pane> | ||
| 138 | 41 | ||
| 139 | - <nut-tab-pane title="留言" pane-key="message"> | 42 | + <!-- 消息列表内容 --> |
| 140 | <scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore" | 43 | <scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore" |
| 141 | - :lower-threshold="50" :enable-flex="false"> | 44 | + @scroll="scroll" :lower-threshold="50" :enable-flex="false"> |
| 142 | <view v-for="conversation in filteredConversations" :key="conversation.id" | 45 | <view v-for="conversation in filteredConversations" :key="conversation.id" |
| 143 | class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2" | 46 | class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2" |
| 144 | @click="onConversationClick(conversation)"> | 47 | @click="onConversationClick(conversation)"> |
| ... | @@ -147,13 +50,12 @@ | ... | @@ -147,13 +50,12 @@ |
| 147 | <view class="relative"> | 50 | <view class="relative"> |
| 148 | <image v-if="conversation.avatar" :src="conversation.avatar" | 51 | <image v-if="conversation.avatar" :src="conversation.avatar" |
| 149 | class="w-12 h-12 rounded-full object-cover" mode="aspectFill" /> | 52 | class="w-12 h-12 rounded-full object-cover" mode="aspectFill" /> |
| 150 | - <view v-else | 53 | + <view v-else class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> |
| 151 | - class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> | ||
| 152 | <component :is="conversation.icon" /> | 54 | <component :is="conversation.icon" /> |
| 153 | </view> | 55 | </view> |
| 154 | </view> | 56 | </view> |
| 155 | </nut-col> | 57 | </nut-col> |
| 156 | - <nut-col :span="19" class="content-container"> | 58 | + <nut-col :span="20" class="content-container"> |
| 157 | <view class="flex justify-between items-center mb-1"> | 59 | <view class="flex justify-between items-center mb-1"> |
| 158 | <text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text> | 60 | <text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text> |
| 159 | <view class="flex items-center flex-shrink-0"> | 61 | <view class="flex items-center flex-shrink-0"> |
| ... | @@ -172,8 +74,6 @@ | ... | @@ -172,8 +74,6 @@ |
| 172 | <text class="loading-text">加载中...</text> | 74 | <text class="loading-text">加载中...</text> |
| 173 | </view> | 75 | </view> |
| 174 | </scroll-view> | 76 | </scroll-view> |
| 175 | - </nut-tab-pane> | ||
| 176 | - </nut-tabs> | ||
| 177 | 77 | ||
| 178 | <!-- 自定义TabBar --> | 78 | <!-- 自定义TabBar --> |
| 179 | <TabBar /> | 79 | <TabBar /> |
| ... | @@ -217,7 +117,7 @@ | ... | @@ -217,7 +117,7 @@ |
| 217 | 117 | ||
| 218 | <script setup> | 118 | <script setup> |
| 219 | import { ref, computed, onMounted, markRaw } from 'vue' | 119 | import { ref, computed, onMounted, markRaw } from 'vue' |
| 220 | -import Taro from '@tarojs/taro' | 120 | + |
| 221 | import { Search2, Notice, Message } from '@nutui/icons-vue-taro' | 121 | import { Search2, Notice, Message } from '@nutui/icons-vue-taro' |
| 222 | import TabBar from '@/components/TabBar.vue' | 122 | import TabBar from '@/components/TabBar.vue' |
| 223 | import { $ } from '@tarojs/extend' | 123 | import { $ } from '@tarojs/extend' |
| ... | @@ -301,11 +201,30 @@ const filteredConversations = computed(() => { | ... | @@ -301,11 +201,30 @@ const filteredConversations = computed(() => { |
| 301 | }) | 201 | }) |
| 302 | 202 | ||
| 303 | // Tab点击事件 | 203 | // Tab点击事件 |
| 304 | -const onTabClick = (tab) => { | 204 | +const setActiveTab = (tabKey) => { |
| 305 | - activeTab.value = tab.paneKey | 205 | + if (activeTab.value === tabKey) return; |
| 306 | - // 重置分页 | 206 | + |
| 307 | - page.value = 1 | 207 | + // 添加淡出效果 |
| 308 | - hasMore.value = true | 208 | + const conversationList = document.querySelector('.conversation-list'); |
| 209 | + if (conversationList) { | ||
| 210 | + conversationList.style.opacity = '0'; | ||
| 211 | + conversationList.style.transform = 'translateY(10rpx)'; | ||
| 212 | + } | ||
| 213 | + | ||
| 214 | + setTimeout(() => { | ||
| 215 | + activeTab.value = tabKey; | ||
| 216 | + page.value = 1; | ||
| 217 | + hasMore.value = true; | ||
| 218 | + initData(); | ||
| 219 | + | ||
| 220 | + // 添加淡入效果 | ||
| 221 | + setTimeout(() => { | ||
| 222 | + if (conversationList) { | ||
| 223 | + conversationList.style.opacity = '1'; | ||
| 224 | + conversationList.style.transform = 'translateY(0)'; | ||
| 225 | + } | ||
| 226 | + }, 50); | ||
| 227 | + }, 150); | ||
| 309 | } | 228 | } |
| 310 | 229 | ||
| 311 | // 加载更多数据 | 230 | // 加载更多数据 |
| ... | @@ -387,7 +306,7 @@ onMounted(() => { | ... | @@ -387,7 +306,7 @@ onMounted(() => { |
| 387 | const windowHeight = wx.getWindowInfo().windowHeight; | 306 | const windowHeight = wx.getWindowInfo().windowHeight; |
| 388 | setTimeout(async () => { | 307 | setTimeout(async () => { |
| 389 | const headerHeight = await $('#page-header').height(); | 308 | const headerHeight = await $('#page-header').height(); |
| 390 | - const navHeight = await $('.nut-tabs__list').height(); | 309 | + const navHeight = await $('#status-tabs').height(); |
| 391 | scrollStyle.value = { | 310 | scrollStyle.value = { |
| 392 | height: windowHeight - headerHeight - navHeight - 70 + 'px' | 311 | height: windowHeight - headerHeight - navHeight - 70 + 'px' |
| 393 | } | 312 | } |
| ... | @@ -665,27 +584,119 @@ onMounted(() => { | ... | @@ -665,27 +584,119 @@ onMounted(() => { |
| 665 | 584 | ||
| 666 | } | 585 | } |
| 667 | 586 | ||
| 668 | -/* NutUI 组件样式覆盖 */ | 587 | +/* 状态筛选标签 */ |
| 669 | -:deep(.nut-searchbar) { | 588 | +.status-tabs { |
| 670 | - background: transparent !important; | 589 | + background: white; |
| 590 | + padding: 20rpx 35rpx; /* 增加内边距 */ | ||
| 591 | + border-bottom: 1rpx solid #e5e7eb; | ||
| 592 | + display: flex; | ||
| 593 | + position: relative; | ||
| 594 | + overflow-x: auto; | ||
| 595 | + scrollbar-width: none; | ||
| 596 | + -ms-overflow-style: none; | ||
| 597 | + | ||
| 598 | + &::-webkit-scrollbar { | ||
| 599 | + display: none; | ||
| 600 | + } | ||
| 671 | } | 601 | } |
| 672 | 602 | ||
| 673 | -// :deep(.nut-searchbar .nut-searchbar__search-input) { | 603 | +.tab-item { |
| 674 | -// background: #ffffff !important; | 604 | + margin-right: 48rpx; |
| 675 | -// border-radius: 40rpx !important; | 605 | + padding-bottom: 16rpx; |
| 676 | -// } | 606 | + font-size: 30rpx; /* 增大字体 */ |
| 607 | + color: #6b7280; | ||
| 608 | + position: relative; | ||
| 609 | + cursor: pointer; | ||
| 610 | + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 611 | + white-space: nowrap; | ||
| 612 | + flex-shrink: 0; | ||
| 613 | + transform: translateX(0); | ||
| 677 | 614 | ||
| 678 | -.nut-tabs { | 615 | + &.active { |
| 679 | - background: #ffffff; | 616 | + color: #f97316; |
| 617 | + font-weight: 500; | ||
| 618 | + transform: translateY(-2rpx); | ||
| 619 | + | ||
| 620 | + &::after { | ||
| 621 | + content: ''; | ||
| 622 | + position: absolute; | ||
| 623 | + bottom: 0; | ||
| 624 | + left: 0; | ||
| 625 | + right: 0; | ||
| 626 | + height: 4rpx; | ||
| 627 | + background: linear-gradient(90deg, #f97316, #fb923c); | ||
| 628 | + border-radius: 2rpx; | ||
| 629 | + animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 630 | + } | ||
| 631 | + } | ||
| 632 | + | ||
| 633 | + &:hover { | ||
| 634 | + color: #f97316; | ||
| 635 | + transform: translateY(-1rpx); | ||
| 636 | + } | ||
| 637 | + | ||
| 638 | + &:last-child { | ||
| 639 | + margin-right: 0; | ||
| 640 | + } | ||
| 680 | } | 641 | } |
| 681 | 642 | ||
| 682 | -.nut-tabs__titles { | 643 | + |
| 683 | - background: #ffffff; | 644 | + |
| 684 | - border-bottom: 1rpx solid #f0f0f0; | 645 | +/* 滑入动画 */ |
| 646 | +@keyframes slideIn { | ||
| 647 | + 0% { | ||
| 648 | + transform: scaleX(0); | ||
| 649 | + opacity: 0; | ||
| 650 | + } | ||
| 651 | + 100% { | ||
| 652 | + transform: scaleX(1); | ||
| 653 | + opacity: 1; | ||
| 654 | + } | ||
| 655 | +} | ||
| 656 | + | ||
| 657 | +/* 内容切换动画 */ | ||
| 658 | +.conversation-list { | ||
| 659 | + animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 660 | + transition: opacity 0.15s ease-out, transform 0.15s ease-out; | ||
| 661 | +} | ||
| 662 | + | ||
| 663 | +@keyframes fadeInUp { | ||
| 664 | + from { | ||
| 665 | + opacity: 0; | ||
| 666 | + transform: translateY(20rpx); | ||
| 667 | + } | ||
| 668 | + to { | ||
| 669 | + opacity: 1; | ||
| 670 | + transform: translateY(0); | ||
| 671 | + } | ||
| 672 | +} | ||
| 673 | + | ||
| 674 | +/* 消息项动画 */ | ||
| 675 | +.conversation-item { | ||
| 676 | + animation: fadeInItem 0.3s cubic-bezier(0.4, 0, 0.2, 1); | ||
| 677 | + animation-fill-mode: both; | ||
| 685 | } | 678 | } |
| 686 | 679 | ||
| 687 | -.nut-tab-pane { | 680 | +.conversation-item:nth-child(1) { animation-delay: 0.1s; } |
| 688 | - padding: 0 !important; | 681 | +.conversation-item:nth-child(2) { animation-delay: 0.15s; } |
| 682 | +.conversation-item:nth-child(3) { animation-delay: 0.2s; } | ||
| 683 | +.conversation-item:nth-child(4) { animation-delay: 0.25s; } | ||
| 684 | +.conversation-item:nth-child(5) { animation-delay: 0.3s; } | ||
| 685 | + | ||
| 686 | +@keyframes fadeInItem { | ||
| 687 | + from { | ||
| 688 | + opacity: 0; | ||
| 689 | + transform: translateX(-20rpx); | ||
| 690 | + } | ||
| 691 | + to { | ||
| 692 | + opacity: 1; | ||
| 693 | + transform: translateX(0); | ||
| 694 | + } | ||
| 695 | +} | ||
| 696 | + | ||
| 697 | +/* NutUI 组件样式覆盖 */ | ||
| 698 | +:deep(.nut-searchbar) { | ||
| 699 | + background: transparent !important; | ||
| 689 | } | 700 | } |
| 690 | 701 | ||
| 691 | /* 响应式设计 */ | 702 | /* 响应式设计 */ | ... | ... |
-
Please register or login to post a comment