hookehuyr

feat(消息): 新增消息详情组件并重构消息页面

将消息详情弹框抽离为独立组件 MessageDetail,支持消息发送功能
重构消息页面代码,移除冗余样式,优化消息列表布局
......@@ -10,6 +10,7 @@ declare module 'vue' {
BrandModelPicker: typeof import('./src/components/BrandModelPicker.vue')['default']
FeaturedRecommendations: typeof import('./src/components/FeaturedRecommendations.vue')['default']
LatestScooters: typeof import('./src/components/LatestScooters.vue')['default']
MessageDetail: typeof import('./src/components/MessageDetail.vue')['default']
NavBar: typeof import('./src/components/navBar.vue')['default']
NutActionSheet: typeof import('@nutui/nutui-taro')['ActionSheet']
NutButton: typeof import('@nutui/nutui-taro')['Button']
......
<template>
<nut-popup
v-model:visible="visible"
position="right"
:style="{ width: '100%', height: '100%' }"
closeable
close-icon-position="top-right"
@close="handleClose"
>
<view class="message-detail-container">
<!-- 详情页头部 -->
<view class="detail-header">
<view class="flex items-center">
<image
v-if="conversation?.avatar"
:src="conversation.avatar"
class="w-12 h-12 rounded-full object-cover mr-3"
mode="aspectFill"
/>
<view v-else class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mr-3">
<component :is="conversation?.icon" />
</view>
<view class="flex-1">
<text class="text-lg font-medium">{{ conversation?.name }}</text>
<text class="text-sm text-gray-500 block">{{ conversation?.time }}</text>
</view>
</view>
</view>
<!-- 消息内容区域 -->
<view class="detail-content">
<!-- 系统通知样式 -->
<view v-if="conversation?.type === 'notification'" class="notification-content">
<view class="message-content">
<text class="text-base">{{ conversation?.lastMessage }}</text>
</view>
</view>
<!-- 聊天记录样式 -->
<view v-else class="chat-content">
<scroll-view
class="chat-messages"
:scroll-y="true"
:scroll-top="scrollTop"
:scroll-into-view="scrollIntoView"
>
<view
v-for="(message, index) in messages"
:key="index"
:id="`msg-${index}`"
class="message-item"
:class="{
'message-sent': message.type === 'sent',
'message-received': message.type === 'received'
}"
>
<view class="message-bubble">
<text class="message-text">{{ message.content }}</text>
<text class="message-time">{{ message.time }}</text>
</view>
</view>
</scroll-view>
<!-- 输入框区域 -->
<view class="chat-input-area">
<view class="input-container">
<nut-textarea
v-model="inputMessage"
placeholder="请输入回复内容..."
:rows="2"
:max-length="500"
class="message-input"
@focus="handleInputFocus"
/>
<nut-button
type="primary"
size="small"
color="orange"
@click="sendMessage"
:disabled="!inputMessage.trim()"
class="send-button"
>
发送
</nut-button>
</view>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="detail-footer">
<nut-button
type="primary"
size="large"
block
@click="handleClose"
color="orange"
>
{{ conversation?.type === 'notification' ? '关闭' : '返回' }}
</nut-button>
</view>
</view>
</nut-popup>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import Taro from '@tarojs/taro'
/**
* 消息详情组件 Props
*/
const props = defineProps({
// 弹框显示状态
modelValue: {
type: Boolean,
default: false
},
// 当前对话信息
conversation: {
type: Object,
default: () => ({})
}
})
/**
* 组件事件
*/
const emit = defineEmits(['update:modelValue', 'close', 'sendMessage'])
// 弹框显示状态
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 输入消息内容
const inputMessage = ref('')
// 滚动相关
const scrollTop = ref(0)
const scrollIntoView = ref('')
// 模拟聊天消息数据
const messages = ref([])
/**
* 初始化聊天消息
*/
const initChatMessages = () => {
if (props.conversation?.type === 'message' || props.conversation?.type === 'chat') {
// 模拟历史消息
messages.value = [
{
type: 'received',
content: props.conversation.lastMessage || '您好,有什么可以帮助您的吗?',
time: '10:30'
},
{
type: 'sent',
content: '我想咨询一下车辆的相关问题',
time: '10:32'
},
{
type: 'received',
content: '好的,请问您具体想了解哪方面的信息呢?我会尽力为您解答。',
time: '10:33'
},
{
type: 'sent',
content: '我想咨询一下车辆的相关问题',
time: '10:34'
},
{
type: 'received',
content: '好的,请问您具体想了解哪方面的信息呢?我会尽力为您解答。',
time: '10:35'
},
{
type: 'sent',
content: '我想咨询一下车辆的相关问题',
time: '10:36'
},
{
type: 'received',
content: '好的,请问您具体想了解哪方面的信息呢?我会尽力为您解答。',
time: '10:37'
},
]
}
}
/**
* 发送消息
*/
const sendMessage = async () => {
if (!inputMessage.value.trim()) {
Taro.showToast({
title: '请输入消息内容',
icon: 'none'
})
return
}
const newMessage = {
type: 'sent',
content: inputMessage.value.trim(),
time: new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
messages.value.push(newMessage)
// 清空输入框
const messageContent = inputMessage.value
inputMessage.value = ''
// 滚动到底部
await nextTick()
scrollToBottom()
// 触发发送消息事件
emit('sendMessage', {
conversation: props.conversation,
message: messageContent
})
// 模拟对方回复
setTimeout(() => {
const replyMessage = {
type: 'received',
content: '收到您的消息,我们会尽快处理并回复您。',
time: new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit'
})
}
messages.value.push(replyMessage)
nextTick(() => {
scrollToBottom()
})
}, 1000)
}
/**
* 滚动到底部
*/
const scrollToBottom = () => {
if (messages.value.length > 0) {
// 使用 setTimeout 确保 DOM 更新完成后再滚动
setTimeout(() => {
scrollIntoView.value = `msg-${messages.value.length - 1}`
}, 100)
}
}
/**
* 输入框获得焦点
*/
const handleInputFocus = () => {
setTimeout(() => {
scrollToBottom()
}, 300)
}
/**
* 关闭弹框
*/
const handleClose = () => {
visible.value = false
inputMessage.value = ''
emit('close')
}
/**
* 监听对话变化,初始化消息
*/
watch(
() => props.conversation,
(newConversation) => {
if (newConversation && visible.value) {
initChatMessages()
// 延迟滚动确保消息渲染完成
setTimeout(() => {
nextTick(() => {
scrollToBottom()
})
}, 200)
}
},
{ immediate: true }
)
/**
* 监听弹框显示状态
*/
watch(visible, (newVisible) => {
if (newVisible && props.conversation) {
initChatMessages()
// 确保弹框完全打开后再滚动到底部
setTimeout(() => {
nextTick(() => {
scrollToBottom()
})
}, 300)
}
})
</script>
<style lang="less">
.message-detail-container {
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff;
}
.detail-header {
padding: 32rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
background: #ffffff;
flex-shrink: 0;
}
.detail-content {
flex: 1;
overflow: hidden;
background: #f8f9fa;
}
// 系统通知样式
.notification-content {
padding: 24rpx;
height: 100%;
overflow-y: auto;
.message-content {
background: #ffffff;
padding: 24rpx;
border-radius: 16rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
}
// 聊天记录样式
.chat-content {
height: 100%;
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1;
padding: 24rpx;
overflow-y: auto;
}
.message-item {
margin-bottom: 24rpx;
display: flex;
padding: 0 8rpx; // 添加左右内边距防止遮挡
&.message-sent {
justify-content: flex-end;
padding-right: 60rpx; // 右侧消息增加右边距
.message-bubble {
background: #f97316;
color: #ffffff;
border-radius: 20rpx 20rpx 8rpx 20rpx;
max-width: 65%; // 减少最大宽度,留出更多空间
}
}
&.message-received {
justify-content: flex-start;
padding-left: 16rpx; // 左侧消息增加左边距
.message-bubble {
background: #ffffff;
color: #333333;
border-radius: 20rpx 20rpx 20rpx 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
max-width: 65%; // 减少最大宽度,保持一致
}
}
}
.message-bubble {
padding: 16rpx 20rpx;
position: relative;
word-wrap: break-word;
overflow-wrap: break-word;
}
.message-text {
font-size: 30rpx;
line-height: 1.5;
word-break: break-word;
display: block;
}
.message-time {
font-size: 22rpx;
opacity: 0.7;
margin-top: 8rpx;
display: block;
}
.chat-input-area {
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.input-container {
padding: 24rpx;
display: flex;
align-items: flex-end;
gap: 16rpx;
}
.message-input {
flex: 1;
min-height: 80rpx;
max-height: 160rpx;
}
.send-button {
height: 80rpx;
padding: 0 24rpx;
flex-shrink: 0;
}
.detail-footer {
padding: 24rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
// Tailwind CSS 类的补充样式
// .w-12 {
// width: 88rpx;
// }
// .h-12 {
// height: 88rpx;
// }
// .rounded-full {
// border-radius: 50%;
// }
// .object-cover {
// object-fit: cover;
// }
// .bg-gray-100 {
// background-color: #f3f4f6;
// }
// .flex {
// display: flex;
// }
// .flex-1 {
// flex: 1;
// }
// .items-center {
// align-items: center;
// }
// .mr-3 {
// margin-right: 24rpx;
// }
// .font-medium {
// font-weight: 500;
// }
// .text-lg {
// font-size: 36rpx;
// }
// .text-sm {
// font-size: 28rpx;
// }
// .text-base {
// font-size: 32rpx;
// line-height: 1.5;
// }
// .text-gray-500 {
// color: #6b7280;
// }
// .block {
// display: block;
// }
// NutUI 组件样式覆盖
:deep(.nut-textarea) {
.nut-textarea__textarea {
border: 1rpx solid #e5e7eb;
border-radius: 12rpx;
padding: 16rpx;
font-size: 30rpx;
line-height: 1.5;
}
}
:deep(.nut-button) {
border-radius: 12rpx;
}
</style>
......@@ -40,8 +40,9 @@
</nut-sticky>
<!-- 消息列表内容 -->
<scroll-view ref="scrollViewRef" class="conversation-list" :style="scrollStyle" :scroll-y="true" @scrolltolower="loadMore"
@scroll="scroll" :lower-threshold="100" :enable-flex="false" :scroll-top="scrollTop">
<scroll-view ref="scrollViewRef" class="conversation-list" :style="scrollStyle" :scroll-y="true"
@scrolltolower="loadMore" @scroll="scroll" :lower-threshold="100" :enable-flex="false"
:scroll-top="scrollTop">
<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)">
......@@ -70,7 +71,8 @@
</view>
<!-- 空状态提示 -->
<view v-if="filteredConversations.length === 0 && !loading && !hasMore" class="empty-state py-8 text-center">
<view v-if="filteredConversations.length === 0 && !loading && !hasMore"
class="empty-state py-8 text-center">
<Message size="48" color="#9ca3af" class="mb-4" />
<text class="text-gray-500 text-base block mb-2">暂无消息</text>
<text class="text-gray-400 text-sm">当前筛选条件下没有找到相关消息</text>
......@@ -91,39 +93,8 @@
<TabBar />
<!-- 消息详情弹框 -->
<nut-popup v-model:visible="showMessageDetail" position="right" :style="{ width: '100%', height: '100%' }"
closeable close-icon-position="top-right" @close="closeMessageDetail">
<view class="message-detail-container">
<!-- 详情页头部 -->
<view class="detail-header">
<view class="flex items-center">
<image v-if="selectedConversation?.avatar" :src="selectedConversation.avatar"
class="w-12 h-12 rounded-full object-cover mr-3" mode="aspectFill" />
<view v-else class="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mr-3">
<component :is="selectedConversation?.icon" />
</view>
<view class="flex-1">
<text class="text-lg font-medium">{{ selectedConversation?.name }}</text>
<text class="text-sm text-gray-500 block">{{ selectedConversation?.time }}</text>
</view>
</view>
</view>
<!-- 消息内容 -->
<view class="detail-content">
<view class="message-content">
<text class="text-base">{{ selectedConversation?.lastMessage }}</text>
</view>
</view>
<!-- 底部关闭按钮 -->
<view class="detail-footer">
<nut-button type="primary" size="large" block @click="closeMessageDetail" color="orange">
关闭
</nut-button>
</view>
</view>
</nut-popup>
<MessageDetail v-model="showMessageDetail" :conversation="selectedConversation" @close="closeMessageDetail"
@sendMessage="handleSendMessage" />
</view>
</template>
......@@ -131,10 +102,12 @@
import { ref, computed, onMounted, markRaw } from 'vue'
import { Search2, Notice, Message } from '@nutui/icons-vue-taro'
import TabBar from '@/components/TabBar.vue'
import MessageDetail from '@/components/MessageDetail.vue'
import { $ } from '@tarojs/extend'
import Taro from '@tarojs/taro'
// 默认头像
const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
// const defaultAvatar = 'https://cdn.ipadbiz.cn/mlaj/images/icon_1.jpeg'
const scrollStyle = ref({
height: 'calc(100vh - 500rpx)'
......@@ -329,6 +302,25 @@ const markAsRead = (conversationId) => {
}
}
// 处理发送消息
const handleSendMessage = (data) => {
const { conversation, message } = data
if (!conversation || !message.trim()) return
// 更新对话的最后一条消息
const conv = conversations.value.find(conv => conv.id === conversation.id)
if (conv) {
conv.lastMessage = message
conv.time = '刚刚'
conv.unread = false // 标记为已读
}
Taro.showToast({
title: '消息发送成功',
icon: 'success'
})
}
// 页面加载时初始化数据
onMounted(() => {
// 设置滚动列表可视高度
......@@ -560,69 +552,7 @@ onMounted(() => {
position: relative;
}
/* 消息详情弹框样式 */
.message-detail-container {
height: 100%;
display: flex;
flex-direction: column;
background: #ffffff;
}
.detail-header {
padding: 32rpx 24rpx;
border-bottom: 1rpx solid #f0f0f0;
background: #ffffff;
flex-shrink: 0;
}
.detail-content {
flex: 1;
padding: 24rpx;
overflow-y: auto;
background: #f8f9fa;
}
.message-content {
background: #ffffff;
padding: 24rpx;
border-radius: 16rpx;
margin-bottom: 24rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.message-history {
.message-item {
background: #ffffff;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 16rpx;
box-shadow: 0 1rpx 4rpx rgba(0, 0, 0, 0.05);
}
}
.detail-footer {
padding: 24rpx;
background: #ffffff;
border-top: 1rpx solid #f0f0f0;
flex-shrink: 0;
}
.text-lg {
font-size: 36rpx;
}
.text-base {
font-size: 32rpx;
line-height: 1.5;
}
.mr-3 {
margin-right: 24rpx;
}
.mt-1 {
margin-top: 8rpx;
}
}
......@@ -659,7 +589,8 @@ onMounted(() => {
/* 状态筛选标签 */
.status-tabs {
background: white;
padding: 20rpx 35rpx; /* 增加内边距 */
padding: 20rpx 35rpx;
/* 增加内边距 */
border-bottom: 1rpx solid #e5e7eb;
display: flex;
position: relative;
......@@ -668,14 +599,15 @@ onMounted(() => {
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
display: none;
}
}
.tab-item {
margin-right: 48rpx;
padding-bottom: 16rpx;
font-size: 30rpx; /* 增大字体 */
font-size: 30rpx;
/* 增大字体 */
color: #6b7280;
position: relative;
cursor: pointer;
......@@ -685,30 +617,30 @@ onMounted(() => {
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);
}
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);
color: #f97316;
transform: translateY(-1rpx);
}
&:last-child {
margin-right: 0;
margin-right: 0;
}
}
......@@ -720,6 +652,7 @@ onMounted(() => {
transform: scaleX(0);
opacity: 0;
}
100% {
transform: scaleX(1);
opacity: 1;
......@@ -737,6 +670,7 @@ onMounted(() => {
opacity: 0;
transform: translateY(20rpx);
}
to {
opacity: 1;
transform: translateY(0);
......@@ -749,17 +683,32 @@ onMounted(() => {
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; }
.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);
......