This commit is contained in:
Vinjor 2025-09-02 15:53:57 +08:00
parent 0a793caf80
commit a33b1bff7e
8 changed files with 514 additions and 156 deletions

View File

@ -80,4 +80,8 @@ public class BusiChatMain extends DlBaseEntity
/** 未读消息数量 */
@TableField(exist = false)
private Integer unreadCount;
/** 产品名称 */
@TableField(exist = false)
private String prodName;
}

View File

@ -118,6 +118,8 @@ public class BusiChatMainServiceImpl extends ServiceImpl<BusiChatMainMapper,Busi
for (BusiChatMain session : sessions) {
session.setUnreadCount(busiChatItemService.selectUnreadCount(session.getId(),DATA_FROM_CUSTOMER));
}
//对会话未读消息数量进行倒叙排列
sessions.sort((o1, o2) -> o2.getUnreadCount() - o1.getUnreadCount());
return sessions;
}

View File

@ -5,15 +5,15 @@ spring:
driverClassName: com.mysql.cj.jdbc.Driver
druid:
# 主库数据源-点亮开发库
# master:
# url: jdbc:mysql://82.156.161.160:3306/dl_site_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
# username: site
# password: 123456
#主库数据源-客户测试服务器
master:
url: jdbc:mysql://127.0.0.1:3306/dl_site_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
url: jdbc:mysql://82.156.161.160:3306/dl_site_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
username: site
password: 123456
#主库数据源-客户测试服务器
# master:
# url: jdbc:mysql://127.0.0.1:3306/dl_site_system?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
# username: site
# password: 123456
# 从库数据源
slave:
# 从数据源开关/默认关闭

View File

@ -134,13 +134,15 @@
</select>
<select id="selectByServiceId" resultType="com.ruoyi.busi.domain.BusiChatMain">
SELECT
*
dbcm.*,
dbpn.title AS prodName
FROM
dl_busi_chat_main
dl_busi_chat_main dbcm
LEFT JOIN dl_busi_prod_new dbpn ON dbcm.prod_id = dbpn.id
WHERE
user_id = #{serviceId}
AND `status` = 1
AND del_flag = '0'
dbcm.user_id =#{serviceId}
AND dbcm.`status` = 1
AND dbcm.del_flag = '0'
</select>
<select id="selectActiveSession" resultType="com.ruoyi.busi.domain.BusiChatMain">
SELECT

View File

@ -14,4 +14,4 @@ VUE_CLI_BABEL_TRANSPILE_MODULES = true
VUE_APP_WEBSOCKET = 'ws://localhost:8099/ws/asset/'
# 产品、文章预览
VUE_APP_PREVIEW = 'http://www.lighting-it.cn/admin-preview/'
VUE_APP_PREVIEW = 'http://192.168.1.13:3001/admin-preview/'

View File

@ -5,10 +5,10 @@ VUE_APP_TITLE = 成事达管理平台
ENV = 'production'
# 成事达管理平台/生产环境
VUE_APP_BASE_API = 'http://114.132.197.85:8099'
VUE_APP_BASE_API = 'http://1.92.99.15:8099'
# websocket
VUE_APP_WEBSOCKET = 'ws://114.132.197.85:8099/ws/asset/'
VUE_APP_WEBSOCKET = 'ws://1.92.99.15:8099/ws/asset/'
# 产品、文章预览
VUE_APP_PREVIEW = 'http://www.lighting-it.cn/admin-preview/'

View File

