Files
Portal/pages/user/customservice.vue
T
li 322cc24208 feat: integrate customer service chat functionality and add socket.io-client dependency
- Implemented a customer service chat interface in the application.
- Added socket.io-client for real-time communication.
- Updated language files to include customer service related translations.
- Modified navigation to direct users to the customer service page.
- Enhanced UI elements for better user experience in chat interactions.
2026-01-29 03:04:52 +08:00

519 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="customservice">
<u-navbar
leftIconColor="#d9ac6b"
bgColor="#1f120e"
:placeholder="true"
:fixed="true"
height="50"
@leftClick="back"
:title="$lang.customerService || '在线客服'"
titleStyle="color: #d9ac6b; font-weight: bold;"
></u-navbar>
<!-- 聊天窗口 -->
<view class="chat-container">
<!-- 聊天头部状态 -->
<view class="chat-header">
<view class="chat-status" :class="{'online': sessionId}">
{{ sessionId ? ($lang.csConnected || '已连接') : ($lang.csConnecting || '连接中...') }}
</view>
</view>
<!-- 消息列表 -->
<scroll-view class="chat-messages" scroll-y :scroll-top="scrollTop" scroll-with-animation>
<!-- 欢迎信息 -->
<view v-if="!sessionId" class="chat-welcome">
<text class="welcome-icon">💬</text>
<text class="welcome-title">{{ $lang.csWelcome || '欢迎使用在线客服' }}</text>
<text v-if="!isConnected" class="welcome-tip">{{ $lang.csConnecting || '正在连接...' }}</text>
<text v-else-if="sessionStatus === 0" class="welcome-tip warning">{{ $lang.csBusy || '客服繁忙请稍候...' }}</text>
<text v-else class="welcome-tip">{{ $lang.csInputTip || '请输入您的问题' }}</text>
</view>
<!-- 消息列表 -->
<view v-for="(msg, index) in messages" :key="index"
class="chat-message"
:class="{
'chat-message-own': msg.senderType === 1,
'chat-message-system': msg.senderType === 0
}">
<!-- 系统消息 -->
<view v-if="msg.senderType === 0" class="system-msg">
<text>{{ msg.content }}</text>
</view>
<!-- 普通消息 -->
<view v-else class="message-content">
<view class="message-avatar">
<text v-if="msg.senderType === 1">{{ $lang.csMe || '' }}</text>
<text v-else>{{ $lang.csAgent || '客服' }}</text>
</view>
<view class="message-bubble">
<text class="message-text">{{ msg.content }}</text>
<text class="message-time">{{ formatTime(msg.createTime) }}</text>
</view>
</view>
</view>
<!-- 底部占位 -->
<view style="height: 20px;"></view>
</scroll-view>
<!-- 输入区域 -->
<view class="chat-input-area">
<textarea
v-model="inputMessage"
:placeholder="$lang.csInputPlaceholder || '输入消息,点击发送...'"
:disabled="!isConnected || !sessionId"
class="chat-input"
:maxlength="500"
@confirm="sendMessage"
></textarea>
<view class="chat-actions">
<button
class="chat-btn-send"
@click="sendMessage"
:disabled="!isConnected || !sessionId || !inputMessage.trim()"
>{{ $lang.csSend || '发送' }}</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { mapState } from "vuex";
import io from 'socket.io-client';
export default {
data() {
return {
socket: null,
isConnected: false,
sessionId: null,
sessionStatus: null,
messages: [],
inputMessage: '',
scrollTop: 0,
heartbeatTimer: null
};
},
computed: {
...mapState({
userInfo: state => state.app.userInfo,
sockUrl: state => state.app.sockUrl,
$lang: state => state.app.lang[state.app.language]
})
},
onLoad() {
this.connectWebSocket();
},
onUnload() {
this.disconnect();
},
methods: {
back() {
this.disconnect();
uni.navigateBack();
},
disconnect() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
},
connectWebSocket() {
// 使用 store 中的 sockUrl
const sockUrl = this.sockUrl || 'wss://wss.g7g7.top';
const loginToken = this.userInfo.online_token || this.userInfo.login_token || '';
console.log('[CustomerService] 连接 WebSocket:', sockUrl);
console.log('[CustomerService] loginToken:', loginToken);
console.log('[CustomerService] userInfo:', JSON.stringify(this.userInfo));
// 客服连接:不带 connect=user 参数,避免触发游戏连接逻辑
// 连接成功后通过 chat.connect 事件进行客服认证
this.socket = io(sockUrl, {
transports: ['websocket'],
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
});
this.socket.on('connect', () => {
console.log('[CustomerService] WebSocket 已连接, socket.id:', this.socket.id);
this.isConnected = true;
// 发送客服连接请求,后端通过 chat.connect 事件验证用户
const chatConnectData = {
token: loginToken,
role: 'user',
source: 3 // Portal端
};
console.log('[CustomerService] 发送 chat.connect:', JSON.stringify(chatConnectData));
this.socket.emit('chat.connect', chatConnectData);
// 启动心跳
this.startHeartbeat();
});
this.socket.on('disconnect', (reason) => {
console.log('[CustomerService] WebSocket 已断开, 原因:', reason);
this.isConnected = false;
});
this.socket.on('connect_error', (error) => {
console.log('[CustomerService] 连接错误:', error.message);
this.isConnected = false;
});
this.socket.on('error', (error) => {
console.log('[CustomerService] Socket错误:', error);
});
// 后端 ChatConnect.php 使用下划线格式的事件名
this.socket.on('chat_connected', (data) => {
console.log('[CustomerService] chat_connected:', data);
if (data.success) {
this.sessionId = data.sessionId;
this.sessionStatus = data.status;
if (data.agentInfo) {
this.addSystemMessage((this.$lang.csAgent || '客服') + ' ' + data.agentInfo.nickname + ' ' + (this.$lang.csAgentServing || '为您服务'));
}
} else {
uni.showToast({ title: (this.$lang.csConnectFailed || '连接失败') + ': ' + (data.error || '未知错误'), icon: 'none' });
}
});
this.socket.on('chat.session.assigned', (data) => {
console.log('[CustomerService] chat.session.assigned:', data);
this.sessionStatus = 1;
this.addSystemMessage((this.$lang.csAgent || '客服') + ' ' + data.agentInfo.nickname + ' ' + (this.$lang.csAgentJoined || '已接入'));
});
this.socket.on('chat_message_new', (data) => {
console.log('[CustomerService] chat_message_new:', data);
const msg = data.data || data;
this.messages.push({
msgId: msg.msgId,
senderType: msg.senderType,
msgType: msg.msgType,
content: msg.content,
createTime: msg.createTime || msg.time
});
this.scrollToBottom();
// 发送已读回执
if (msg.msgId) {
this.socket.emit('chat.message.ack', { msgId: msg.msgId });
}
});
this.socket.on('chat.session.ended', () => {
console.log('[CustomerService] chat.session.ended');
this.addSystemMessage(this.$lang.csSessionEnded || '会话已结束');
this.sessionStatus = 2;
});
// 后端使用下划线格式
this.socket.on('chat_offline_notice', (data) => {
console.log('[CustomerService] chat_offline_notice:', data);
this.addSystemMessage(data.message);
});
// 后端使用下划线格式
this.socket.on('chat_pong', () => {
// 心跳响应
});
},
startHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
}
this.heartbeatTimer = setInterval(() => {
if (this.socket && this.socket.connected) {
this.socket.emit('chat.ping', {});
}
}, 25000);
},
sendMessage() {
const content = this.inputMessage.trim();
if (!content || !this.sessionId) return;
const clientMsgId = Date.now();
this.socket.emit('chat.message.send', {
sessionId: this.sessionId,
msgType: 1,
content: content,
clientMsgId: clientMsgId
});
// 乐观更新 UI
this.messages.push({
msgId: clientMsgId,
senderType: 1, // 用户发送
msgType: 1,
content: content,
createTime: Date.now()
});
this.inputMessage = '';
this.scrollToBottom();
},
addSystemMessage(text) {
this.messages.push({
msgId: Date.now(),
senderType: 0,
msgType: 1,
content: text,
createTime: Date.now()
});
this.scrollToBottom();
},
scrollToBottom() {
// 先重置为0,再设置大值,确保Vue检测到变化
this.scrollTop = 0;
this.$nextTick(() => {
this.scrollTop = 999999;
});
},
formatTime(timestamp) {
if (!timestamp) return '';
// 如果是毫秒时间戳
const ts = timestamp > 9999999999 ? timestamp : timestamp * 1000;
const date = new Date(ts);
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
}
};
</script>
<style lang="scss" scoped>
.customservice {
background: #0b1520;
width: 100%;
min-height: 100vh;
::v-deep .u-navbar__content__title {
color: #d9ac6b;
font-weight: 600;
}
/* 聊天容器 */
.chat-container {
position: fixed;
top: 50px;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
background: #3d2c22;
}
/* 聊天头部 */
.chat-header {
flex-shrink: 0;
display: flex;
justify-content: center;
align-items: center;
padding: 10px 15px;
background: #1f120e;
}
.chat-status {
padding: 4px 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
font-size: 12px;
color: #999;
}
.chat-status.online {
background: rgba(76, 175, 80, 0.3);
color: #4caf50;
}
/* 消息区域 */
.chat-messages {
flex: 1;
padding: 15px;
background: #3d2c22;
overflow: hidden;
height: 0; /* 配合flex:1让scroll-view正常工作 */
}
/* 欢迎信息 */
.chat-welcome {
text-align: center;
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
.welcome-icon {
font-size: 48px;
}
.welcome-title {
font-size: 18px;
font-weight: bold;
color: #d9ac6b;
}
.welcome-tip {
font-size: 14px;
color: #999;
}
.welcome-tip.warning {
color: #ff9800;
}
}
/* 消息项 */
.chat-message {
margin-bottom: 15px;
display: flex;
}
.chat-message-own {
justify-content: flex-end;
}
.chat-message-system {
justify-content: center;
}
/* 系统消息 */
.system-msg {
background: rgba(0, 0, 0, 0.3);
color: #999;
font-size: 12px;
padding: 5px 15px;
border-radius: 12px;
}
/* 消息内容 */
.message-content {
display: flex;
align-items: flex-start;
max-width: 80%;
}
.chat-message-own .message-content {
flex-direction: row-reverse;
}
/* 头像 */
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #1f120e;
color: #d9ac6b;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
margin: 0 10px;
}
.chat-message-own .message-avatar {
background: linear-gradient(135deg, #d9ac6b 0%, #a67c3d 100%);
color: #1f120e;
}
/* 消息气泡 */
.message-bubble {
background: #2a1f18;
border-radius: 8px;
padding: 10px 12px;
}
.chat-message-own .message-bubble {
background: linear-gradient(135deg, #d9ac6b 0%, #a67c3d 100%);
}
.message-text {
color: #e0e0e0;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
.chat-message-own .message-text {
color: #1f120e;
}
.message-time {
font-size: 11px;
color: #777;
margin-top: 5px;
display: block;
text-align: right;
}
.chat-message-own .message-time {
color: rgba(31, 18, 14, 0.6);
}
/* 输入区域 */
.chat-input-area {
flex-shrink: 0;
border-top: 1px solid #2a1f18;
padding: 12px;
background: #1f120e;
padding-bottom: calc(12px + env(safe-area-inset-bottom));
}
.chat-input {
width: 100%;
min-height: 60px;
max-height: 100px;
background: #3d2c22;
border: 1px solid #553e31;
border-radius: 6px;
padding: 10px;
font-size: 14px;
color: #e0e0e0;
box-sizing: border-box;
}
.chat-input[disabled] {
opacity: 0.5;
}
.chat-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.chat-btn-send {
padding: 10px 30px;
border: none;
border-radius: 20px;
font-size: 14px;
font-weight: bold;
background: linear-gradient(180deg, #f7ee99 0%, #78681c 50%, #c0b141 100%);
color: #1f120e;
}
.chat-btn-send:active {
opacity: 0.8;
}
.chat-btn-send[disabled] {
opacity: 0.5;
}
}
</style>