增加技师端---用户端 聊天界面

This commit is contained in:
贾祥聪
2025-08-21 16:24:35 +08:00
parent 56f0b25679
commit 9f85cf458a
13 changed files with 7411 additions and 20746 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -86,4 +86,4 @@
"vite": "4.1.4",
"weapp-tailwindcss-webpack-plugin": "1.12.8"
}
}
}

View File

@@ -0,0 +1,102 @@
import request from '@/utils/request'
/**
* 获取会话信息
* @param {number} conversationId 会话ID数据库自增ID
* @returns {Promise}
*/
export const apiGetConversationInfo = (conversationId) => {
return request.get({
url: '/chat/conversation_info',
data: { conversation_id: conversationId }
})
}
/**
* 获取聊天历史
* @param {Object} params
* @returns {Promise}
*/
export const apiGetChatHistory = (params) => {
return request.get({
url: '/chat/history',
data: {
conversation_id: params.conversation_id,
page: params.page,
page_size: params.page_size
}
})
}
/**
* 发送消息
* @param {Object} data
* @returns {Promise}
*/
export const apiSendMessage = (data) => {
return request.post({
url: '/chat/send',
data: {
conversation_id: data.conversation_id,
sender_id: data.sender_id,
sender_type: data.sender_type,
content: data.content
}
})
}
/**
* 标记消息为已读
* @param {Object} data
* @returns {Promise}
*/
export const apiMarkMessagesAsRead = (data) => {
return request.post({
url: '/chat/mark_as_read',
data: {
conversation_id: data.conversation_id,
user_id: data.user_id
}
})
}
/**
* 获取会话列表(技师端)
* @param {number} techId 技师ID
* @returns {Promise}
*/
export const apiGetConversations = (techId) => {
return request.get({
url: '/chat/conversations',
data: { tech_id: techId }
})
}
/**
* 获取用户信息
* @param {number} userId 用户ID
* @returns {Promise}
*/
export const apiGetUserInfo = (userId) => {
return request.get({
url: '/user/info',
data: { id: userId }
})
}
/**
* 获取未读消息数量
* @returns
*/
export const apiGetUnreadMessageCount = () => request.get({ url: '/chat/unreadCount' })
/**
* 获取最后一条消息
* @param params
* @returns
*/
export const apiGetLastMessage = (params: {
tech_id: number
order_id: number
}) => request.get({ url: '/chat/lastMessage', data: params })

View File

@@ -1,5 +1,5 @@
import { isDevMode } from '@/utils/env'
const envBaseUrl = import.meta.env.VITE_APP_BASE_URL || ''
const envBaseUrl = import.meta.env.VITE_APP_BASE_URL || 'http://anmo.com'
let baseUrl = `${envBaseUrl}/`

View File