@ -1,39 +1,57 @@
<template>
<!-- 选择产品对话框 -->
<el-dialog @close="close" :title="title" :visible.sync="open" width="800px" append-to-body>
<div class="dl-chat-box" >
<template v-for="(item,index) in messages">
<div v-if="item.dataFrom=='customer'" class="dl-customer-dom">
<div class="dl-customer-photo">
<img src="@/assets/images/customer.jpg" >
</div>
<div class="dl-customer-right">
<div class="dl-customer-time">{{item.createTime}}</div>
<div class="dl-customer-content">{{ item.content }}</div>
<el-dialog @close="close" :title="title" :visible.sync="open" width="800px" append-to-body class="customer-service-dialog">
<!-- 自定义标题栏 -->
<div slot="header" class="dialog-header">
<div class="service-info">
<h3 class="service-name">{{ title }}</h3>
</div>
<button class="close-btn" @click="close" aria-label="关闭对话框">×</button>
</div>
<div class="dl-prod-box" @click="goProdDetail">咨询产品<span>{{session.prodName}}</span></div>
<!-- 聊天内容区域 -->
<div class="chat-container">
<!-- 消息列表内部滚动 -->
<div class="message-list-wrapper">
<div class="message-list" ref="messageList">
<!-- 消息项 -->
<div
v-for="(msg, index) in messages"
:key="index"
:class="['message-item', msg.dataFrom=='platform' ? 'self-message' :'other-message']"
>
<div class="message-content">
<div class="message-text">{{ msg.content }}</div>
<div class="message-time">{{ formatTime(msg.createTime) }}</div>
</div>
</div>
</div>
<div v-if="item.dataFrom=='platform'" class="dl-platform-dom">
</div>
<div class="dl-platform-right">
<div class="dl-platform-time">{{item.createTime}}</div>
<div class="dl-platform-content">{{ item.content }}</div>
</div>
<div class="dl-platform-photo">
<img src="@/assets/images/customer.jpg" >
</div>
</div>
</template>
<!-- 输入区域 -->
<div class="input-area">
<textarea
v-model="text"
class="message-input"
placeholder="按Enter发送"
@keydown.enter="sendToServer"
:disabled="isInputDisabled || readOnly"
></textarea>
<el-button type="primary"
icon="el-icon-s-promotion"
@click="sendToServer"
:disabled="!text.trim() || isInputDisabled || readOnly"
>
</el-button>
<el-button type="primary"
:disabled="readOnly"
@click="closeCurrentSession"
>结束会话
</el-button>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-input type="textarea"
class="inputT"
placeholder="按 Enter 发送" v-model="text"
@keyup.enter.native="sendToServer"
></el-input>
<el-button type="primary" icon="el-icon-s-promotion" @click="sendToServer"></el-button>
<el-button type="warning" @click="closeCurrentSession">结束会话</el-button>
</div>
</el-dialog>
</template>
@ -44,6 +62,9 @@ export default {
name: 'chatForm',
data() {
return {
//
readOnly:false,
isInputDisabled: false,
picPrex:process.env.VUE_APP_BASE_API,
//
title: "聊天记录",
@ -53,7 +74,9 @@ export default {
messages: [],
//
text:'',
session: null,
session: {
prodName:""
},
}
},
methods: {
@ -86,9 +109,9 @@ export default {
scrollBottom(){
//
this.$nextTick(() => {
const chatBox = this.$el.querySelector('.dl-chat-box');
if (chatBox) {
chatBox.scrollTop = chatBox.scrollHeight;
const messageList = this.$refs.messageList;
if (messageList) {
messageList.scrollTop = messageList.scrollHeight;
}
});
},
@ -131,105 +154,369 @@ export default {
},
//
addNewMsg(msg){
this.messages.push(msg)
this.scrollBottom()
console.log(msg,"msg")
// this.messages.push(msg)
setTimeout(()=>{
this.scrollBottom()
},0.5)
},
close(){
this.open = false;
//
this.$emit("closeForm")
}
},
/**
* 格式化时间显示
* @param {string|number} time - 时间戳或时间字符串
* @returns {string} 格式化后的时间字符串 HH:mm
*/
formatTime(time) {
if (!time) return '';
try {
const date = new Date(time);
if (isNaN(date.getTime())) return ''; //
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
} catch (error) {
console.error('时间格式化错误:', error);
return '';
}
},
goProdDetail(){
this.$router.push({path:'/product/prodForm',query:{id:this.session.prodId}})
}
}
}
</script>
<style scoped>
.dl-chat-box{
width: 100%;
background: #F1F3F7;
max-height:500px;
display: flex;
flex-direction: column;
align-items: start;
justify-content: start;
overflow-y: scroll;
padding: 10px 20px;
/deep/.el-dialog__body{
padding: 10px 20px !important;
}
.dl-customer-dom{
padding: 15px 0;
width: 100%;
display: flex;
.customer-service-dialog {
--primary-color: #409eff;
--primary-light: #e8f3ff;
--primary-dark: #337ecc;
--bg-color: #f7f9fc;
--text-color: #303133;
--text-light: #909399;
--border-radius: 12px;
--shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
}
.dl-customer-photo{
width: 60px;
/* 标题栏样式 */
.dialog-header {
display: flex;
justify-content: center;
justify-content: space-between;
align-items: center;
}
.dl-customer-photo img{
width: 40px;
height: 40px;
border-radius: 50%;
}
.dl-customer-right{
flex: 1;
display: flex;
flex-direction: column;
align-items: start;
justify-content: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
background-color: #fff;
}
.dl-customer-time{
font-size: 10px;
color: #878B90;
}
.dl-customer-content{
margin-top: 10px;
width: auto;
background-color: white;
color: black;
border-radius: 5px;
padding: 12px;
.service-info {
line-height: 1.6;
}
.dl-platform-dom{
padding: 15px 0;
width: 100%;
display: flex;
.service-name {
font-size: 16px;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.dl-platform-photo{
.service-status {
font-size: 12px;
color: var(--text-light);
display: flex;
justify-content: center;
align-items: center;
width: 60px;
}
.dl-platform-photo img{
width: 40px;
height: 40px;
border-radius: 50%;
}
.dl-platform-right{
flex: 1;
display: flex;
flex-direction: column;
align-items: end;
justify-content: center;
gap: 6px;
}
.dl-platform-time{
font-size: 10px;
color: #878B90;
.online-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #52c41a;
box-shadow: 0 0 0 3px rgba(82, 196, 26, 0.15);
}
.dl-platform-content{
.close-btn {
background: none;
border: none;
font-size: 18px;
color: var(--text-light);
cursor: pointer;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.close-btn:hover {
background-color: var(--bg-color);
color: var(--text-color);
}
.dl-prod-box{
padding-bottom: 10px;
}
.dl-prod-box span{
color: rgb(64, 158, 255);
cursor: pointer;
}
/* 聊天容器 */
.chat-container {
padding: 0;
display: flex;
flex-direction: column;
height: 550px;
background-color: var(--bg-color);
}
/* 消息列表容器 */
.message-list-wrapper {
flex: 1;
overflow: hidden;
position: relative;
}
/* 消息列表(内部滚动) */
.message-list {
height: 100%;
overflow-y: auto;
padding: 20px;
scroll-behavior: smooth;
background-color: var(--bg-color);
background-image:
radial-gradient(var(--primary-color) 0.5px, transparent 0.5px),
radial-gradient(var(--primary-color) 0.5px, var(--bg-color) 0.5px);
background-size: 20px 20px;
background-position: 0 0, 10px 10px;
background-attachment: local;
}
/* 自定义滚动条 */
.message-list::-webkit-scrollbar {
width: 6px;
}
.message-list::-webkit-scrollbar-track {
background: transparent;
}
.message-list::-webkit-scrollbar-thumb {
background-color: rgba(150, 150, 150, 0.3);
border-radius: 3px;
}
.message-list::-webkit-scrollbar-thumb:hover {
background-color: rgba(150, 150, 150, 0.5);
}
/* 系统消息 */
.system-message {
text-align: center;
font-size: 12px;
color: var(--text-light);
background-color: rgba(255, 255, 255, 0.85);
padding: 6px 14px;
border-radius: 14px;
margin: 0 auto 20px;
display: inline-block;
box-shadow: var(--shadow);
}
/* 消息项 */
.message-item {
margin-bottom: 18px;
max-width: 70%;
display: inline-block;
animation: fadeIn 0.3s ease-out;
}
.self-message {
float: right;
clear: both;
}
.other-message {
float: left;
clear: both;
}
.message-content {
position: relative;
display: inline-block;
}
.message-text {
padding: 12px 16px;
border-radius: var(--border-radius);
line-height: 1.6;
word-wrap: break-word;
max-width: 100%;
box-shadow: var(--shadow);
}
/* 消息气泡样式 */
.self-message .message-text {
background-color: var(--primary-color);
color: #fff;
border-top-right-radius: 4px;
transition: background-color 0.2s;
}
.self-message .message-text:hover {
background-color: var(--primary-dark);
}
.other-message .message-text {
background-color: #fff;
color: var(--text-color);
border-top-left-radius: 4px;
transition: box-shadow 0.2s;
}
.other-message .message-text:hover {
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.08);
}
/* 消息时间 */
.message-time {
font-size: 11px;
color: var(--text-light);
margin-top: 5px;
text-align: right;
margin-top: 10px;
width: auto;
background-color: white;
color: black;
border-radius: 5px;
padding: 12px;
white-space: nowrap;
opacity: 0.8;
transition: opacity 0.2s;
}
.message-item:hover .message-time {
opacity: 1;
}
.other-message .message-time {
text-align: left;
padding-left: 4px;
}
/* 正在输入指示器 */
.typing-indicator {
margin-bottom: 18px;
max-width: 70%;
float: left;
clear: both;
}
.typing-dots {
background-color: #fff;
padding: 12px 16px;
border-radius: var(--border-radius);
border-top-left-radius: 4px;
box-shadow: var(--shadow);
display: inline-flex;
gap: 6px;
align-items: center;
}
.typing-dots span {
width: 8px;
height: 8px;
background-color: var(--text-light);
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out both;
}
.typing-dots span:nth-child(1) { animation-delay: -0.32s; }
.typing-dots span:nth-child(2) { animation-delay: -0.16s; }
/* 输入区域 */
.input-area {
display: flex;
gap: 12px;
padding: 15px 20px;
border-top: 1px solid #f0f0f0;
background-color: #fff;
}
.message-input {
flex: 1;
border: 1px solid #e0e0e0;
border-radius: var(--border-radius);
padding: 12px 15px;
min-height: 42px;
max-height: 120px;
resize: vertical;
outline: none;
transition: all 0.2s;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
}
.message-input:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px var(--primary-light);
}
.message-input:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
/* 发送按钮 */
.send-button {
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--border-radius);
width: 44px;
height: 44px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.send-button:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
}
.send-button:active {
transform: translateY(1px);
}
.send-button:disabled {
background-color: #b3d8ff;
cursor: not-allowed;
transform: none;
}
/* 动画效果 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes typing {
0% { transform: scale(0); }
40% { transform: scale(1); }
80% { transform: scale(0); }
100% { transform: scale(0); }
}
</style>

View File

@ -1,33 +1,37 @@
<template>
<div class="session-list">
<div class="search-box">
<el-input placeholder="搜索会话..." v-model="searchKeyword" clearable size="small"></el-input>
<div class="toggle-button" @click="toggleCollapse" :title="isCollapsed?'点击展开':'点击收起'">
<i :class="isCollapsed ? 'el-icon-d-arrow-left' : 'el-icon-d-arrow-right'"></i>
</div>
<div class="session-item"
v-for="session in filteredSessions"
:key="session.id"
:class="{ 'active': currentSessionId === session.id, 'unread': session.unreadCount > 0 }"
@click="switchSession(session)"
>
<div class="avatar">
<img :src="session.userAvatar || '/img/user.png'" alt="用户头像">
<div v-show="!isCollapsed" class="session-content">
<div class="search-box">
<el-input placeholder="搜索会话..." v-model="searchKeyword" clearable size="small"></el-input>
</div>
<div class="session-info">
<div class="session-header">
<span class="user-name">{{ session.userName || '匿名用户' }}</span>
<span class="time">{{ formatTime(session.lastTime) }}</span>
</div>
<div class="last-message">
<span>{{ session.lastMessage || '暂无消息' }}</span>
<span class="unread-count" v-if="session.unreadCount > 0">{{ session.unreadCount }}</span>
<div v-show="filteredSessions.length>0" class="session-item"
v-for="session in filteredSessions"
:key="session.id"
:class="{ 'active': currentSessionId === session.id, 'unread': session.unreadCount > 0 }"
@click="switchSession(session)"
>
<div class="session-info">
<div class="session-header">
<span class="user-name">来自{{ session.oceania || '未知洲' }}-{{session.national||'未知国家'}}的会话</span>
<span class="time">{{ formatTime(session.lastTime) }}</span>
</div>
<div class="last-message">
<span>{{ session.lastMessage || '暂无消息' }}</span>
<span class="unread-count" v-if="session.unreadCount > 0">{{ session.unreadCount }}</span>
</div>
</div>
</div>
<div v-show="filteredSessions.length==0" style="text-align: center;color: rgb(90, 94, 102);padding-top: 5px;font-size: 14px">暂无会话</div>
</div>
</div>
</template>
<script>
import { getByServiceId, closeSession } from '@/api/busi/chatMain'
export default {
name: 'messageList',
data() {
@ -49,11 +53,18 @@ export default {
//
inputMessage: '',
// WebSocket
websocket: null
websocket: null,
//
isCollapsed: false
}
},
created() {
this.loadSessions()
//
const collapsed = localStorage.getItem('messageListCollapsed')
if (collapsed) {
this.isCollapsed = JSON.parse(collapsed)
}
},
watch: {
searchKeyword(val) {
@ -62,10 +73,18 @@ export default {
(session.lastMessage && session.lastMessage.includes(val))
})
},
//
isCollapsed(val) {
localStorage.setItem('messageListCollapsed', JSON.stringify(val))
}
},
methods: {
// /
toggleCollapse() {
this.isCollapsed = !this.isCollapsed
},
//
closeCurrentSession(){
closeCurrentSession() {
closeSession(this.currentSessionId).then(() => {
this.$message.success('会话已结束')
//
@ -75,7 +94,7 @@ export default {
content: '客服已结束会话',
sessionId: this.currentSessionId
}
this.$store.dispatch('websocket_send',JSON.stringify(wsMsg));
this.$store.dispatch('websocket_send', JSON.stringify(wsMsg))
//
this.currentSessionId = null
this.currentSession = {}
@ -83,13 +102,13 @@ export default {
//
this.loadSessions()
//
this.$emit("closeForm")
this.$emit('closeForm')
})
},
//
closeForm(){
this.currentSessionId=null
this.currentSession= {}
closeForm() {
this.currentSessionId = null
this.currentSession = {}
},
//
loadSessions() {
@ -102,10 +121,10 @@ export default {
switchSession(session) {
this.currentSessionId = session.id
this.currentSession = session
this.$emit("switchSession",session)
this.$emit('switchSession', session)
},
//
updateSessionRead(sessionId){
updateSessionRead(sessionId) {
const session = this.sessions.find(s => s.id === sessionId)
if (session) {
session.unreadCount = 0
@ -127,11 +146,11 @@ export default {
let newMsg = {
id: new Date().getTime(), // ID
mainId: message.sessionId,
dataFrom: "customer", //
dataFrom: 'customer', //
senderId: message.fromUserId,
receiverId: this.serviceId,
content: message.content,
createTime: new Date().toLocaleString('zh-CN', {
createTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@ -143,7 +162,7 @@ export default {
isRead: 1 //
}
//
this.$emit("addNewMag",newMsg)
this.$emit('addNewMag', newMsg)
} else {
//
const session = this.sessions.find(s => s.id === message.sessionId)
@ -151,10 +170,14 @@ export default {
session.unreadCount = (session.unreadCount || 0) + 1
session.lastMessage = message.content
session.lastTime = new Date()
let str = message.content
if(str.length>15){
str = str.substring(0,15)+'...'
}
//
this.$notify.info({
title: '新消息',
message: `来自 ${session.userName || '匿名用户'} 的消息`,
message: str,
duration: 3000
})
} else {
@ -192,25 +215,65 @@ export default {
<style scoped>
.session-list {
width: 300px;
height: 200px;
max-width: 350px;
max-height: 400px;
overflow-y: scroll;
overflow-x: hidden;
position: absolute;
right: 5px;
top: 84px;
background-color: white;
padding: 5px 0 5px 5px;
background-color: white;
border: 1px solid #c0c0c0;
border-radius: 4px;
display: flex;
flex-direction: column;
transition: background-color 1s;
}
.session-content {
width: 300px;
flex: 1;
display: flex;
padding-right: 8px;
flex-direction: column;
animation: slideIn 0.3s ease-out forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* 当元素隐藏时添加过渡效果 */
.session-content[v-if] {
transition: all 0.3s ease;
}
.toggle-button {
width: 10px;
max-height: 400px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
margin-right: 5px;
}
.toggle-button:hover {
color:rgb(64, 158, 255) ;
}
.search-box {
padding: 10px;
border-bottom: 1px solid #e6e6e6;
}
.session-item {
padding: 10px 15px;
padding: 10px 8px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
display: flex;
@ -238,7 +301,7 @@ export default {
}
.session-info {
margin-left: 10px;
margin-left: 5px;
flex: 1;
overflow: hidden;
}