hookehuyr

refactor(messages): 替换NutUI标签组件为自定义实现并优化动画效果

移除NutUI的Tabs和TabPane组件,改用自定义的状态筛选标签
添加标签切换时的淡入淡出动画效果
优化消息列表项的入场动画
......@@ -30,8 +30,6 @@ declare module 'vue' {
NutSticky: typeof import('@nutui/nutui-taro')['Sticky']
NutSwiper: typeof import('@nutui/nutui-taro')['Swiper']
NutSwiperItem: typeof import('@nutui/nutui-taro')['SwiperItem']
NutTabPane: typeof import('@nutui/nutui-taro')['TabPane']
NutTabs: typeof import('@nutui/nutui-taro')['Tabs']
NutTextarea: typeof import('@nutui/nutui-taro')['Textarea']
NutToast: typeof import('@nutui/nutui-taro')['Toast']
PayCard: typeof import('./src/components/payCard.vue')['default']
......
<template>
<view class="messages-page">
<view class="flex flex-col">
<view id="page-header" class="bg-orange-400 p-4 pt-4 pb-4">
<nut-row type="flex" justify="center" align="center">
<nut-col span="4">
<view class="text-xl font-bold text-white">消息</view>
</nut-col>
<nut-col span="20">
<!-- Search Bar -->
<nut-searchbar v-model="searchValue" placeholder="搜索消息" @blur="onBlurSearch" shape="round"
background="transparent" input-background="#ffffff">
<template #leftin>
<Search2 />
</template>
</nut-searchbar>
</nut-col>
</nut-row>
</view>
</view>
<nut-sticky>
<view class="flex flex-col">
<view id="page-header" class="bg-orange-400 p-4 pt-4 pb-4">
<nut-row type="flex" justify="center" align="center">
<nut-col span="4">
<view class="text-xl font-bold text-white">消息</view>
</nut-col>
<nut-col span="20">
<!-- Search Bar -->
<nut-searchbar v-model="searchValue" placeholder="搜索消息" @blur="onBlurSearch" shape="round" background="transparent" input-background="#ffffff">
<template #leftin>
<Search2 />
</template>
</nut-searchbar>
</nut-col>
</nut-row>
<!-- 状态筛选标签 -->
<view id="status-tabs" class="status-tabs">
<view class="tab-item" :class="{ active: activeTab === 'all' }" @click="setActiveTab('all')">
全部
</view>
<view class="tab-item" :class="{ active: activeTab === 'unread' }" @click="setActiveTab('unread')">
未读
</view>
<view class="tab-item" :class="{ active: activeTab === 'notification' }"
@click="setActiveTab('notification')">
通知
</view>
<view class="tab-item" :class="{ active: activeTab === 'message' }" @click="setActiveTab('message')">
留言
</view>
</view>
</nut-sticky>
<!-- Tab Navigation -->
<nut-tabs id="page-filter" v-model="activeTab" @click="onTabClick">
<nut-tab-pane title="全部" pane-key="all">
<scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore"
@scroll="scroll" :lower-threshold="50" :enable-flex="false">
<view v-for="conversation in filteredConversations" :key="conversation.id"
class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="6" class="avatar-container">
<view class="relative">
<image v-if="conversation.avatar" :src="conversation.avatar"
class="w-12 h-12 rounded-full object-cover" mode="aspectFill" />
<view v-else
class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<component :is="conversation.icon" />
</view>
</view>
</nut-col>
<nut-col :span="20" class="content-container">
<view class="flex justify-between items-center mb-1">
<text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text>
<view class="flex items-center flex-shrink-0">
<text class="text-xs text-gray-500">{{ conversation.time }}</text>
<view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full">
</view>
</view>
</view>
<text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text>
</nut-col>
</nut-row>
</view>
<!-- Loading indicator -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
</scroll-view>
</nut-tab-pane>
<nut-tab-pane title="未读" pane-key="unread">
<scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore"
:lower-threshold="50" :enable-flex="false">
<view v-for="conversation in filteredConversations" :key="conversation.id"
class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="6" class="avatar-container">
<view class="relative">
<image v-if="conversation.avatar" :src="conversation.avatar"
class="w-12 h-12 rounded-full object-cover" mode="aspectFill" />
<view v-else
class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<component :is="conversation.icon" />
</view>
</view>
</nut-col>
<nut-col :span="19" class="content-container">
<view class="flex justify-between items-center mb-1">
<text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text>
<view class="flex items-center flex-shrink-0">
<text class="text-xs text-gray-500">{{ conversation.time }}</text>
<view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full">
</view>
</view>
</view>
<text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text>
</nut-col>
</nut-row>
</view>
<!-- Loading indicator -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
</scroll-view>
</nut-tab-pane>
<nut-tab-pane title="通知" pane-key="notification">
<scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore"
:lower-threshold="50" :enable-flex="false">
<view v-for="conversation in filteredConversations" :key="conversation.id"
class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="6" class="avatar-container">
<view class="relative">
<image v-if="conversation.avatar" :src="conversation.avatar"
class="w-12 h-12 rounded-full object-cover" mode="aspectFill" />
<view v-else
class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<component :is="conversation.icon" />
</view>
</view>
</nut-col>
<nut-col :span="19" class="content-container">
<view class="flex justify-between items-center mb-1">
<text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text>
<view class="flex items-center flex-shrink-0">
<text class="text-xs text-gray-500">{{ conversation.time }}</text>
<view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full">
</view>
</view>
</view>
<text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text>
</nut-col>
</nut-row>
</view>
<!-- Loading indicator -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
</scroll-view>
</nut-tab-pane>
<nut-tab-pane title="留言" pane-key="message">
<scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore"
:lower-threshold="50" :enable-flex="false">
<view v-for="conversation in filteredConversations" :key="conversation.id"
class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="6" class="avatar-container">
<view class="relative">
<image v-if="conversation.avatar" :src="conversation.avatar"
class="w-12 h-12 rounded-full object-cover" mode="aspectFill" />
<view v-else
class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<component :is="conversation.icon" />
</view>
</view>
</nut-col>
<nut-col :span="19" class="content-container">
<view class="flex justify-between items-center mb-1">
<text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text>
<view class="flex items-center flex-shrink-0">
<text class="text-xs text-gray-500">{{ conversation.time }}</text>
<view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full">
</view>
</view>
<!-- 消息列表内容 -->
<scroll-view class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore"
@scroll="scroll" :lower-threshold="50" :enable-flex="false">
<view v-for="conversation in filteredConversations" :key="conversation.id"
class="conversation-item mt-2 mb-4 border-b border-gray-100 pb-2"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="6" class="avatar-container">
<view class="relative">
<image v-if="conversation.avatar" :src="conversation.avatar"
class="w-12 h-12 rounded-full object-cover" mode="aspectFill" />
<view v-else class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center">
<component :is="conversation.icon" />
</view>
</view>
</nut-col>
<nut-col :span="20" class="content-container">
<view class="flex justify-between items-center mb-1">
<text class="font-medium truncate flex-1 mr-2">{{ conversation.name }}</text>
<view class="flex items-center flex-shrink-0">
<text class="text-xs text-gray-500">{{ conversation.time }}</text>
<view v-if="conversation.unread" class="ml-1 w-2 h-2 bg-red-500 rounded-full">
</view>
<text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text>
</nut-col>
</nut-row>
</view>
</view>
</view>
<text class="text-sm text-gray-600 truncate block">{{ conversation.lastMessage }}</text>
</nut-col>
</nut-row>
</view>
<!-- Loading indicator -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
</scroll-view>
</nut-tab-pane>
</nut-tabs>
<!-- Loading indicator -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
</scroll-view>
<!-- 自定义TabBar -->
<TabBar />
......@@ -217,7 +117,7 @@
<script setup>
import { ref, computed, onMounted, markRaw } from 'vue'
import Taro from '@tarojs/taro'
import { Search2, Notice, Message } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
import { $ } from '@tarojs/extend'
......@@ -301,11 +201,30 @@ const filteredConversations = computed(() => {
})
// Tab点击事件
const onTabClick = (tab) => {
activeTab.value = tab.paneKey
// 重置分页
page.value = 1
hasMore.value = true
const setActiveTab = (tabKey) => {
if (activeTab.value === tabKey) return;
// 添加淡出效果
const conversationList = document.querySelector('.conversation-list');
if (conversationList) {
conversationList.style.opacity = '0';
conversationList.style.transform = 'translateY(10rpx)';
}
setTimeout(() => {
activeTab.value = tabKey;
page.value = 1;
hasMore.value = true;
initData();
// 添加淡入效果
setTimeout(() => {
if (conversationList) {
conversationList.style.opacity = '1';
conversationList.style.transform = 'translateY(0)';
}
}, 50);
}, 150);
}
// 加载更多数据
......@@ -387,7 +306,7 @@ onMounted(() => {
const windowHeight = wx.getWindowInfo().windowHeight;
setTimeout(async () => {
const headerHeight = await $('#page-header').height();
const navHeight = await $('.nut-tabs__list').height();
const navHeight = await $('#status-tabs').height();
scrollStyle.value = {
height: windowHeight - headerHeight - navHeight - 70 + 'px'
}
......@@ -665,27 +584,119 @@ onMounted(() => {
}
/* NutUI 组件样式覆盖 */
:deep(.nut-searchbar) {
background: transparent !important;
/* 状态筛选标签 */
.status-tabs {
background: white;
padding: 20rpx 35rpx; /* 增加内边距 */
border-bottom: 1rpx solid #e5e7eb;
display: flex;
position: relative;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
}
.tab-item {
margin-right: 48rpx;
padding-bottom: 16rpx;
font-size: 30rpx; /* 增大字体 */
color: #6b7280;
position: relative;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
flex-shrink: 0;
transform: translateX(0);
&.active {
color: #f97316;
font-weight: 500;
transform: translateY(-2rpx);
&::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4rpx;
background: linear-gradient(90deg, #f97316, #fb923c);
border-radius: 2rpx;
animation: slideIn 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
}
&:hover {
color: #f97316;
transform: translateY(-1rpx);
}
&:last-child {
margin-right: 0;
}
}
// :deep(.nut-searchbar .nut-searchbar__search-input) {
// background: #ffffff !important;
// border-radius: 40rpx !important;
// }
.nut-tabs {
background: #ffffff;
/* 滑入动画 */
@keyframes slideIn {
0% {
transform: scaleX(0);
opacity: 0;
}
100% {
transform: scaleX(1);
opacity: 1;
}
}
.nut-tabs__titles {
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
/* 内容切换动画 */
.conversation-list {
animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
transition: opacity 0.15s ease-out, transform 0.15s ease-out;
}
.nut-tab-pane {
padding: 0 !important;
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 消息项动画 */
.conversation-item {
animation: fadeInItem 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation-fill-mode: both;
}
.conversation-item:nth-child(1) { animation-delay: 0.1s; }
.conversation-item:nth-child(2) { animation-delay: 0.15s; }
.conversation-item:nth-child(3) { animation-delay: 0.2s; }
.conversation-item:nth-child(4) { animation-delay: 0.25s; }
.conversation-item:nth-child(5) { animation-delay: 0.3s; }
@keyframes fadeInItem {
from {
opacity: 0;
transform: translateX(-20rpx);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* NutUI 组件样式覆盖 */
:deep(.nut-searchbar) {
background: transparent !important;
}
/* 响应式设计 */
......