@@ -171,6 +171,21 @@
"navigationBarTitleText": "头像裁剪",
"navigationBarBackgroundColor": "#000000"
}
},
{
"path" : "pages/chat/index",
"style" :
{
"navigationBarTitleText" : ""
}
},
{
"path" : "pages/chat/list",
"style" :
{
"navigationBarTitleText" : ""
}
}
],
"subPackages": [

View File

@@ -0,0 +1,461 @@
<template>
<view class="chat-container">
<!-- 导航栏 -->
<u-navbar
:title="chatTitle"
:is-back="true"
:border-bottom="false"
title-color="#000"
back-icon-color="#000"
>
<template #right>
<u-icon
name="more-dot-fill"
size="22"
color="#000"
@click="showActions = true"
></u-icon>
</template>
</u-navbar>
<!-- 操作菜单 -->
<u-action-sheet
:show="showActions"
:actions="actions"
@close="showActions = false"
@select="handleAction"
></u-action-sheet>
<!-- 聊天区域 -->
<scroll-view
scroll-y="true"
class="chat-messages"
:scroll-top="scrollTop"
@scrolltolower="loadHistory"
>
<view v-for="(msg, index) in messages" :key="index" class="message-item">
<!-- 对方消息用户 -->
<view v-if="msg.sender_type === 1" class="message-other">
<u-avatar :src="userInfo.avatar" size="40"></u-avatar>
<view class="message-content">
<view class="message-bubble">{{ msg.content }}</view>
<view class="message-time">{{ formatTime(msg.create_time) }}</view>
</view>
</view>
<!-- 我的消息技师 -->
<view v-else class="message-me">
<view class="message-content">
<view class="message-bubble">{{ msg.content }}</view>
<view class="message-time">{{ formatTime(msg.create_time) }}</view>
</view>
<u-avatar :src="techInfo.avatar" size="40"></u-avatar>
</view>
</view>
<!-- 加载更多提示 -->
<view v-if="loadingHistory" class="loading-more">
<u-loading-icon></u-loading-icon>
<text class="ml-2">加载中...</text>
</view>
</scroll-view>
<!-- 输入区域 -->
<view class="input-area">
<u-input
v-model="inputMessage"
placeholder="输入消息..."
border="none"
class="input-box"
@confirm="sendMessage"
></u-input>
<u-button
type="primary"
size="mini"
:disabled="!inputMessage.trim()"
@click="sendMessage"
>
发送
</u-button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute } from 'uniapp-router-next'
import { useUserStore } from '@/stores/user'
import {
apiGetChatHistory,
apiSendMessage,
apiMarkMessagesAsRead,
apiGetConversationInfo
} from '@/api/chat'
const route = useRoute()
const userStore = useUserStore()
const messages = ref([])
const inputMessage = ref('')
const conversationId = ref(0) // 使用数据库自增ID
const loadingHistory = ref(false)
const scrollTop = ref(0)
const ws = ref(null)
const userInfo = ref({
avatar: '/static/default-avatar.png',
nickname: '用户'
})
const techInfo = ref({
avatar: userStore.userInfo.avatar,
nickname: userStore.userInfo.nickname
})
const chatTitle = ref('在线聊天')
const showActions = ref(false)
const actions = ref([
{ name: '查看用户信息' },
{ name: '清除聊天记录' },
{ name: '投诉用户' }
])
// 格式化时间
const formatTime = (time) => {
if (!time) return ''
const date = new Date(time)
return `${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`
}
// 加载会话信息
const loadConversationInfo = async () => {
try {
const res = await apiGetConversationInfo(conversationId.value)
if (res.code === 200) {
userInfo.value = res.data.user_info
techInfo.value = res.data.tech_info
chatTitle.value = userInfo.value.nickname
}
} catch (error) {
console.error('加载会话信息失败', error)
}
}
// 加载聊天历史
const loadHistory = async () => {
loadingHistory.value = true
try {
const res = await apiGetChatHistory({
conversation_id: conversationId.value,
page: 1,
page_size: 20
})
messages.value = res.list
scrollToBottom()
} catch (error) {
console.error('加载聊天历史失败', error)
} finally {
loadingHistory.value = false
}
}
// 发送消息
const sendMessage = async () => {
if (!inputMessage.value.trim()) return
const content = inputMessage.value.trim()
try {
// 通过WebSocket发送消息
sendMessageViaWebSocket(content)
// 创建本地消息对象(临时)
const newMsg = {
id: Date.now(), // 临时ID
conversation_id: conversationId.value,
sender_id: userStore.userInfo.id,
sender_type: 2, // 技师
receiver_id: userInfo.value.id,
receiver_type: 1, // 用户
content: content,
message_type: 1, // 文本
read_status: 0, // 未读
create_time: new Date().toISOString(),
user: { // 技师信息
avatar: techInfo.value.avatar
},
isTemp: true // 标记为临时消息
}
// 添加到消息列表
messages.value.push(newMsg)
inputMessage.value = ''
scrollToBottom()
} catch (error) {
console.error('发送消息失败', error)
}
}
// 标记消息为已读
const markAsRead = async () => {
if (!conversationId.value) return
try {
await apiMarkMessagesAsRead({
conversation_id: conversationId.value,
user_id: userStore.userInfo.id // 技师ID
})
} catch (error) {
console.error('标记已读失败', error)
}
}
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
// 使用更可靠的滚动方式
scrollTop.value = scrollTop.value + 1
setTimeout(() => {
scrollTop.value = 999999
}, 100)
})
}
// 初始化WebSocket
const initWebSocket = () => {
const token = userStore.token
const userId = userStore.userInfo.id
const userType = 2 // 技师
ws.value = new WebSocket(`ws://anmo.com:9501?token=${token}&type=${userType}`)
ws.value.onopen = () => {
console.log('WebSocket连接成功')
}
ws.value.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.action === 'new') {
handleNewMessage(data.data)
}
}
ws.value.onerror = (error) => {
console.error('WebSocket错误', error)
}
ws.value.onclose = () => {
console.log('WebSocket连接关闭')
// 尝试重新连接
setTimeout(initWebSocket, 3000)
}
}
// 处理新消息
const handleNewMessage = (msg) => {
// 确保消息属于当前会话
if (msg.conversation_id === conversationId.value) {
// 如果是自己发送的消息(替换临时消息)
if (msg.sender_type === 2) {
// 找到对应的临时消息(通过内容匹配)
const tempIndex = messages.value.findIndex(m =>
m.isTemp && m.content === msg.content
);
if (tempIndex !== -1) {
// 替换临时消息为服务器返回的消息
messages.value.splice(tempIndex, 1, {
...msg,
user: {
avatar: techInfo.value.avatar
}
});
} else {
// 如果没有找到临时消息,直接添加
messages.value.push({
...msg,
user: {
avatar: techInfo.value.avatar
}
});
}
}
// 如果是对方(用户)的消息
else {
messages.value.push({
...msg,
user: {
avatar: userInfo.value.avatar
}
});
}
scrollToBottom()
}
}
// 发送消息通过WebSocket
const sendMessageViaWebSocket = (content) => {
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) return
const message = {
action: 'send',
conversation_id: conversationId.value,
sender_id: userStore.userInfo.id,
sender_type: 2, // 技师
receiver_id: userInfo.value.id,
receiver_type: 1, // 用户
content: content
}
ws.value.send(JSON.stringify(message))
}
// 处理操作菜单选择
const handleAction = (item) => {
showActions.value = false
switch (item.name) {
case '查看用户信息':
uni.navigateTo({
url: `/pages/user/detail?id=${userInfo.value.id}`
})
break;
case '清除聊天记录':
uni.showModal({
title: '提示',
content: '确定要清除聊天记录吗?',
success: (res) => {
if (res.confirm) {
messages.value = []
}
}
})
break;
case '投诉用户':
uni.navigateTo({
url: '/pages/complaint/create?target_id=' + userInfo.value.id
})
break;
}
}
onMounted(async () => {
// 从路由参数获取会话ID
conversationId.value = parseInt(route.query.conversation_id)
if (!conversationId.value) {
uni.showToast({
title: '会话ID无效',
icon: 'error'
})
uni.navigateBack()
return
}
// 加载会话信息
await loadConversationInfo()
// 加载聊天历史
await loadHistory()
// 标记为已读
await markAsRead()
// 初始化WebSocket
initWebSocket()
})
onUnmounted(() => {
if (ws.value) {
ws.value.close()
ws.value = null
}
})
</script>
<style lang="scss" scoped>
.chat-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
}
.chat-messages {
flex: 1;
padding: 20rpx;
overflow-y: auto;
background-color: #f5f5f5;
}
.message-item {
margin-bottom: 30rpx;
}
.message-other, .message-me {
display: flex;
align-items: flex-start;
margin-bottom: 20rpx;
}
.message-other {
.message-content {
margin-left: 20rpx;
max-width: 70%;
}
}
.message-me {
justify-content: flex-end;
.message-content {
margin-right: 20rpx;
max-width: 70%;
}
}
.message-bubble {
padding: 15rpx 20rpx;
border-radius: 10rpx;
font-size: 28rpx;
line-height: 1.5;
}
.message-other .message-bubble {
background-color: #ffffff;
border: 1rpx solid #e5e5e5;
}
.message-me .message-bubble {
background-color: #95ec69;
color: #000;
}
.message-time {
font-size: 22rpx;
color: #999;
margin-top: 8rpx;
text-align: right;
}
.input-area {
display: flex;
align-items: center;
padding: 20rpx;
background-color: #fff;
border-top: 1rpx solid #eee;
}
.input-box {
flex: 1;
background-color: #f5f5f5;
border-radius: 50rpx;
padding: 0 30rpx;
margin-right: 20rpx;
}
.loading-more {
display: flex;
justify-content: center;
align-items: center;
padding: 20rpx;
color: #999;
font-size: 26rpx;
}
</style>

