增加技师端---用户端 聊天界面
This commit is contained in:
11671
staff_uniapp/package-lock.json
generated
11671
staff_uniapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
102
staff_uniapp/src/api/chat.ts
Normal file
102
staff_uniapp/src/api/chat.ts
Normal 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 })
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isDevMode } from '@/utils/env'
|
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}/`
|
let baseUrl = `${envBaseUrl}/`
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,21 @@
|
|||||||
"navigationBarTitleText": "头像裁剪",
|
"navigationBarTitleText": "头像裁剪",
|
||||||
"navigationBarBackgroundColor": "#000000"
|
"navigationBarBackgroundColor": "#000000"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"path" : "pages/chat/index",
|
||||||
|
"style" :
|
||||||
|
{
|
||||||
|
"navigationBarTitleText" : ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path" : "pages/chat/list",
|
||||||
|
"style" :
|
||||||
|
{
|
||||||
|
"navigationBarTitleText" : ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [
|
"subPackages": [
|
||||||
|
|||||||
461
staff_uniapp/src/pages/chat/index.vue
Normal file
461
staff_uniapp/src/pages/chat/index.vue
Normal 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>
|
||||||
207
staff_uniapp/src/pages/chat/list.vue
Normal file
207
staff_uniapp/src/pages/chat/list.vue
Normal 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>
|
||||||
15019
uniapp/package-lock.json
generated
15019
uniapp/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
86
uniapp/src/api/chat.ts
Normal file
86
uniapp/src/api/chat.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* @param {number} userId 用户ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const apiGetUserInfo = (userId) => {
|
||||||
|
return request.get({
|
||||||
|
url: '/user/info',
|
||||||
|
data: { id: userId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取技师信息
|
||||||
|
* @param {number} techId 技师ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const apiGetTechInfo = (techId) => {
|
||||||
|
return request.get({
|
||||||
|
url: '/chat/info',
|
||||||
|
data: { id: techId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 获取会话ID
|
||||||
|
* @param {Object} params
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const apiGetConversationId = (params) => {
|
||||||
|
return request.get({
|
||||||
|
url: '/chat/conversation_id',
|
||||||
|
data: {
|
||||||
|
user_id: params.user_id,
|
||||||
|
tech_id: params.tech_id
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取聊天历史记录
|
||||||
|
* @param params
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const apiGetChatHistory = (params: {
|
||||||
|
tech_id: number
|
||||||
|
order_id: number
|
||||||
|
page?: number
|
||||||
|
page_size?: number
|
||||||
|
}) => request.get({ url: '/chat/history', data: params })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息
|
||||||
|
* @param params
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const apiSendMessage = (params: {
|
||||||
|
tech_id: number
|
||||||
|
order_id: number
|
||||||
|
content: string
|
||||||
|
}) => request.post({ url: '/chat/send', data: params })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记消息为已读
|
||||||
|
* @param params
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const apiMarkMessagesAsRead = (params: {
|
||||||
|
conversation_id: string
|
||||||
|
}) => request.post({ url: '/chat/markAsRead', data: params })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未读消息数量
|
||||||
|
* @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 })
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { isDevMode } from "@/utils/env";
|
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}/`;
|
let baseUrl = `${envBaseUrl}/`;
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,13 @@
|
|||||||
"navigationBarTitleText": "商家列表",
|
"navigationBarTitleText": "商家列表",
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path" : "pages/chat/index",
|
||||||
|
"style" :
|
||||||
|
{
|
||||||
|
"navigationBarTitleText" : "在线聊天"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"subPackages": [
|
"subPackages": [
|
||||||
|
|||||||
553
uniapp/src/pages/chat/index.vue
Normal file
553
uniapp/src/pages/chat/index.vue
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 加载会话提示 -->
|
||||||
|
<view v-if="loadingConversation" class="loading-conversation">
|
||||||
|
<u-loading-icon></u-loading-icon>
|
||||||
|
<text class="ml-2">加载会话中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 聊天区域 -->
|
||||||
|
<scroll-view
|
||||||
|
v-else
|
||||||
|
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 === 2" 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"
|
||||||
|
:disabled="!conversationId || loadingConversation"
|
||||||
|
@confirm="sendMessage"
|
||||||
|
></u-input>
|
||||||
|
<u-button
|
||||||
|
type="primary"
|
||||||
|
size="mini"
|
||||||
|
:disabled="!inputMessage.trim() || !conversationId || loadingConversation"
|
||||||
|
@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,
|
||||||
|
apiGetUnreadMessageCount,
|
||||||
|
apiGetLastMessage,
|
||||||
|
apiGetConversationId,
|
||||||
|
apiGetUserInfo,
|
||||||
|
apiGetTechInfo
|
||||||
|
} from '@/api/chat'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const messages = ref([])
|
||||||
|
const inputMessage = ref('')
|
||||||
|
const unreadCount = ref(0)
|
||||||
|
const lastMessage = ref(null)
|
||||||
|
const conversationId = ref('')
|
||||||
|
const techId = ref(0)
|
||||||
|
const orderId = ref(0)
|
||||||
|
const loadingHistory = ref(false)
|
||||||
|
const loadingConversation = 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 loadHistory = async () => {
|
||||||
|
if (!conversationId.value) return
|
||||||
|
|
||||||
|
loadingHistory.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiGetChatHistory({
|
||||||
|
conversation_id: conversationId.value,
|
||||||
|
page: 1,
|
||||||
|
page_size: 99999
|
||||||
|
})
|
||||||
|
messages.value = res.list.filter(msg => msg != null)
|
||||||
|
scrollToBottom()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载聊天历史失败', error)
|
||||||
|
} finally {
|
||||||
|
loadingHistory.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const sendMessage = async () => {
|
||||||
|
if (!inputMessage.value.trim() || !conversationId.value) 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: 1, // 用户
|
||||||
|
receiver_id: techId.value,
|
||||||
|
receiver_type: 2, // 技师
|
||||||
|
content: content,
|
||||||
|
message_type: 1, // 文本
|
||||||
|
read_status: 0, // 未读
|
||||||
|
create_time: new Date().toISOString(),
|
||||||
|
order_id: orderId.value,
|
||||||
|
user: { // 添加用户信息
|
||||||
|
avatar: userStore.userInfo.avatar
|
||||||
|
},
|
||||||
|
isTemp: true // 标记为临时消息
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加到消息列表
|
||||||
|
messages.value.push(newMsg)
|
||||||
|
inputMessage.value = ''
|
||||||
|
scrollToBottom()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('发送消息失败', error)
|
||||||
|
uni.showToast({
|
||||||
|
title: '发送消息失败',
|
||||||
|
icon: 'error'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记消息为已读
|
||||||
|
const markAsRead = async () => {
|
||||||
|
if (!conversationId.value) return
|
||||||
|
try {
|
||||||
|
await apiMarkMessagesAsRead({
|
||||||
|
conversation_id: conversationId.value,
|
||||||
|
user_id: userStore.userInfo.id
|
||||||
|
})
|
||||||
|
unreadCount.value = 0
|
||||||
|
} catch (error) {
|
||||||
|
console.error('标记已读失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取未读消息数量
|
||||||
|
const getUnreadCount = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiGetUnreadMessageCount()
|
||||||
|
unreadCount.value = res.count
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取未读消息失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取最后一条消息
|
||||||
|
const getLastMessage = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiGetLastMessage({
|
||||||
|
conversation_id: conversationId.value
|
||||||
|
})
|
||||||
|
lastMessage.value = res.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取最后一条消息失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
const getUserInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiGetUserInfo(techId.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
userInfo.value = res.data
|
||||||
|
chatTitle.value = userInfo.value.nickname
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户信息失败', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取技师信息
|
||||||
|
const getTechInfo = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiGetTechInfo(techId.value)
|
||||||
|
if (res.code === 200) {
|
||||||
|
techInfo.value = res.data
|
||||||
|
}
|
||||||
|
} 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 = 1 // 用户
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// 注意:这里直接使用 msg,而不是 msg.data
|
||||||
|
|
||||||
|
// 如果是自己发送的消息(替换临时消息)
|
||||||
|
if (msg.sender_type === 1) { // 技师发送的消息
|
||||||
|
// 找到对应的临时消息(通过内容匹配)
|
||||||
|
const tempIndex = messages.value.findIndex(m =>
|
||||||
|
m.isTemp && m.content === msg.content
|
||||||
|
);
|
||||||
|
if (tempIndex !== -1) {
|
||||||
|
// 替换临时消息为服务器返回的消息
|
||||||
|
messages.value.splice(tempIndex, 1, msg);
|
||||||
|
} else {
|
||||||
|
// 如果没有找到临时消息,直接添加
|
||||||
|
messages.value.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果是对方(用户)的消息
|
||||||
|
else if (msg.sender_type === 2) {
|
||||||
|
messages.value.push(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 1, // 用户
|
||||||
|
receiver_id: techId.value,
|
||||||
|
receiver_type: 2, // 技师
|
||||||
|
content: content,
|
||||||
|
order_id: orderId.value
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.value.send(JSON.stringify(message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理操作菜单选择
|
||||||
|
const handleAction = (item) => {
|
||||||
|
showActions.value = false
|
||||||
|
switch (item.name) {
|
||||||
|
case '查看用户信息':
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/user/detail?id=${techId.value}`
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case '清除聊天记录':
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定要清除聊天记录吗?',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
messages.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
case '投诉用户':
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages/complaint/create?target_id=' + techId.value
|
||||||
|
})
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载会话ID
|
||||||
|
const loadConversationId = async () => {
|
||||||
|
loadingConversation.value = true
|
||||||
|
try {
|
||||||
|
const res = await apiGetConversationId({
|
||||||
|
tech_id: techId.value
|
||||||
|
})
|
||||||
|
|
||||||
|
conversationId.value = res.conversation_id
|
||||||
|
console.log('获取会话ID成功:', conversationId.value)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取会话ID失败', error)
|
||||||
|
uni.showToast({
|
||||||
|
title: '获取会话失败',
|
||||||
|
icon: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loadingConversation.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 从路由参数获取信息
|
||||||
|
techId.value = parseInt(route.query.tech_id)
|
||||||
|
orderId.value = parseInt(route.query.order_id || 0)
|
||||||
|
|
||||||
|
// 验证参数
|
||||||
|
if (isNaN(techId.value)) {
|
||||||
|
console.error('无效的技师ID:', route.query.tech_id)
|
||||||
|
uni.showToast({
|
||||||
|
title: '参数错误',
|
||||||
|
icon: 'error'
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载会话ID
|
||||||
|
await loadConversationId()
|
||||||
|
if (!conversationId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
await getUserInfo()
|
||||||
|
|
||||||
|
// 获取技师信息
|
||||||
|
await getTechInfo()
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
await loadHistory()
|
||||||
|
await getUnreadCount()
|
||||||
|
await getLastMessage()
|
||||||
|
|
||||||
|
// 标记为已读
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-conversation {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40rpx;
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
@@ -121,12 +121,23 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="flex-none">
|
<view class="flex-none flex space-x-2">
|
||||||
<u-button size="medium" @click="call(orderData.coach_info.mobile)">
|
<!-- 在线聊天按钮 -->
|
||||||
<u-icon name="phone" color="#333" size="24rpx"></u-icon>
|
<u-button
|
||||||
<text class="ml-1">联系技师</text>
|
size="medium"
|
||||||
</u-button>
|
type="primary"
|
||||||
</view>
|
@click="startChat(orderData.coach_id)"
|
||||||
|
>
|
||||||
|
<u-icon name="chat" color="#fff" size="24rpx"></u-icon>
|
||||||
|
<text class="ml-1 text-white">在线聊天</text>
|
||||||
|
</u-button>
|
||||||
|
|
||||||
|
<!-- 联系技师按钮 -->
|
||||||
|
<u-button size="medium" @click="call(orderData.coach_info.mobile)">
|
||||||
|
<u-icon name="phone" color="#333" size="24rpx"></u-icon>
|
||||||
|
<text class="ml-1">联系技师</text>
|
||||||
|
</u-button>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 商品卡片 -->
|
<!-- 商品卡片 -->
|
||||||
@@ -401,7 +412,12 @@ const showAppend = ref<boolean>(false)
|
|||||||
|
|
||||||
const scrollTop = ref<number>(0)
|
const scrollTop = ref<number>(0)
|
||||||
const percent = ref<number>(0)
|
const percent = ref<number>(0)
|
||||||
|
// 跳转到聊天页面
|
||||||
|
const startChat = (techId: number) => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/chat/index?tech_id=${techId}&order_id=${orderId.value}&tech_info=${encodeURIComponent(JSON.stringify(orderData.value.coach_info))}`
|
||||||
|
})
|
||||||
|
}
|
||||||
const currentIcon = computed(() => orderBgMap[orderData.value.order_status])
|
const currentIcon = computed(() => orderBgMap[orderData.value.order_status])
|
||||||
|
|
||||||
const handleCommand = (row: { command: string; order_id: number }) => {
|
const handleCommand = (row: { command: string; order_id: number }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user