最新版在线客服系统开发

1 阅读5分钟

在线客服系统开发

概述

本项目是一个基于 Web 的在线客服系统,包含用户端和客服端,支持实时聊天、用户分配、虚拟列表、分页加载历史消息以及客服登录认证。用户端无需登录,可集成到 Web/H5 页面;客服端类似于微信界面,左侧为用户列表,右侧为聊天面板,仅显示未分配或已分配给当前客服的用户。系统使用 Vue3 + TypeScript + AntDesignVue + SocketIO 构建前端,Spring Boot 3 + MyBatis-Plus + MySQL 构建后端,不使用 Redis。

需求分析

功能需求

  1. 用户端
    • 无需登录,集成到 Web/H5 页面。
    • 根据集成项目的 UserId 区分用户,无 UserId 时生成临时 ID。
    • 提供简单聊天界面,支持发送消息、接收客服回复,并显示“客服 xxx 为您服务”提示。
    • 支持虚拟列表,优化大量消息的渲染性能。
  2. 客服端
    • 类似于微信界面,左侧用户列表,右侧聊天面板。
    • 用户列表仅显示“未分配”或“已分配给当前客服”的用户。
    • 用户发送消息时,所有客服收到;某客服回复后,该用户从其他客服列表移除。
    • 支持用户状态(未分配/已分配)。
    • 支持虚拟列表和分页加载历史消息。
    • 需登录认证,验证客服身份。
  3. 后端
    • 提供用户管理、消息存储、状态分配等功能。
    • 支持分页查询历史消息。
    • 使用 SocketIO 实现实时消息传递。
    • 提供 JWT 认证接口,确保客服端安全访问。
  4. 数据库
    • 使用 MySQL 存储用户信息、客服信息和消息记录。
    • 不使用 Redis,依赖 MySQL 持久化数据。

技术栈

  • 前端:Vue3 + TypeScript + AntDesignVue + SocketIO + vue-virtual-scroller
  • 后端:Spring Boot 3 + MyBatis-Plus spring-boot3-starter 3.5.9 + MySQL
  • 实时通信:SocketIO
  • 认证:Spring Security + JWT

目录结构

online-chat-system/
├── client/                          # 用户端
│   ├── src/
│   │   ├── components/
│   │   │   └── ChatWindow.vue       # 聊天界面(虚拟列表)
│   │   ├── App.vue                  # 主组件
│   │   ├── main.ts                  # 入口文件
│   │   ├── types/
│   │   │   └── index.ts             # 类型定义
│   │   └── assets/
│   ├── package.json                 # 依赖配置
│   ├── vite.config.ts               # Vite 配置
│   └── tsconfig.json
├── agent/                           # 客服端
│   ├── src/
│   │   ├── components/
│   │   │   ├── UserList.vue         # 用户列表
│   │   │   ├── ChatPanel.vue        # 聊天面板(虚拟列表)
│   │   │   └── Login.vue            # 登录页面
│   │   ├── App.vue                  # 主组件
│   │   ├── main.ts                  # 入口文件
│   │   ├── types/
│   │   │   └── index.ts             # 类型定义
│   │   └── assets/
│   ├── package.json                 # 依赖配置
│   ├── vite.config.ts               # Vite 配置
│   └── tsconfig.json
├── server/                          # 后端
│   ├── src/
│   │   ├── main/
│   │   │   ├── java/com/example/chat/
│   │   │   │   ├── config/
│   │   │   │   │   ├── SocketIOConfig.java
│   │   │   │   │   └── SecurityConfig.java  # Spring Security 配置
│   │   │   │   ├── controller/
│   │   │   │   │   ├── ChatController.java  # 聊天接口(含分页)
│   │   │   │   │   └── AuthController.java  # 登录接口
│   │   │   │   ├── entity/
│   │   │   │   │   ├── User.java
│   │   │   │   │   ├── Agent.java           # 含 password 字段
│   │   │   │   │   └── Message.java
│   │   │   │   ├── mapper/
│   │   │   │   │   ├── UserMapper.java
│   │   │   │   │   ├── AgentMapper.java
│   │   │   │   │   └── MessageMapper.java
│   │   │   │   ├── service/
│   │   │   │   │   ├── UserService.java
│   │   │   │   │   ├── AgentService.java
│   │   │   │   │   └── MessageService.java
│   │   │   │   └── socket/
│   │   │   │       └── ChatSocketHandler.java
│   │   │   └── resources/
│   │   │       ├── application.yml
│   │   │       └── mapper/
│   │   │           ├── UserMapper.xml
│   │   │           ├── AgentMapper.xml
│   │   │           └── MessageMapper.xml
│   ├── pom.xml                      # Maven 配置
│   └── sql/
│       └── init.sql                 # 数据库初始化

