|
|
@@ -0,0 +1,508 @@
|
|
|
+<template>
|
|
|
+ <view class="chat-container">
|
|
|
+ <view class="header">
|
|
|
+ <text class="title">{{name || '在线客服'}}</text>
|
|
|
+ <view class="status">
|
|
|
+ <text class="status-dot" :class="{ 'online': isConnected }"></text>
|
|
|
+ <text class="status-text">{{ isConnected ? '已连接' : '连接中...' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <scroll-view
|
|
|
+ class="message-list"
|
|
|
+ scroll-y
|
|
|
+ :scroll-into-view="scrollToView"
|
|
|
+ :scroll-with-animation="true"
|
|
|
+ >
|
|
|
+ <view class="message-item" v-for="(msg, index) in messages" :key="index" :id="'msg-' + index">
|
|
|
+ <view v-if="msg.type === 'text'" class="message-bubble" :class="msg.from === 'me' ? 'right' : 'left'">
|
|
|
+ <view class="avatar">
|
|
|
+ <image v-if="msg.from === 'me' && userInfo.avatar" :src="userInfo.avatar" mode="aspectFill"></image>
|
|
|
+ <view v-else :class="msg.from === 'me' ? 'my-avatar' : 'service-avatar'">
|
|
|
+ <text>{{ msg.from === 'me' ? '我' : '客' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="bubble-content">
|
|
|
+ <text>{{ msg.content }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-else-if="msg.type === 'image'" class="message-bubble" :class="msg.from === 'me' ? 'right' : 'left'">
|
|
|
+ <view class="avatar">
|
|
|
+ <image v-if="msg.from === 'me' && userInfo.avatar" :src="userInfo.avatar" mode="aspectFill"></image>
|
|
|
+ <view v-else :class="msg.from === 'me' ? 'my-avatar' : 'service-avatar'">
|
|
|
+ <text>{{ msg.from === 'me' ? '我' : '客' }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ <view class="bubble-image" @click="previewImage(msg.content)">
|
|
|
+ <image :src="msg.content" mode="widthFix" @tap="previewImage(msg.content)"></image>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-else-if="msg.type === 'system'" class="system-message">
|
|
|
+ <text>{{ msg.content }}</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+
|
|
|
+ <view class="input-area">
|
|
|
+ <view class="input-wrapper">
|
|
|
+ <input
|
|
|
+ class="input"
|
|
|
+ type="text"
|
|
|
+ v-model="inputText"
|
|
|
+ placeholder="请输入消息..."
|
|
|
+ placeholder-class="placeholder"
|
|
|
+ @confirm="sendText"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+ <view class="action-buttons">
|
|
|
+ <view class="btn" @click="sendImage">
|
|
|
+ <text class="btn-icon">📷</text>
|
|
|
+ </view>
|
|
|
+ <view class="btn send-btn" :class="{ 'disabled': !inputText.trim() }" @click="sendText">
|
|
|
+ <text class="btn-text">发送</text>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script>
|
|
|
+ import {
|
|
|
+ mapState,
|
|
|
+ } from 'vuex';
|
|
|
+ import {
|
|
|
+ upload
|
|
|
+ } from '@/api/set.js';
|
|
|
+
|
|
|
+ export default {
|
|
|
+ data() {
|
|
|
+ return {
|
|
|
+ name: '',
|
|
|
+ socketTask: null,
|
|
|
+ isConnected: false,
|
|
|
+ reconnectCount: 0,
|
|
|
+ maxReconnect: 3,
|
|
|
+ messages: [],
|
|
|
+ inputText: '',
|
|
|
+ scrollToView: '',
|
|
|
+ wsUrl: 'ws://api.myje.cn:2345'
|
|
|
+ }
|
|
|
+ },
|
|
|
+ onLoad(opt) {
|
|
|
+ this.addSystemMessage('正在连接服务器...');
|
|
|
+ this.name = opt.name
|
|
|
+ this.connectWebSocket(opt.to_uid);
|
|
|
+ },
|
|
|
+ onUnload() {
|
|
|
+ this.closeWebSocket();
|
|
|
+ },
|
|
|
+ computed: {
|
|
|
+ ...mapState('user', ['userInfo'])
|
|
|
+ },
|
|
|
+ methods: {
|
|
|
+ connectWebSocket(to_uid) {
|
|
|
+ const token = uni.getStorageSync('token') || '';
|
|
|
+ const wsUrl =`${this.wsUrl}?token=${token}` + '&type=user&from=3&to_uid=' + to_uid;
|
|
|
+
|
|
|
+ this.socketTask = uni.connectSocket({
|
|
|
+ url: wsUrl,
|
|
|
+ success: () => {
|
|
|
+ console.log('WebSocket连接请求成功');
|
|
|
+ },
|
|
|
+ fail: (err) => {
|
|
|
+ console.log('WebSocket连接失败', err);
|
|
|
+ this.handleReconnect();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ this.socketTask.onOpen(() => {
|
|
|
+ console.log('WebSocket连接成功');
|
|
|
+ this.isConnected = true;
|
|
|
+ this.reconnectCount = 0;
|
|
|
+ this.addSystemMessage('已连接到服务器');
|
|
|
+ });
|
|
|
+
|
|
|
+ this.socketTask.onMessage((res) => {
|
|
|
+ this.handleMessage(res.data);
|
|
|
+ });
|
|
|
+
|
|
|
+ this.socketTask.onClose(() => {
|
|
|
+ console.log('WebSocket连接关闭');
|
|
|
+ this.isConnected = false;
|
|
|
+ this.handleReconnect();
|
|
|
+ });
|
|
|
+
|
|
|
+ this.socketTask.onError((err) => {
|
|
|
+ console.log('WebSocket错误', err);
|
|
|
+ this.isConnected = false;
|
|
|
+ this.addSystemMessage('连接发生错误');
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ handleReconnect() {
|
|
|
+ if (this.reconnectCount < this.maxReconnect) {
|
|
|
+ this.reconnectCount++;
|
|
|
+ this.addSystemMessage(`正在重连... (${this.reconnectCount}/${this.maxReconnect})`);
|
|
|
+ setTimeout(() => {
|
|
|
+ this.connectWebSocket();
|
|
|
+ }, 2000);
|
|
|
+ } else {
|
|
|
+ this.addSystemMessage('连接失败,请稍后重试');
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ closeWebSocket() {
|
|
|
+ if (this.socketTask) {
|
|
|
+ this.socketTask.close();
|
|
|
+ this.socketTask = null;
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ handleMessage(data) {
|
|
|
+ try {
|
|
|
+ const message = typeof data === 'string' ? JSON.parse(data) : data;
|
|
|
+ this.addMessage(message);
|
|
|
+ } catch (e) {
|
|
|
+ this.addMessage({
|
|
|
+ from: 'service',
|
|
|
+ type: 'text',
|
|
|
+ content: data
|
|
|
+ });
|
|
|
+ }
|
|
|
+ },
|
|
|
+
|
|
|
+ addMessage(msg) {
|
|
|
+ this.messages.push({
|
|
|
+ from: msg.from || 'service',
|
|
|
+ type: msg.type || 'text',
|
|
|
+ content: msg.content,
|
|
|
+ time: new Date().getTime()
|
|
|
+ });
|
|
|
+ this.scrollToBottom();
|
|
|
+ },
|
|
|
+
|
|
|
+ addSystemMessage(content) {
|
|
|
+ this.messages.push({
|
|
|
+ type: 'system',
|
|
|
+ content: content,
|
|
|
+ time: new Date().getTime()
|
|
|
+ });
|
|
|
+ this.scrollToBottom();
|
|
|
+ },
|
|
|
+
|
|
|
+ sendText() {
|
|
|
+ if (!this.inputText.trim()) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const text = this.inputText.trim();
|
|
|
+
|
|
|
+ if (!this.isConnected) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '连接未就绪',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const message = {
|
|
|
+ type: 'text',
|
|
|
+ content: text
|
|
|
+ };
|
|
|
+
|
|
|
+ this.socketTask.send({
|
|
|
+ data: JSON.stringify(message),
|
|
|
+ success: () => {
|
|
|
+ this.addMessage({
|
|
|
+ from: 'me',
|
|
|
+ type: 'text',
|
|
|
+ content: text
|
|
|
+ });
|
|
|
+ this.inputText = '';
|
|
|
+ },
|
|
|
+ fail: () => {
|
|
|
+ uni.showToast({
|
|
|
+ title: '发送失败',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ sendImage() {
|
|
|
+ if (!this.isConnected) {
|
|
|
+ uni.showToast({
|
|
|
+ title: '连接未就绪',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ upload({
|
|
|
+ filename: ''
|
|
|
+ }).then(data => {
|
|
|
+ const imageUrl = data[0].img;
|
|
|
+
|
|
|
+ const message = {
|
|
|
+ type: 'image',
|
|
|
+ content: imageUrl
|
|
|
+ };
|
|
|
+
|
|
|
+ this.socketTask.send({
|
|
|
+ data: JSON.stringify(message),
|
|
|
+ success: () => {
|
|
|
+ this.addMessage({
|
|
|
+ from: 'me',
|
|
|
+ type: 'image',
|
|
|
+ content: imageUrl
|
|
|
+ });
|
|
|
+ },
|
|
|
+ fail: () => {
|
|
|
+ uni.showToast({
|
|
|
+ title: '发送失败',
|
|
|
+ icon: 'none'
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }).catch(err => {
|
|
|
+ console.log('上传图片失败', err);
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ previewImage(url) {
|
|
|
+ uni.previewImage({
|
|
|
+ urls: [url],
|
|
|
+ current: url
|
|
|
+ });
|
|
|
+ },
|
|
|
+
|
|
|
+ scrollToBottom() {
|
|
|
+ this.$nextTick(() => {
|
|
|
+ const index = this.messages.length - 1;
|
|
|
+ if (index >= 0) {
|
|
|
+ this.scrollToView = 'msg-' + index;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss">
|
|
|
+ .chat-container {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ height: 100vh;
|
|
|
+ background-color: $page-color-base;
|
|
|
+ }
|
|
|
+
|
|
|
+ .header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+ background-color: #FFFFFF;
|
|
|
+ border-bottom: 1px solid $border-color-light;
|
|
|
+
|
|
|
+ .title {
|
|
|
+ font-size: $font-lg;
|
|
|
+ color: $font-color-dark;
|
|
|
+ font-weight: bold;
|
|
|
+ }
|
|
|
+
|
|
|
+ .status {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .status-dot {
|
|
|
+ width: 16rpx;
|
|
|
+ height: 16rpx;
|
|
|
+ border-radius: 50%;
|
|
|
+ background-color: $font-color-light;
|
|
|
+ margin-right: 10rpx;
|
|
|
+
|
|
|
+ &.online {
|
|
|
+ background-color: $color-green;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .status-text {
|
|
|
+ font-size: $font-sm;
|
|
|
+ color: $font-color-light;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-list {
|
|
|
+ flex: 1;
|
|
|
+ padding: 20rpx;
|
|
|
+ overflow-y: auto;
|
|
|
+ }
|
|
|
+
|
|
|
+ .message-item {
|
|
|
+ margin-bottom: 20rpx;
|
|
|
+
|
|
|
+ .message-bubble {
|
|
|
+ display: flex;
|
|
|
+ align-items: flex-start;
|
|
|
+
|
|
|
+ &.right {
|
|
|
+ flex-direction: row-reverse;
|
|
|
+
|
|
|
+ .avatar {
|
|
|
+ margin-left: 16rpx;
|
|
|
+ margin-right: 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ .bubble-content {
|
|
|
+ background-color: $base-color;
|
|
|
+ color: #FFFFFF;
|
|
|
+ border-radius: 16rpx 0 16rpx 16rpx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.left {
|
|
|
+ .avatar {
|
|
|
+ margin-right: 16rpx;
|
|
|
+ }
|
|
|
+
|
|
|
+ .bubble-content {
|
|
|
+ background-color: #FFFFFF;
|
|
|
+ color: $font-color-dark;
|
|
|
+ border-radius: 0 16rpx 16rpx 16rpx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .avatar {
|
|
|
+ width: 80rpx;
|
|
|
+ height: 80rpx;
|
|
|
+ flex-shrink: 0;
|
|
|
+
|
|
|
+ image {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 50%;
|
|
|
+ background-color: $image-bg-color;
|
|
|
+ }
|
|
|
+
|
|
|
+ .my-avatar, .service-avatar {
|
|
|
+ width: 100%;
|
|
|
+ height: 100%;
|
|
|
+ border-radius: 50%;
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+
|
|
|
+ text {
|
|
|
+ color: #FFFFFF;
|
|
|
+ font-size: $font-sm;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .my-avatar {
|
|
|
+ background-color: $base-color;
|
|
|
+ }
|
|
|
+
|
|
|
+ .service-avatar {
|
|
|
+ background-color: $color-green;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .bubble-content {
|
|
|
+ max-width: 60%;
|
|
|
+ padding: 20rpx 24rpx;
|
|
|
+ font-size: $font-base;
|
|
|
+ line-height: 1.6;
|
|
|
+ word-break: break-all;
|
|
|
+ }
|
|
|
+
|
|
|
+ .bubble-image {
|
|
|
+ max-width: 60%;
|
|
|
+
|
|
|
+ image {
|
|
|
+ max-width: 100%;
|
|
|
+ max-height: 400rpx;
|
|
|
+ border-radius: 16rpx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .system-message {
|
|
|
+ text-align: center;
|
|
|
+ padding: 16rpx;
|
|
|
+
|
|
|
+ text {
|
|
|
+ font-size: $font-sm;
|
|
|
+ color: $font-color-light;
|
|
|
+ background-color: rgba(0, 0, 0, 0.06);
|
|
|
+ padding: 8rpx 20rpx;
|
|
|
+ border-radius: 20rpx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .input-area {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ padding: 20rpx 30rpx;
|
|
|
+ background-color: #FFFFFF;
|
|
|
+ border-top: 1px solid $border-color-light;
|
|
|
+
|
|
|
+ .input-wrapper {
|
|
|
+ flex: 1;
|
|
|
+ margin-right: 20rpx;
|
|
|
+
|
|
|
+ .input {
|
|
|
+ width: 100%;
|
|
|
+ height: 76rpx;
|
|
|
+ padding: 0 24rpx;
|
|
|
+ background-color: $page-color-base;
|
|
|
+ border-radius: 38rpx;
|
|
|
+ font-size: $font-base;
|
|
|
+ }
|
|
|
+
|
|
|
+ .placeholder {
|
|
|
+ color: $font-color-light;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .action-buttons {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+
|
|
|
+ .btn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 76rpx;
|
|
|
+ height: 76rpx;
|
|
|
+ margin-right: 16rpx;
|
|
|
+ background-color: $page-color-base;
|
|
|
+ border-radius: 50%;
|
|
|
+
|
|
|
+ &.send-btn {
|
|
|
+ width: auto;
|
|
|
+ padding: 0 30rpx;
|
|
|
+ background-color: $base-color;
|
|
|
+ border-radius: 38rpx;
|
|
|
+ margin-right: 0;
|
|
|
+
|
|
|
+ &.disabled {
|
|
|
+ opacity: 0.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ .btn-text {
|
|
|
+ font-size: $font-base;
|
|
|
+ color: #FFFFFF;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ .btn-icon {
|
|
|
+ font-size: 36rpx;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+</style>
|