在线客服系统开发
概述
本项目是一个基于 Web 的在线客服系统,包含用户端和客服端,支持实时聊天、用户分配、虚拟列表、分页加载历史消息以及客服登录认证。用户端无需登录,可集成到 Web/H5 页面;客服端类似于微信界面,左侧为用户列表,右侧为聊天面板,仅显示未分配或已分配给当前客服的用户。系统使用 Vue3 + TypeScript + AntDesignVue + SocketIO 构建前端,Spring Boot 3 + MyBatis-Plus + MySQL 构建后端,不使用 Redis。
需求分析
功能需求
- 用户端:
- 无需登录,集成到 Web/H5 页面。
- 根据集成项目的
UserId
区分用户,无UserId
时生成临时 ID。 - 提供简单聊天界面,支持发送消息、接收客服回复,并显示“客服 xxx 为您服务”提示。
- 支持虚拟列表,优化大量消息的渲染性能。
- 客服端:
- 类似于微信界面,左侧用户列表,右侧聊天面板。
- 用户列表仅显示“未分配”或“已分配给当前客服”的用户。
- 用户发送消息时,所有客服收到;某客服回复后,该用户从其他客服列表移除。
- 支持用户状态(未分配/已分配)。
- 支持虚拟列表和分页加载历史消息。
- 需登录认证,验证客服身份。
- 后端:
- 提供用户管理、消息存储、状态分配等功能。
- 支持分页查询历史消息。
- 使用 SocketIO 实现实时消息传递。
- 提供 JWT 认证接口,确保客服端安全访问。
- 数据库:
- 使用 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
}
});
部署与运行
环境准备
- MySQL:
- 安装 MySQL 8.0+。
- 执行
init.sql
创建数据库和表。
- Node.js:安装 Node.js 16+。
- Maven:安装 Maven 3.8+。
运行步骤
- 后端:
- 进入
server
目录,更新application.yml
中的your_password
和jwt.secret
。 - 运行
mvn spring-boot:run
(端口 8080)。
- 进入
- 用户端:
- 进入
client
目录,运行npm install && npm run dev
(端口 3000)。 - 访问
http://localhost:3000?userId=test_user
测试。
- 进入
- 客服端:
- 进入
agent
目录,运行npm install && npm run dev
(端口 3001)。 - 访问
http://localhost:3001
,使用agentId: agent_1
,password: 123456
登录。
- 进入
测试流程
- 用户端发送消息,所有客服收到通知,未分配用户出现在列表中。
- 客服登录后,选择用户,分配后其他客服列表更新。
- 用户收到“客服 xxx 为您服务”提示,消息通过虚拟列表渲染。
- 历史消息通过分页加载(每页 20 条)。
注意事项
- 性能优化:虚拟列表(
vue-virtual-scroller
)确保大量消息场景下流畅渲染。 - 安全性:客服端 API 请求需携带 JWT token,SocketIO 连接无需认证(可扩展)。
- 扩展性:可添加消息搜索、多客服组等功能。