使用cursor来开发UniApp + SQL Server 客服聊天功能
1. 功能概述
本功能基于UniApp + SQL Server实现了一个类似微信的即时聊天系统,支持用户和客服进行实时对话。主要特点:
- 实时消息对话
- 历史消息加载
- 新消息提醒
- 已读状态同步
- 多客服分配
在线演示
演示地址: http://demo.xiyueta.com/case/web20250222/
测试账号: demo
测试密码: 123456
使用效果
用户端聊天
- 简洁的聊天界面设计
- 支持文字和图片消息
- 消息状态实时显示
- 未读消息红点提醒
客服端管理
- 多会话同时处理
- 客户信息一目了然
- 快捷回复功能
- 消息记录永久保存
会话列表
- 最新消息优先显示
- 未读数量醒目提示
- 会话时间实时更新
- 支持会话置顶功能
我的
- 根据用户权限显示对应
- 并显示信息条数
2. 数据库设计
2.1 聊天消息表
CREATE TABLE pre20250222_chat (
message_id INT IDENTITY(1,1) PRIMARY KEY, -- 消息ID
sender_type TINYINT NOT NULL, -- 发送者类型(1:用户 2:客服)
sender_id INT NOT NULL, -- 发送者ID
accept_id INT NOT NULL, -- 接收者ID
content NVARCHAR(MAX) NOT NULL, -- 消息内容
is_read TINYINT DEFAULT 0, -- 是否已读(0:未读 1:已读)
create_time DATETIME DEFAULT GETDATE() -- 发送时间
)
-- 创建索引
CREATE INDEX idx_sender ON pre20250222_chat (sender_id, sender_type)
CREATE INDEX idx_accept ON pre20250222_chat (accept_id)
CREATE INDEX idx_create_time ON pre20250222_chat (create_time)
2.2 字段说明
- sender_type: 1表示用户发送,2表示客服发送
- sender_id: 发送者的用户ID
- accept_id: 接收者的用户ID
- is_read: 0表示未读,1表示已读
3. 核心设计思路
3.1 消息加载机制
1. 首次加载:
- 获取最新的10条消息
- 返回最新消息ID(latest_id)和最早消息ID(earliest_id)
- 用于后续的消息加载和更新
2. 历史消息加载:
- 触发条件: 滚动到顶部
- 参数: earliest_id(最早消息ID)
- 获取比earliest_id更早的消息
- 新消息插入到列表顶部
- 维持滚动位置,避免跳动
3. 新消息检查:
- 定时轮询(30秒)
- 参数: latest_id(最新消息ID)
- 获取比latest_id更新的消息
- 新消息追加到列表底部
- 可选自动滚动到底部
4. 发送消息:
- 先调用发送接口
- 成功后获取最新消息
- 参数: after_id(最新消息ID)
- 自动滚动到底部
3.2 已读状态处理
1. 客服查看时:
- 把用户发的消息标记为已读
UPDATE chat SET is_read=1
WHERE sender_type=1 AND sender_id={用户ID} AND accept_id={客服ID}
2. 用户查看时:
- 把客服发的消息标记为已读
UPDATE chat SET is_read=1
WHERE sender_type=2 AND accept_id={用户ID}
4. 前端实现
4.1 技术选型
- Vue3 Composition API: 更好的代码组织和复用
- UniApp: 实现多端统一开发
- Scroll View: 实现滚动加载
4.2 核心代码与问题解决
4.2.1 消息列表组件
<template>
<scroll-view
class="message-list"
scroll-y
:scroll-anchoring="true" <!-- 解决iOS滚动抖动 -->
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="loadMoreMessages"
@scrolltolower="onScrollToLower"
:scroll-into-view="scrollIntoView"
ref="messageListRef"
>
<!-- 加载提示 -->
<view class="load-more">
<text v-if="loading">加载中...</text>
<text v-else-if="!hasMore">没有更多消息了</text>
<text v-else>下拉加载更多</text>
</view>
<!-- 消息列表 -->
<view
v-for="(message, index) in messages"
:key="message.message_id"
:id="'msg-' + message.message_id"
class="message-item"
:class="getMessageClass(message)"
>
<!-- 日期分割线 -->
<view class="date-divider" v-if="showDateDivider(index)">
{{ formatDate(message.create_time) }}
</view>
<!-- 消息内容 -->
<view class="content">
<view class="bubble">{{ message.content }}</view>
<view class="time">{{ formatTime(message.create_time) }}</view>
</view>
</view>
</scroll-view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import dayjs from 'dayjs'
// 状态管理
const messages = ref([])
const loading = ref(false)
const refreshing = ref(false)
const hasMore = ref(true)
const scrollIntoView = ref('')
const latestMessageId = ref(0)
const earliestMessageId = ref(999999)
// 定时器
let refreshTimer = null
// 格式化时间
const formatTime = (time) => {
return dayjs(time).format('HH:mm')
}
// 格式化日期
const formatDate = (time) => {
const date = dayjs(time)
const today = dayjs()
if(date.isSame(today, 'day')) {
return '今天'
} else if(date.isSame(today.subtract(1, 'day'), 'day')) {
return '昨天'
}
return date.format('MM-DD')
}
// 显示日期分割线
const showDateDivider = (index) => {
if(index === 0) return true
const curr = dayjs(messages.value[index].create_time)
const prev = dayjs(messages.value[index - 1].create_time)
return !curr.isSame(prev, 'day')
}
// 获取消息样式
const getMessageClass = computed(() => (message) => {
return {
'self': message.sender_id === userInfo.value.user_id,
'other': message.sender_id !== userInfo.value.user_id,
'unread': !message.is_read
}
})
// 获取消息列表
const getMessages = async () => {
if (loading.value) return
loading.value = true
try {
const params = {
pageSize: 10,
chat_with_id: chatWithId.value
}
// 加载历史消息
if (refreshing.value) {
params.before_id = earliestMessageId.value
}
// 获取新消息
else {
params.after_id = latestMessageId.value
}
const res = await api.chat.list(params)
if(res.status === 'y') {
const { list, latest_id, earliest_id } = res.data
// 更新消息ID范围
if(latest_id) latestMessageId.value = latest_id
if(earliest_id) earliestMessageId.value = earliest_id
// 更新消息列表
if(refreshing.value) {
messages.value = [...list, ...messages.value]
} else {
messages.value = [...messages.value, ...list]
// 新消息滚动到底部
scrollIntoView.value = `msg-${latest_id}`
}
hasMore.value = list.length === 10
}
} finally {
loading.value = false
refreshing.value = false
}
}
// 定时刷新
const startRefreshTimer = () => {
refreshTimer = setInterval(async () => {
await getMessages()
}, 30000)
}
onMounted(() => {
getMessages()
startRefreshTimer()
})
onUnmounted(() => {
if(refreshTimer) clearInterval(refreshTimer)
})
// 页面显示时刷新
onShow(() => {
getMessages()
})
</script>
<style lang="scss" scoped>
.message-list {
height: calc(100vh - 100rpx); // 减去输入框高度
background: #f5f5f5;
.date-divider {
text-align: center;
font-size: 24rpx;
color: #999;
margin: 20rpx 0;
}
.message-item {
margin: 20rpx;
&.self {
.content {
flex-direction: row-reverse;
}
.bubble {
background: #95EC69;
}
}
.content {
display: flex;
align-items: flex-start;
}
.bubble {
max-width: 60%;
padding: 20rpx;
border-radius: 8rpx;
background: #fff;
word-break: break-all;
}
.time {
font-size: 24rpx;
color: #999;
margin: 0 20rpx;
}
}
}
</style>
4.2.2 常见问题及解决方案
1. iOS滚动抖动问题
- 问题:iOS端滚动加载时会出现抖动
- 解决:添加scroll-anchoring属性固定滚动位置
<scroll-view :scroll-anchoring="true">
2. 消息时间分组显示
- 问题:需要按日期分组显示消息
- 解决:比较相邻消息时间,显示日期分割线
const showDateDivider = (index) => {
if(index === 0) return true
const curr = dayjs(messages.value[index].create_time)
const prev = dayjs(messages.value[index - 1].create_time)
return !curr.isSame(prev, 'day')
}
3. 新消息提醒
- 问题:新消息需要提醒用户
- 解决:监听新消息,显示角标和震动提醒
const checkNewMessage = async () => {
const res = await api.chat.unreadCount()
if(res.data > 0) {
// 设置角标
uni.setTabBarBadge({
index: 2,
text: String(res.data)
})
// 震动提醒
uni.vibrateShort()
}
}
4. 图片消息预览
- 问题:图片消息需要支持预览
- 解决:使用uni.previewImage实现图片预览
const previewImage = (url) => {
uni.previewImage({
urls: [url],
current: url
})
}
5. 后端实现
5.1 核心功能设计
5.1.1 消息排序策略
1. 历史消息查询
- 使用message_id降序排序
- 优点:可以准确获取指定ID之前的消息
- 实现:WHERE message_id < earliest_id ORDER BY message_id DESC
2. 新消息查询
- 使用message_id升序排序
- 优点:保证消息按发送顺序显示
- 实现:WHERE message_id > latest_id ORDER BY message_id ASC
5.1.2 已读状态更新
- 批量更新以提高性能
- 根据角色区分更新逻辑
- 使用事务确保数据一致性
5.2 核心SQL实现
' 获取消息列表
dim sql: sql = "SELECT c.*,"
sql = sql & "u1.username as sender_name,"
sql = sql & "u2.username as accept_name "
sql = sql & "FROM " & db_PREFIX & "chat c "
sql = sql & "LEFT JOIN " & db_PREFIX & "user u1 ON c.sender_id = u1.user_id "
sql = sql & "LEFT JOIN " & db_PREFIX & "user u2 ON c.accept_id = u2.user_id "
sql = sql & "WHERE (c.sender_id=" & chatWithId & " AND c.accept_id=" & userId & ") "
sql = sql & "OR (c.sender_id=" & userId & " AND c.accept_id=" & chatWithId & ") "
' 根据查询类型构建条件
if beforeId <> "" then
' 获取历史消息 - 降序排序
sql = sql & "AND c.message_id < " & beforeId & " "
sql = sql & "ORDER BY c.message_id DESC "
elseif afterId <> "" then
' 获取新消息 - 升序排序
sql = sql & "AND c.message_id > " & afterId & " "
sql = sql & "ORDER BY c.message_id ASC "
else
' 首次加载 - 降序排序后反转
sql = sql & "ORDER BY c.message_id DESC "
end if
' 分页
if beforeId = "" and afterId = "" then
sql = sql & "OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY"
end if
' 更新已读状态
dim updateSql: updateSql = "UPDATE " & db_PREFIX & "chat SET is_read=1 "
updateSql = updateSql & "WHERE accept_id=" & userId & " "
if userRs("role") = 1 then
' 客服查看 - 更新用户发送的消息
updateSql = updateSql & "AND sender_type=1 AND sender_id=" & chatWithId
else
' 用户查看 - 更新客服发送的消息
updateSql = updateSql & "AND sender_type=2"
end if
conn.Execute(updateSql)
' 获取最新消息ID
dim maxSql: maxSql = "SELECT MAX(message_id) as max_id FROM " & db_PREFIX & "chat "
maxSql = maxSql & "WHERE (sender_id=" & chatWithId & " AND accept_id=" & userId & ") "
maxSql = maxSql & "OR (sender_id=" & userId & " AND accept_id=" & chatWithId & ")"
' 获取最早消息ID
dim minSql: minSql = "SELECT MIN(message_id) as min_id FROM " & db_PREFIX & "chat "
minSql = minSql & "WHERE (sender_id=" & chatWithId & " AND accept_id=" & userId & ") "
minSql = minSql & "OR (sender_id=" & userId & " AND accept_id=" & chatWithId & ")"
5.3 SQL优化说明
1. 联表查询
- 使用LEFT JOIN获取发送者和接收者信息
- 避免多次查询用户表
- 减少数据库访问次数
2. 索引使用
- message_id主键索引用于分页
- sender_id和accept_id索引用于筛选
- create_time索引用于排序
3. 分页优化
- 使用OFFSET-FETCH替代TOP
- 支持跳页查询
- 性能更好
4. 批量更新
- 一次更新多条记录
- 减少数据库操作
- 提高性能
6. 总结
本项目基于UniApp + SQL Server打造的即时聊天系统,实现了实时对话、历史消息、已读状态等核心功能,提供了流畅的用户体验和稳定的性能表现。
关于作者
如果本文章对您有所帮助,欢迎交流和探讨技术问题。
QQ: 313801120
更多文章: www.xiyueta.com/
希望能一起成长,共同探索更多开发技巧!