数据库设计

初始化脚本 (init.sql)

CREATE DATABASE IF NOT EXISTS chat_system;
USE chat_system;

-- 用户表
CREATE TABLE users (
    user_id VARCHAR(50) PRIMARY KEY,
    temp_id VARCHAR(50) NULL,
    status ENUM('UNASSIGNED', 'ASSIGNED') DEFAULT 'UNASSIGNED',
    assigned_agent_id VARCHAR(50) NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 客服表
CREATE TABLE agents (
    agent_id VARCHAR(50) PRIMARY KEY,
    agent_name VARCHAR(100) NOT NULL,
    password VARCHAR(100) NOT NULL, -- 存储加密密码
    status ENUM('ONLINE', 'OFFLINE') DEFAULT 'ONLINE',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 消息表
CREATE TABLE messages (
    message_id BIGINT AUTO_INCREMENT PRIMARY KEY,
    sender_id VARCHAR(50) NOT NULL,
    receiver_id VARCHAR(50) NOT NULL,
    content TEXT NOT NULL,
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 初始化测试客服账号(密码:123456,使用 BCrypt 加密)
INSERT INTO agents (agent_id, agent_name, password, status) 
VALUES ('agent_1', '客服小明', '$2a$10$3zHz6b1I2e9aJ8U6g9QzS.3y9X4f5z3X8e8f9g7h6j5k4l3m2n1', 'ONLINE');

说明

  • password 字段使用 BCrypt 加密,测试账号密码为 123456
  • 表结构支持用户状态、客服认证和消息存储。

后端实现 (Spring Boot 3 + MyBatis-Plus)

Maven 配置 (pom.xml)

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>chat-system</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>chat-system</name>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt</artifactId>
            <version>0.12.6</version>
        </dependency>
        <dependency>
            <groupId>com.github.xiaoymin</groupId>
            <artifactId>knife4j-openapi3-jakarta spring-boot-starter</artifactId>
            <version>4.0.0</version>
        </dependency>
        <dependency>
            <groupId>com.corundumstudio.socketio</groupId>
            <artifactId>netty-socketio</artifactId>
            <version>2.0.3</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
            <version>3.5.9</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

配置文件 (application.yml)

server:
  port: 8080
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/chat_system?useSSL=false&serverTimezone=UTC
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver
mybatis-plus:
  mapper-locations: classpath*:/mapper/*.xml
  type-aliases-package: com.example.chat.entity
socketio:
  host: localhost
  port: 9092
jwt:
  secret: your_jwt_secret_key
  expiration: 86400000

实体类

User.java
package com.example.chat.entity;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class User {
    private String userId;
    private String tempId;
    private String status;
    private String assignedAgentId;
    private LocalDateTime createdAt;
}
Agent.java
package com.example.chat.entity;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class Agent {
    private String agentId;
    private String agentName;
    private String password;
    private String status;
    private LocalDateTime createdAt;
}
Message.java
package com.example.chat.entity;

import lombok.Data;
import java.time.LocalDateTime;

@Data
public class Message {
    private Long messageId;
    private String senderId;
    private String receiverId;
    private String content;
    private LocalDateTime timestamp;
}

Mapper 接口

UserMapper.java
package com.example.chat.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.chat.entity.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<User> {
}
AgentMapper.java
package com.example.chat.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.chat.entity.Agent;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AgentMapper extends BaseMapper<Agent> {
}
MessageMapper.java
package com.example.chat.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.chat.entity.Message;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface MessageMapper extends BaseMapper<Message> {
}

Mapper XML

UserMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.mapper.UserMapper">
    <resultMap id="BaseResultMap" type="com.example.chat.entity.User">
        <id column="user_id" property="userId"/>
        <result column="temp_id" property="tempId"/>
        <result column="status" property="status"/>
        <result column="assigned_agent_id" property="assignedAgentId"/>
        <result column="created_at" property="createdAt"/>
    </resultMap>
</mapper>
AgentMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.mapper.AgentMapper">
    <resultMap id="BaseResultMap" type="com.example.chat.entity.Agent">
        <id column="agent_id" property="agentId"/>
        <result column="agent_name" property="agentName"/>
        <result column="password" property="password"/>
        <result column="status" property="status"/>
        <result column="created_at" property="createdAt"/>
    </resultMap>
</mapper>
MessageMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.chat.mapper.MessageMapper">
    <resultMap id="BaseResultMap" type="com.example.chat.entity.Message">
        <id column="message_id" property="messageId"/>
        <result column="sender_id" property="senderId"/>
        <result column="receiver_id" property="receiverId"/>
        <result column="content" property="content"/>
        <result column="timestamp" property="timestamp"/>
    </resultMap>
</mapper>

服务层

UserService.java
package com.example.chat.service;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.chat.entity.User;
import com.example.chat.mapper.UserMapper;
import org.springframework.stereotype.Service;

@Service
public class UserService extends ServiceImpl<UserMapper, User> {
}
AgentService.java
package com.example.chat.service;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.chat.entity.Agent;
import com.example.chat.mapper.AgentMapper;
import org.springframework.stereotype.Service;

@Service
public class AgentService extends ServiceImpl<AgentMapper, Agent> {
}
MessageService.java
package com.example.chat.service;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.chat.entity.Message;
import com.example.chat.mapper.MessageMapper;
import org.springframework.stereotype.Service;

@Service
public class MessageService extends ServiceImpl<MessageMapper, Message> {
}

配置类

SocketIOConfig.java
package com.example.chat.config;

import com.corundumstudio.socketio.SocketIOServer;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class SocketIOConfig {
    @Value("${socketio.host}")
    private String host;
    @Value("${socketio.port}")
    private int port;

    @Bean
    public SocketIOServer socketIOServer() {
        com.corundumstudio.socketio.Configuration config = new com.corundumstudio.socketio.Configuration();
        config.setHostname(host);
        config.setPort(port);
        return new SocketIOServer(config);
    }
}
SecurityConfig.java
package com.example.chat.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
            );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

控制器

AuthController.java
package com.example.chat.controller;

import com.example.chat.entity.Agent;
import com.example.chat.service.AgentService;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {
    @Autowired
    private AgentService agentService;
    @Autowired
    private PasswordEncoder passwordEncoder;
    @Value("${jwt.secret}")
    private String jwtSecret;
    @Value("${jwt.expiration}")
    private long jwtExpiration;

    @PostMapping("/login")
    public Map<String, String> login(@RequestBody Map<String, String> loginRequest) {
        String agentId = loginRequest.get("agentId");
        String password = loginRequest.get("password");
        Agent agent = agentService.getById(agentId);
        Map<String, String> response = new HashMap<>();

        if (agent != null && passwordEncoder.matches(password, agent.getPassword())) {
            String token = Jwts.builder()
                .setSubject(agentId)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + jwtExpiration))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
            response.put("token", token);
            response.put("agentId", agentId);
            response.put("agentName", agent.getAgentName());
        } else {
            response.put("error", "Invalid credentials");
        }
        return response;
    }
}
ChatController.java
package com.example.chat.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.chat.entity.Message;
import com.example.chat.entity.User;
import com.example.chat.service.MessageService;
import com.example.chat.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api")
public class ChatController {
    @Autowired
    private UserService userService;
    @Autowired
    private MessageService messageService;

    @GetMapping("/users/unassigned")
    public List<User> getUnassignedUsers() {
        return userService.lambdaQuery().eq(User::getStatus, "UNASSIGNED").list();
    }

    @GetMapping("/users/assigned")
    public List<User> getAssignedUsers(@RequestParam String agentId) {
        return userService.lambdaQuery().eq(User::getAssignedAgentId, agentId).list();
    }

    @GetMapping("/messages")
    public IPage<Message> getMessages(
        @RequestParam String userId,
        @RequestParam String agentId,
        @RequestParam(defaultValue = "1") int page,
        @RequestParam(defaultValue = "20") int size) {
        return messageService.lambdaQuery()
            .eq(Message::getSenderId, userId).or()
            .eq(Message::getReceiverId, userId)
            .eq(Message::getSenderId, agentId).or()
            .eq(Message::getReceiverId, agentId)
            .orderByDesc(Message::getTimestamp)
            .page(new Page<>(page, size));
    }
}

SocketIO 处理

ChatSocketHandler.java
package com.example.chat.socket;

import com.corundumstudio.socketio.SocketIOServer;
import com.corundumstudio.socketio.annotation.OnConnect;
import com.corundumstudio.socketio.annotation.OnDisconnect;
import com.corundumstudio.socketio.annotation.OnEvent;
import com.example.chat.entity.Message;
import com.example.chat.entity.User;
import com.example.chat.service.MessageService;
import com.example.chat.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;
import java.util.UUID;

@Component
public class ChatSocketHandler {
    @Autowired
    private SocketIOServer socketIOServer;
    @Autowired
    private UserService userService;
    @Autowired
    private MessageService messageService;

    @PostConstruct
    public void start() {
        socketIOServer.start();
    }

    @PreDestroy
    public void stop() {
        socketIOServer.stop();
    }

    @OnConnect
    public void onConnect(com.corundumstudio.socketio.SocketIOClient client) {
        String userId = client.getHandshakeData().getSingleUrlParam("userId");
        if (userId == null || userId.isEmpty()) {
            userId = UUID.randomUUID().toString();
            User user = new User();
            user.setUserId(userId);
            user.setTempId(userId);
            user.setStatus("UNASSIGNED");
            userService.save(user);
        }
        client.joinRoom(userId);
    }

    @OnDisconnect
    public void onDisconnect(com.corundumstudio.socketio.SocketIOClient client) {
        // Handle disconnect
    }

    @OnEvent("send_message")
    public void onSendMessage(com.corundumstudio.socketio.SocketIOClient client, Message message) {
        messageService.save(message);
        if (message.getReceiverId().startsWith("agent_")) {
            socketIOServer.getRoomOperations(message.getReceiverId()).sendEvent("receive_message", message);
        } else {
            socketIOServer.getBroadcastOperations().sendEvent("receive_message", message);
        }
    }

    @OnEvent("assign_user")
    public void onAssignUser(com.corundumstudio.socketio.SocketIOClient client, String userId, String agentId, String agentName) {
        User user = userService.getById(userId);
        user.setStatus("ASSIGNED");
        user.setAssignedAgentId(agentId);
        userService.updateById(user);
        socketIOServer.getRoomOperations(userId).sendEvent("service_notification", "客服 " + agentName + " 为您服务");
        socketIOServer.getBroadcastOperations().sendEvent("user_assigned", userId);
    }
}

用户端实现 (Vue3 + TypeScript + AntDesignVue + vue-virtual-scroller)

package.json

{
  "name": "chat-client",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.2.47",
    "ant-design-vue": "^3.2.15",
    "socket.io-client": "^4.5.4",
    "vue-router": "^4.1.6",
    "vue-virtual-scroller": "^2.0.0-alpha.1"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.0.0",
    "typescript": "^4.9.5",
    "vite": "^4.1.1"
  }
}

聊天窗口 (ChatWindow.vue)

<template>
  <div class="chat-window">
    <a-card title="在线客服">
      <RecycleScroller
        class="message-list"
        :items="messages"
        :item-size="40"
        key-field="messageId"
        v-slot="{ item }"
      >
        <div :class="item.senderId === userId ? 'message-right' : 'message-left'">
          {{ item.content }}
        </div>
      </RecycleScroller>
      <a-input v-model:value="inputMessage" placeholder="请输入消息" @keyup.enter="sendMessage"/>
    </a-card>
    <a-alert v-if="serviceNotification" :message="serviceNotification" type="info" show-icon />
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { io } from 'socket.io-client';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import type { Message } from '../types';

const props = defineProps<{ userId: string }>();
const messages = ref<Message[]>([]);
const inputMessage = ref('');
const serviceNotification = ref('');
const socket = io('http://localhost:9092', { query: { userId: props.userId } });

onMounted(() => {
  socket.on('receive_message', (message: Message) => {
    messages.value.push(message);
  });
  socket.on('service_notification', (notification: string) => {
    serviceNotification.value = notification;
  });
});

const sendMessage = () => {
  if (inputMessage.value.trim()) {
    const message: Message = {
      senderId: props.userId,
      receiverId: 'agent_all',
      content: inputMessage.value,
      timestamp: new Date().toISOString()
    };
    socket.emit('send_message', message);
    messages.value.push(message);
    inputMessage.value = '';
  }
};
</script>

<style scoped>
.chat-window {
  width: 300px;
  height: 400px;
  display: flex;
  flex-direction: column;
}
.message-list {
  flex: 1;
  overflow-y: auto;
  padding: 10px;
}
.message-right {
  text-align: right;
  margin: 5px;
}
.message-left {
  text-align: left;
  margin: 5px;
}
</style>

主组件 (App.vue)

<template>
  <div>
    <ChatWindow :userId="userId" />
  </div>
</template>

<script lang="ts" setup>
import ChatWindow from './components/ChatWindow.vue';
import { ref } from 'vue';

const userId = ref(new URLSearchParams(window.location.search).get('userId') || 'user_' + Math.random().toString(36).substr(2, 9));
</script>

类型定义 (types/index.ts)

export interface Message {
  messageId?: number;
  senderId: string;
  receiverId: string;
  content: string;
  timestamp: string;
}

Vite 配置 (vite.config.ts)

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000
  }
});

客服端实现 (Vue3 + TypeScript + AntDesignVue + vue-virtual-scroller)

package.json

{
  "name": "chat-agent",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.2.47",
    "ant-design-vue": "^3.2.15",
    "socket.io-client": "^4.5.4",
    "vue-router": "^4.1.6",
    "axios": "^1.3.4",
    "vue-virtual-scroller": "^2.0.0-alpha.1"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.0.0",
    "typescript": "^4.9.5",
    "vite": "^4.1.1"
  }
}

登录页面 (Login.vue)

<template>
  <a-card title="客服登录">
    <a-form :model="form" @finish="handleLogin">
      <a-form-item label="客服ID">
        <a-input v-model:value="form.agentId" placeholder="请输入客服ID"/>
      </a-form-item>
      <a-form-item label="密码">
        <a-input-password v-model:value="form.password" placeholder="请输入密码"/>
      </a-form-item>
      <a-form-item>
        <a-button type="primary" html-type="submit">登录</a-button>
      </a-form-item>
    </a-form>
    <a-alert v-if="error" :message="error" type="error" show-icon />
  </a-card>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import axios from 'axios';

const router = useRouter();
const form = ref({ agentId: '', password: '' });
const error = ref('');

const handleLogin = async () => {
  try {
    const response = await axios.post('http://localhost:8080/api/auth/login', form.value);
    if (response.data.token) {
      localStorage.setItem('token', response.data.token);
      localStorage.setItem('agentId', response.data.agentId);
      localStorage.setItem('agentName', response.data.agentName);
      router.push('/chat');
    } else {
      error.value = response.data.error;
    }
  } catch (err) {
    error.value = '登录失败,请检查网络或凭据';
  }
};
</script>

<style scoped>
.card {
  width: 400px;
  margin: 50px auto;
}
</style>

用户列表 (UserList.vue)

<template>
  <a-list :data-source="users" bordered>
    <template #renderItem="{ item }">
      <a-list-item @click="selectUser(item)">
        <div>{{ item.userId }} ({{ item.status }})</div>
      </a-list-item>
    </template>
  </a-list>
</template>

<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { io } from 'socket.io-client';
import axios from 'axios';
import type { User } from '../types';

const props = defineProps<{ agentId: string; agentName: string }>();
const users = ref<User[]>([]);
const socket = io('http://localhost:9092', { query: { userId: props.agentId } });
const emit = defineEmits(['select-user']);

const fetchUsers = async () => {
  try {
    const config = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
    const unassigned = await axios.get('http://localhost:8080/api/users/unassigned', config);
    const assigned = await axios.get(`http://localhost:8080/api/users/assigned?agentId=${props.agentId}`, config);
    users.value = [...unassigned.data, ...assigned.data];
  } catch (err) {
    console.error('Failed to fetch users:', err);
  }
};

onMounted(() => {
  fetchUsers();
  socket.on('receive_message', () => fetchUsers());
  socket.on('user_assigned', () => fetchUsers());
});

const selectUser = (user: User) => {
  if (user.status === 'UNASSIGNED') {
    socket.emit('assign_user', user.userId, props.agentId, props.agentName);
  }
  emit('select-user', user);
};
</script>

聊天面板 (ChatPanel.vue)

<template>
  <a-card v-if="selectedUser" title="与 {{ selectedUser.userId }} 的聊天">
    <RecycleScroller
      class="message-list"
      :items="messages"
      :item-size="40"
      key-field="messageId"
      v-slot="{ item }"
    >
      <div :class="item.senderId === agentId ? 'message-right' : 'message-left'">
        {{ item.content }}
      </div>
    </RecycleScroller>
    <a-input v-model:value="inputMessage" placeholder="请输入消息" @keyup.enter="sendMessage"/>
  </a-card>
</template>

<script lang="ts" setup>
import { ref, watch, onMounted } from 'vue';
import { io } from 'socket.io-client';
import axios from 'axios';
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import type { Message, User } from '../types';

const props = defineProps<{ agentId: string; selectedUser: User | null }>();
const messages = ref<Message[]>([]);
const inputMessage = ref('');
const socket = io('http://localhost:9092', { query: { userId: props.agentId } });

const fetchMessages = async (userId: string) => {
  try {
    const config = { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } };
    const response = await axios.get(`http://localhost:8080/api/messages?userId=${userId}&agentId=${props.agentId}`, config);
    messages.value = response.data.records;
  } catch (err) {
    console.error('Failed to fetch messages:', err);
  }
};

watch(() => props.selectedUser, (newUser) => {
  if (newUser) {
    socket.emit('join_room', newUser.userId);
    fetchMessages(newUser.userId);
  }
});

onMounted(() => {
  socket.on('receive_message', (message: Message) => {
    if (props.selectedUser && (message.senderId === props.selectedUser.userId || message.receiverId === props.selectedUser.userId)) {
      messages.value.push(message);
    }
  });
});

const sendMessage = () => {
  if (inputMessage.value.trim() && props.selectedUser) {
    const message: Message = {
      senderId: props.agentId,
      receiverId: props.selectedUser.userId,
      content: inputMessage.value,
      timestamp: new Date().toISOString()
    };
    socket.emit('send_message', message);
    messages.value.push(message);
    inputMessage.value = '';
  }
};
</script>

<style scoped>
.message-list {
  height: 300px;
  overflow-y: auto;
  padding: 10px;
}
.message-right {
  text-align: right;
  margin: 5px;
}
.message-left {
  text-align: left;
  margin: 5px;
}
</style>

主组件 (App.vue)

<template>
  <router-view />
</template>

<script lang="ts" setup>
import { provide, ref } from 'vue';
import type { User } from './types';

const agentId = ref(localStorage.getItem('agentId') || '');
const agentName = ref(localStorage.getItem('agentName') || '');
const selectedUser = ref<User | null>(null);

provide('agentId', agentId);
provide('agentName', agentName);
provide('selectedUser', selectedUser);
</script>

路由配置 (router/index.ts)

import { createRouter, createWebHistory } from 'vue-router';
import Login from '../components/Login.vue';
import Chat from '../components/Chat.vue';

const routes = [
  { path: '/', redirect: '/login' },
  { path: '/login', component: Login },
  { path: '/chat', component: Chat }
];

const router = createRouter({
  history: createWebHistory(),
  routes
});

router.beforeEach((to, from, next) => {
  if (to.path !== '/login' && !localStorage.getItem('token')) {
    next('/login');
  } else {
    next();
  }
});

export default router;

聊天页面 (Chat.vue)

<template>
  <a-layout>
    <a-layout-sider>
      <UserList :agentId="agentId" :agentName="agentName" @select-user="selectUser"/>
    </a-layout-sider>
    <a-layout-content>
      <ChatPanel :agentId="agentId" :selectedUser="selectedUser"/>
    </a-layout-content>
  </a-layout>
</template>

<script lang="ts" setup>
import UserList from './UserList.vue';
import ChatPanel from './ChatPanel.vue';
import { inject } from 'vue';
import type { User } from '../types';

const agentId = inject('agentId') as string;
const agentName = inject('agentName') as string;
const selectedUser = inject('selectedUser') as Ref<User | null>;

const selectUser = (user: User) => {
  selectedUser.value = user;
};
</script>

类型定义 (types/index.ts)

export interface User {
  userId: string;
  tempId?: string;
  status: 'UNASSIGNED' | 'ASSIGNED';
  assignedAgentId?: string;
  createdAt: string;
}

export interface Message {
  messageId?: number;
  senderId: string;
  receiverId: string;
  content: string;
  timestamp: string;
}

Vite 配置 (vite.config.ts)

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3001
  }
});

部署与运行

环境准备

  1. MySQL
    • 安装 MySQL 8.0+。
    • 执行 init.sql 创建数据库和表。
  2. Node.js:安装 Node.js 16+。
  3. Maven:安装 Maven 3.8+。

运行步骤

  1. 后端
    • 进入 server 目录,更新 application.yml 中的 your_passwordjwt.secret
    • 运行 mvn spring-boot:run(端口 8080)。
  2. 用户端
    • 进入 client 目录,运行 npm install && npm run dev(端口 3000)。
    • 访问 http://localhost:3000?userId=test_user 测试。
  3. 客服端
    • 进入 agent 目录,运行 npm install && npm run dev(端口 3001)。
    • 访问 http://localhost:3001,使用 agentId: agent_1, password: 123456 登录。

测试流程

  1. 用户端发送消息,所有客服收到通知,未分配用户出现在列表中。
  2. 客服登录后,选择用户,分配后其他客服列表更新。
  3. 用户收到“客服 xxx 为您服务”提示,消息通过虚拟列表渲染。
  4. 历史消息通过分页加载(每页 20 条)。

注意事项

  • 性能优化:虚拟列表(vue-virtual-scroller)确保大量消息场景下流畅渲染。
  • 安全性:客服端 API 请求需携带 JWT token,SocketIO 连接无需认证(可扩展)。
  • 扩展性:可添加消息搜索、多客服组等功能。