View File

@@ -0,0 +1,207 @@
<template>
<view class="chat-list-container">
<!-- 导航栏 -->
<u-navbar
title="聊天列表"
:is-back="true"
:border-bottom="false"
title-color="#000"
back-icon-color="#000"
></u-navbar>
<!-- 搜索框 -->
<view class="search-box">
<u-search
v-model="searchKeyword"
placeholder="搜索用户"
:show-action="false"
shape="square"
bg-color="#f5f5f5"
></u-search>
</view>
<!-- 会话列表 -->
<scroll-view scroll-y="true" class="conversation-list">
<view
v-for="(conversation, index) in filteredConversations"
:key="index"
class="conversation-item"
@click="goToChat(conversation)"
>
<u-avatar
:src="conversation.user_avatar"
size="60"
shape="circle"
></u-avatar>
<view class="conversation-info">
<view class="user-name">{{ conversation.user_name }}</view>
<view class="last-message">{{ conversation.last_message }}</view>
</view>
<view class="conversation-meta">
<view class="message-time">{{ formatTime(conversation.last_message_time) }}</view>
<view v-if="conversation.unread_count > 0" class="unread-count">
{{ conversation.unread_count }}
</view>
</view>
</view>
<!-- 空状态 -->
<view v-if="filteredConversations.length === 0" class="empty-state">
<u-empty
mode="list"
icon="http://cdn.uviewui.com/uview/empty/list.png"
>
</u-empty>
<text class="empty-text">暂无聊天记录</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { apiGetConversations } from '@/api/chat'
const userStore = useUserStore()
const searchKeyword = ref('')
const conversations = ref([])
const loading = ref(false)
// 格式化时间
const formatTime = (time) => {
if (!time) return ''
const date = new Date(time)
return `${date.getMonth() + 1}-${date.getDate()} ${date.getHours()}:${date.getMinutes().toString().padStart(2, '0')}`
}
// 过滤会话列表
const filteredConversations = computed(() => {
if (!searchKeyword.value) return conversations.value
return conversations.value.filter(convo =>
convo.user.nickname.includes(searchKeyword.value) ||
convo.last_message.content.includes(searchKeyword.value)
)
})
// 跳转到聊天页面
const goToChat = (conversation) => {
uni.navigateTo({
url: `/pages/chat/index?conversation_id=${conversation.id}`
})
}
// 加载会话列表
const loadConversations = async () => {
loading.value = true
try {
const res = await apiGetConversations({
tech_id: userStore.userInfo.id
})
console.log(res)
conversations.value = res
} catch (error) {
console.error('加载会话列表失败', error)
} finally {
loading.value = false
}
}
onMounted(() => {
loadConversations()
})
</script>
<style lang="scss" scoped>
.chat-list-container {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #fff;
}
.search-box {
padding: 20rpx;
background-color: #fff;
}
.conversation-list {
flex: 1;
padding: 0 20rpx;
background-color: #f5f5f5;
}
.conversation-item {
display: flex;
align-items: center;
padding: 20rpx;
background-color: #fff;
border-radius: 10rpx;
margin-bottom: 20rpx;
.conversation-info {
flex: 1;
margin-left: 20rpx;
overflow: hidden;
.user-name {
font-size: 32rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-message {
font-size: 28rpx;
color: #999;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.conversation-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
.message-time {
font-size: 24rpx;
color: #999;
margin-bottom: 10rpx;
}
.unread-count {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background-color: #f56c6c;
color: #fff;
font-size: 24rpx;
display: flex;
justify-content: center;
align-items: center;
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 0;
.empty-text {
font-size: 28rpx;
color: #999;
margin-top: 20rpx;
}
}
</style>