hookehuyr

feat(消息页面): 重构消息页面UI并添加NutUI组件支持

- 使用NutUI组件重构消息页面布局和样式
- 添加Tab分类功能支持全部、未读、通知和留言
- 实现消息列表的滚动加载功能
- 更新全局组件声明添加NutUI的Col、Row、Tabs等组件
- 修改页面标题从"首页"改为"消息"
......@@ -9,6 +9,7 @@ declare module 'vue' {
export interface GlobalComponents {
NavBar: typeof import('./src/components/navBar.vue')['default']
NutButton: typeof import('@nutui/nutui-taro')['Button']
NutCol: typeof import('@nutui/nutui-taro')['Col']
NutConfigProvider: typeof import('@nutui/nutui-taro')['ConfigProvider']
NutForm: typeof import('@nutui/nutui-taro')['Form']
NutFormItem: typeof import('@nutui/nutui-taro')['FormItem']
......@@ -19,9 +20,12 @@ declare module 'vue' {
NutNavbar: typeof import('@nutui/nutui-taro')['Navbar']
NutPicker: typeof import('@nutui/nutui-taro')['Picker']
NutPopup: typeof import('@nutui/nutui-taro')['Popup']
NutRow: typeof import('@nutui/nutui-taro')['Row']
NutSearchbar: typeof import('@nutui/nutui-taro')['Searchbar']
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']
Picker: typeof import('./src/components/time-picker-data/picker.vue')['default']
PosterBuilder: typeof import('./src/components/PosterBuilder/index.vue')['default']
......
/*
* @Date: 2025-07-01 17:55:00
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-07-02 11:14:06
* @FilePath: /jgdl/src/pages/messages/index.config.js
* @Description: 文件描述
*/
export default {
navigationBarTitleText: '首页'
navigationBarTitleText: '消息'
}
......
<template>
<view class="messages-page">
<!-- 顶部搜索栏 -->
<view class="search-container">
<view class="search-box">
<Search size="18" color="#9ca3af" />
<input
v-model="searchValue"
placeholder="搜索聊天记录..."
class="search-input"
/>
</view>
<!-- Header -->
<view class="bg-orange-400 p-4 pt-8">
<view class="text-xl font-bold text-white mb-3">消息</view>
<!-- Search Bar -->
<nut-searchbar v-model="searchValue" placeholder="搜索消息" shape="round" background="transparent"
input-background="#ffffff">
<template #leftin>
<Search2 />
</template>
</nut-searchbar>
</view>
<!-- 消息列表 -->
<view class="messages-list">
<view
v-for="message in filteredMessages"
:key="message.id"
class="message-item"
@click="onMessageClick(message)"
>
<view class="avatar-container">
<image :src="message.avatar" class="avatar" mode="aspectFill" />
<view v-if="message.unreadCount > 0" class="unread-badge">
<text class="unread-count">{{ message.unreadCount > 99 ? '99+' : message.unreadCount }}</text>
<!-- Tab Navigation -->
<nut-tabs v-model="activeTab" @click="onTabClick">
<nut-tab-pane title="全部" pane-key="all">
<scroll-view class="conversation-list" scroll-y @scrolltolower="loadMore" :lower-threshold="50">
<view v-for="conversation in filteredConversations" :key="conversation.id" class="conversation-item"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="4" 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>
</view>
<view class="message-content">
<view class="message-header">
<text class="sender-name">{{ message.senderName }}</text>
<text class="message-time">{{ formatTime(message.timestamp) }}</text>
<!-- Loading indicator -->
<view v-if="loading" class="loading-container">
<text class="loading-text">加载中...</text>
</view>
<view class="message-preview">
<text class="preview-text" :class="{ 'unread': message.unreadCount > 0 }">
{{ message.lastMessage }}
</text>
<view v-if="message.type === 'image'" class="message-type-icon">
<Image size="16" color="#9ca3af" />
</view>
</scroll-view>
</nut-tab-pane>
<nut-tab-pane title="未读" pane-key="unread">
<scroll-view class="conversation-list" scroll-y @scrolltolower="loadMore" :lower-threshold="50">
<view v-for="conversation in filteredConversations" :key="conversation.id" class="conversation-item"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="4" 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>
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="filteredMessages.length === 0" class="empty-state">
<view class="empty-icon">
<Message size="48" color="#d1d5db" />
</view>
<text class="empty-title">暂无消息</text>
<text class="empty-subtitle">开始与买家或卖家聊天吧</text>
</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" scroll-y @scrolltolower="loadMore" :lower-threshold="50">
<view v-for="conversation in filteredConversations" :key="conversation.id" class="conversation-item"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="4" 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="message">
<scroll-view class="conversation-list" scroll-y @scrolltolower="loadMore" :lower-threshold="50">
<view v-for="conversation in filteredConversations" :key="conversation.id" class="conversation-item"
@click="onConversationClick(conversation)">
<nut-row>
<nut-col :span="4" 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-tabs>
<!-- 浮动按钮 -->
<view class="floating-btn" @click="onNewMessage">
<Plus size="24" color="#ffffff" />
</view>
<!-- 自定义TabBar -->
<TabBar />
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Search, Message, Plus, Image } from '@nutui/icons-vue-taro'
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'
// 响应式数据
// 搜索值
const searchValue = ref('')
// 当前激活的Tab
const activeTab = ref('all')
// 加载状态
const loading = ref(false)
// 页码
const page = ref(1)
// 每页数量
const pageSize = 10
// 是否还有更多数据
const hasMore = ref(true)
// 模拟对话数据
const conversations = ref([])
// 初始化数据
const initData = () => {
const mockData = [
{
id: 1,
name: '张三',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
lastMessage: '你好,这个商品还在吗?',
time: '5分钟前',
unread: true,
type: 'chat'
},
{
id: 2,
name: '李四',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
lastMessage: '价格可以商量吗?',
time: '30分钟前',
unread: false,
type: 'chat'
},
{
id: 3,
name: '系统通知',
avatar: '',
icon: markRaw(Notice),
lastMessage: '您的商品已通过审核',
time: '1小时前',
unread: true,
type: 'notification'
},
{
id: 4,
name: '王五',
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
lastMessage: '[图片]',
time: '2小时前',
unread: true,
type: 'chat'
},
{
id: 5,
name: '客服留言',
avatar: '',
icon: markRaw(Message),
lastMessage: '感谢您的反馈,我们会尽快处理',
time: '昨天',
unread: false,
type: 'message'
}
]
conversations.value = mockData
}
// 消息数据
const messages = ref([
{
id: 1,
senderName: '张同学',
avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=100&h=100&fit=crop&crop=face',
lastMessage: '这辆车还在吗?可以看看实物吗?',
timestamp: Date.now() - 300000, // 5分钟前
unreadCount: 2,
type: 'text'
},
{
id: 2,
senderName: '李小明',
avatar: 'https://images.unsplash.com/photo-1599566150163-29194dcaad36?w=100&h=100&fit=crop&crop=face',
lastMessage: '价格还能再便宜点吗?',
timestamp: Date.now() - 1800000, // 30分钟前
unreadCount: 0,
type: 'text'
},
{
id: 3,
senderName: '王美丽',
avatar: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=100&h=100&fit=crop&crop=face',
lastMessage: '[图片]',
timestamp: Date.now() - 3600000, // 1小时前
unreadCount: 1,
type: 'image'
},
{
id: 4,
senderName: '陈大华',
avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=100&h=100&fit=crop&crop=face',
lastMessage: '好的,谢谢!',
timestamp: Date.now() - 7200000, // 2小时前
unreadCount: 0,
type: 'text'
},
{
id: 5,
senderName: '刘小红',
avatar: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=100&h=100&fit=crop&crop=face',
lastMessage: '车子的电池还好用吗?大概能跑多远?',
timestamp: Date.now() - 86400000, // 1天前
unreadCount: 0,
type: 'text'
},
{
id: 6,
senderName: '赵强',
avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=100&h=100&fit=crop&crop=face',
lastMessage: '明天下午有时间看车吗?',
timestamp: Date.now() - 172800000, // 2天前
unreadCount: 0,
type: 'text'
// 过滤后的对话列表
const filteredConversations = computed(() => {
let filtered = conversations.value
// 根据Tab过滤
if (activeTab.value === 'unread') {
filtered = filtered.filter(conv => conv.unread)
} else if (activeTab.value === 'notification') {
filtered = filtered.filter(conv => conv.type === 'notification')
} else if (activeTab.value === 'message') {
filtered = filtered.filter(conv => conv.type === 'message')
}
])
// 过滤后的消息列表
const filteredMessages = computed(() => {
if (!searchValue.value.trim()) {
return messages.value
// 根据搜索关键词过滤
if (searchValue.value) {
filtered = filtered.filter(conv =>
conv.name.includes(searchValue.value) ||
conv.lastMessage.includes(searchValue.value)
)
}
return messages.value.filter(message =>
message.senderName.includes(searchValue.value) ||
message.lastMessage.includes(searchValue.value)
)
return filtered
})
/**
* 格式化时间
* @param {number} timestamp - 时间戳
* @returns {string} 格式化后的时间
*/
const formatTime = (timestamp) => {
const now = Date.now()
const diff = now - timestamp
if (diff < 60000) { // 1分钟内
return '刚刚'
} else if (diff < 3600000) { // 1小时内
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) { // 1天内
return `${Math.floor(diff / 3600000)}小时前`
} else if (diff < 604800000) { // 1周内
return `${Math.floor(diff / 86400000)}天前`
} else {
const date = new Date(timestamp)
return `${date.getMonth() + 1}/${date.getDate()}`
}
// Tab点击事件
const onTabClick = (tab) => {
activeTab.value = tab.paneKey
// 重置分页
page.value = 1
hasMore.value = true
}
/**
* 消息点击事件
* @param {object} message - 消息对象
*/
const onMessageClick = (message) => {
// 清除未读数量
message.unreadCount = 0
// 跳转到聊天详情页面
Taro.navigateTo({
url: `/pages/chat/index?userId=${message.id}&userName=${message.senderName}`
})
// 加载更多数据
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
// 模拟API请求
setTimeout(() => {
const newData = generateMockData(page.value + 1)
if (newData.length > 0) {
conversations.value.push(...newData)
page.value++
} else {
hasMore.value = false
}
loading.value = false
}, 1000)
}
/**
* 新建消息
*/
const onNewMessage = async () => {
try {
await Taro.showToast({
title: '新建消息',
icon: 'none'
// 生成模拟数据
const generateMockData = (pageNum) => {
if (pageNum > 3) return [] // 模拟只有3页数据
const mockData = []
const startId = (pageNum - 1) * pageSize + conversations.value.length + 1
for (let i = 0; i < pageSize; i++) {
mockData.push({
id: startId + i,
name: `用户${startId + i}`,
avatar: 'https://randomuser.me/api/portraits/men/32.jpg',
lastMessage: `这是第${startId + i}条消息`,
time: `${Math.floor(Math.random() * 24)}小时前`,
unread: Math.random() > 0.5,
type: 'chat'
})
} catch (error) {
console.error('新建消息失败:', error)
}
return mockData
}
// 点击对话
const onConversationClick = (conversation) => {
// 跳转到聊天页面
Taro.navigateTo({
url: `/pages/chat/index?id=${conversation.id}&name=${conversation.name}`
})
}
// 页面加载时初始化数据
onMounted(() => {
initData()
})
</script>
<style lang="less">
<style scoped>
.messages-page {
min-height: 100vh;
background-color: #f9fafb;
padding-bottom: 100px;
background-color: #f5f5f5;
padding-bottom: 100rpx;
/* 为TabBar留出空间 */
}
.search-container {
padding: 16px;
background-color: #ffffff;
border-bottom: 1px solid #f3f4f6;
/* 对话列表样式 */
.conversation-list {
height: calc(100vh - 300rpx);
background: #ffffff;
}
.search-box {
.conversation-item {
padding: 24rpx 20rpx;
border-bottom: 1rpx solid #f0f0f0;
transition: background-color 0.2s;
}
.avatar-container {
display: flex;
align-items: center;
background-color: #f9fafb;
border-radius: 24px;
padding: 12px 16px;
gap: 8px;
align-items: flex-start;
width: 88rpx;
margin-right: 24rpx;
}
.search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: #374151;
.content-container {
padding-left: 8px;
}
.conversation-item:active {
background-color: #f8f9fa;
}
.messages-list {
background-color: #ffffff;
.conversation-item:last-child {
border-bottom: none;
}
.message-item {
/* 加载指示器 */
.loading-container {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f3f4f6;
transition: background-color 0.2s;
justify-content: center;
padding: 40rpx;
gap: 16rpx;
}
.message-item:active {
background-color: #f9fafb;
.loading-text {
font-size: 28rpx;
color: #9ca3af;
}
.message-item:last-child {
border-bottom: none;
/* Tailwind CSS 类的补充样式 */
.w-12 {
width: 88rpx;
}
.avatar-container {
position: relative;
margin-right: 12px;
.h-12 {
height: 88rpx;
}
.avatar {
width: 48px;
height: 48px;
.w-2 {
width: 8rpx;
}
.h-2 {
height: 8rpx;
}
.rounded-full {
border-radius: 50%;
}
.object-cover {
object-fit: cover;
}
.unread-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 20px;
height: 20px;
.bg-gray-100 {
background-color: #f3f4f6;
}
.bg-red-500 {
background-color: #ef4444;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #ffffff;
}
.unread-count {
font-size: 12px;
color: #ffffff;
font-weight: 500;
line-height: 1;
.flex {
display: flex;
}
.message-content {
.flex-1 {
flex: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.min-w-0 {
min-width: 0;
}
.message-header {
display: flex;
justify-content: space-between;
.items-center {
align-items: center;
margin-bottom: 4px;
}
.sender-name {
font-size: 16px;
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.ml-1 {
margin-left: 4rpx;
}
.ml-3 {
margin-left: 24rpx;
}
.mt-1 {
margin-top: 4rpx;
}
.font-medium {
font-weight: 500;
color: #111827;
}
.message-time {
font-size: 12px;
color: #9ca3af;
.text-xs {
font-size: 24rpx;
}
.message-preview {
display: flex;
align-items: center;
gap: 4px;
.text-sm {
font-size: 28rpx;
}
.preview-text {
flex: 1;
font-size: 14px;
.text-xl {
font-size: 40rpx;
}
.font-bold {
font-weight: bold;
}
.text-white {
color: #ffffff;
}
.text-gray-500 {
color: #6b7280;
}
.text-gray-600 {
color: #4b5563;
}
.bg-orange-400 {
background-color: #fb923c;
}
.p-4 {
padding: 32rpx;
}
.pt-8 {
padding-top: 64rpx;
}
.mb-1 {
margin-bottom: 4rpx;
}
.mb-3 {
margin-bottom: 24rpx;
}
.mr-2 {
margin-right: 8rpx;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-text.unread {
color: #374151;
font-weight: 500;
.block {
display: block;
}
.message-type-icon {
flex-shrink: 0;
.relative {
position: relative;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
text-align: center;
/* NutUI 组件样式覆盖 */
:deep(.nut-searchbar) {
background: transparent !important;
}
.empty-icon {
margin-bottom: 16px;
:deep(.nut-searchbar .nut-searchbar__search-input) {
background: #ffffff !important;
border-radius: 40rpx !important;
}
.empty-title {
font-size: 18px;
font-weight: 500;
color: #374151;
margin-bottom: 8px;
display: block;
:deep(.nut-tabs) {
background: #ffffff;
}
.empty-subtitle {
font-size: 14px;
color: #9ca3af;
display: block;
:deep(.nut-tabs__titles) {
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
}
.floating-btn {
position: fixed;
bottom: 100px;
right: 20px;
width: 56px;
height: 56px;
background-color: #f97316;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(249, 115, 22, 0.4);
transition: all 0.2s;
:deep(.nut-tab-pane) {
padding: 0;
}
.floating-btn:active {
transform: scale(0.95);
box-shadow: 0 2px 8px rgba(249, 115, 22, 0.4);
/* 响应式设计 */
@media (max-width: 750rpx) {
.conversation-item {
padding: 20rpx 16rpx;
}
.w-12 {
width: 80rpx;
}
.h-12 {
height: 80rpx;
}
.text-sm {
font-size: 26rpx;
}
}
</style>
\ No newline at end of file
</style>
......