迁移shadcn ui前版本

This commit is contained in:
537yaha
2025-11-17 18:05:00 +08:00
parent 397c324ac7
commit 5640fe7ae1
73 changed files with 10888 additions and 123 deletions
+209
View File
@@ -0,0 +1,209 @@
# AI-CS 智能客服系统
## 项目简介
这是一个基于Go后端和Next.js前端的智能客服系统,用于处理访客与客服之间的对话交流。
## 项目结构
```
AI-CS/
├── backend/ # Go后端服务
│ ├── controller/ # 控制器层
│ ├── models/ # 数据模型
│ ├── service/ # 业务逻辑层
│ ├── repository/ # 数据访问层
│ ├── middleware/ # 中间件
│ ├── router/ # 路由配置
│ ├── infra/ # 基础设施(数据库等)
│ └── utils/ # 工具函数
└── frontend/ # Next.js前端应用
├── app/ # 应用页面
├── public/ # 静态资源
└── ...
```
## 核心功能
### 1. 用户管理
- **用户注册** (`Register`): 创建新用户账户
- **用户登录** (`Login`): 验证用户身份
### 2. 对话管理
- **初始化对话** (`InitConversation`): 为访客创建或获取现有对话
- **发送消息**: 处理消息发送
- **拉取消息**: 获取对话历史
## 数据模型
### User (用户)
```go
type User struct {
ID uint `json:"id" gorm:"primarykey"`
Username string `json:"username" gorm:"unique"`
Password string `json:"password"`
Role string `json:"role"`
}
```
### Conversation (对话)
```go
type Conversation struct {
ID uint `json:"id" gorm:"primaryKey"`
VisitorID uint `json:"visiter_id"`
AgentID uint `json:"agent_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
```
### Message (消息)
```go
type Message struct {
ID uint `json:"id" gorm:"primarykey"`
ConversationID uint `json:"conversation_id"`
SenderID uint `json:"sender_id"`
SenderIsAgent bool `json:"sender_is_agent"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
```
## API接口说明
### 用户相关接口
#### 用户注册
- **路径**: `POST /register`
- **参数**:
```json
{
"username": "用户名",
"password": "密码",
"role": "角色"
}
```
- **返回**: 注册成功或失败信息
#### 用户登录
- **路径**: `POST /login`
- **参数**:
```json
{
"username": "用户名",
"password": "密码"
}
```
- **返回**: 登录成功或失败信息
### 对话相关接口
#### 初始化对话
- **路径**: `POST /conversation/init`
- **参数**:
```json
{
"visitor_id": 访客ID
}
```
- **返回**:
```json
{
"conversation_id": 对话ID,
"status": "对话状态"
}
```
#### 发送消息
- **路径**: `POST /messages`
- **参数**:
```json
{
"conversation_id": 对话ID,
"content": "消息内容",
"sender_is_agent": 是否客服,
"sender_id": 发送者ID(客服必填,访客可省略或传0)
}
```
- **返回**: { "message": "创建消息成功" }
#### 拉取消息
- **路径**: `GET /messages?conversation_id=对话ID`
- **返回**: 消息数组,按创建时间升序
## 对话初始化逻辑详解
当你调用对话初始化接口时,系统会执行以下步骤:
1. **检查现有对话**: 系统会查找该访客是否已有未关闭的对话
2. **复用或创建**:
- 如果找到现有对话,直接返回该对话信息
- 如果没有找到,创建一个新的对话并返回
这就像你去银行办事:
- 如果之前有没办完的业务,继续办理
- 如果没有,开一个新的业务单
## 技术栈
### 后端
- **语言**: Go
- **框架**: Gin (Web框架)
- **数据库**: GORM (ORM)
- **密码加密**: bcrypt
### 前端
- **框架**: Next.js
- **语言**: TypeScript
- **样式**: CSS
## 开发环境设置
### 后端启动
```bash
cd backend
cp .env.example .env # 按需修改数据库配置
go mod tidy
go run main.go
```
### 前端启动
```bash
cd frontend
cp .env.local.example .env.local # 可选:如需自定义 API 地址
npm install
npm run dev
```
## 注意事项
1. 确保数据库连接配置正确(后端从环境变量读取)
2. 用户密码会自动加密存储
3. 对话状态包括: "open"(开放), "closed"(关闭)
4. 消息发送者通过 `SenderIsAgent` 字段区分是访客还是客服
### 后端环境变量
在 `backend/.env` 中配置以下变量(主进程会自动加载):
```
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=CS
```
### 前端环境变量(可选)
在 `frontend/.env.local` 中配置以下变量(不配置则使用默认值):
```
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
```
说明:本地开发无需配置,已默认 `http://127.0.0.1:8080`。部署到生产环境时修改为实际后端地址(如 `https://api.yourdomain.com`)。
## 更新日志
详见 `doc/CHANGELOG.md` 文件
+5
View File
@@ -0,0 +1,5 @@
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=hkbjujk%h2eT
DB_NAME=CS
+55
View File
@@ -0,0 +1,55 @@
package controller
import (
"net/http"
"github.com/2930134478/AI-CS/backend/service"
"github.com/gin-gonic/gin"
)
// AdminController 负责处理管理员相关的 HTTP 请求。
type AdminController struct {
authService *service.AuthService
}
// NewAdminController 创建 AdminController 实例。
func NewAdminController(authService *service.AuthService) *AdminController {
return &AdminController{authService: authService}
}
type createAgentRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Role string `json:"role"`
}
// CreateAgent 处理创建客服或管理员账号的请求。
func (a *AdminController) CreateAgent(c *gin.Context) {
var req createAgentRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := a.authService.CreateAgent(service.CreateAgentInput{
Username: req.Username,
Password: req.Password,
Role: req.Role,
})
if err != nil {
switch err {
case service.ErrUsernameExists:
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{
"message": "创建成功",
"user_id": user.ID,
"username": user.Username,
"role": user.Role,
})
}
+55
View File
@@ -0,0 +1,55 @@
package controller
import (
"net/http"
"github.com/2930134478/AI-CS/backend/service"
"github.com/gin-gonic/gin"
)
// AuthController 负责处理认证相关的 HTTP 请求。
type AuthController struct {
authService *service.AuthService
}
// NewAuthController 创建 AuthController 实例。
func NewAuthController(authService *service.AuthService) *AuthController {
return &AuthController{authService: authService}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login 处理登录请求。
func (a *AuthController) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
return
}
user, err := a.authService.Login(req.Username, req.Password)
if err != nil {
switch err {
case service.ErrInvalidCredentials:
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "登录失败"})
}
return
}
c.JSON(http.StatusOK, gin.H{
"message": "登录成功",
"user_id": user.ID,
"username": user.Username,
"role": user.Role,
})
}
// Logout 响应退出登录请求。
func (a *AuthController) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "退出成功"})
}
@@ -0,0 +1,261 @@
package controller
import (
"net/http"
"github.com/2930134478/AI-CS/backend/service"
"github.com/2930134478/AI-CS/backend/utils"
"github.com/gin-gonic/gin"
)
// ConversationController 负责处理会话相关的 HTTP 请求。
type ConversationController struct {
conversationService *service.ConversationService
}
// NewConversationController 创建 ConversationController 实例。
func NewConversationController(conversationService *service.ConversationService) *ConversationController {
return &ConversationController{conversationService: conversationService}
}
type initConversationRequest struct {
VisitorID uint `json:"visitor_id"`
Website string `json:"website"`
Referrer string `json:"referrer"`
Browser string `json:"browser"`
OS string `json:"os"`
Language string `json:"language"`
}
type updateContactRequest struct {
Email *string `json:"email"`
Phone *string `json:"phone"`
Notes *string `json:"notes"`
}
// InitConversation 为访客初始化或恢复会话。
func (cc *ConversationController) InitConversation(c *gin.Context) {
var req initConversationRequest
if err := c.ShouldBindJSON(&req); err != nil || req.VisitorID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
return
}
browser := req.Browser
os := req.OS
if browser == "" || os == "" {
parsedBrowser, parsedOS := utils.ParseUserAgent(c.GetHeader("User-Agent"))
if browser == "" {
browser = parsedBrowser
}
if os == "" {
os = parsedOS
}
}
result, err := cc.conversationService.InitConversation(service.InitConversationInput{
VisitorID: req.VisitorID,
Website: req.Website,
Referrer: req.Referrer,
Browser: browser,
OS: os,
Language: req.Language,
IPAddress: utils.GetClientIP(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建对话失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"conversation_id": result.ConversationID,
"status": result.Status,
})
}
// UpdateContactInfo 用于更新访客的联系信息。
func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
id, err := parseUintParam(c, "id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
return
}
var req updateContactRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
return
}
if req.Email == nil && req.Phone == nil && req.Notes == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "至少提供一个需要更新的字段"})
return
}
result, err := cc.conversationService.UpdateConversationContact(service.UpdateConversationContactInput{
ConversationID: uint(id),
Email: req.Email,
Phone: req.Phone,
Notes: req.Notes,
})
if err != nil {
if err == service.ErrConversationNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
}
return
}
c.JSON(http.StatusOK, gin.H{
"email": result.Email,
"phone": result.Phone,
"notes": result.Notes,
})
}
// ListConversations 返回当前活跃会话的列表。
func (cc *ConversationController) ListConversations(c *gin.Context) {
conversations, err := cc.conversationService.ListConversations()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"})
return
}
items := make([]gin.H, 0, len(conversations))
for _, conv := range conversations {
item := gin.H{
"id": conv.ID,
"visitor_id": conv.VisitorID,
"agent_id": conv.AgentID,
"status": conv.Status,
"created_at": formatTimeValue(conv.CreatedAt),
"updated_at": formatTimeValue(conv.UpdatedAt),
"unread_count": conv.UnreadCount,
}
// 添加 last_seen_at 字段(用于判断在线状态)
if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" {
item["last_seen_at"] = lastSeen
}
if conv.LastMessage != nil {
item["last_message"] = gin.H{
"id": conv.LastMessage.ID,
"content": conv.LastMessage.Content,
"sender_is_agent": conv.LastMessage.SenderIsAgent,
"message_type": conv.LastMessage.MessageType,
"is_read": conv.LastMessage.IsRead,
"read_at": formatTimePointer(conv.LastMessage.ReadAt),
"created_at": formatTimeValue(conv.LastMessage.CreatedAt),
}
}
items = append(items, item)
}
c.JSON(http.StatusOK, items)
}
// GetConversationDetail 返回会话的详细信息。
func (cc *ConversationController) GetConversationDetail(c *gin.Context) {
id, err := parseUintParam(c, "id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
return
}
detail, err := cc.conversationService.GetConversationDetail(uint(id))
if err != nil {
if err == service.ErrConversationNotFound {
c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
} else {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
}
return
}
response := gin.H{
"id": detail.ID,
"visitor_id": detail.VisitorID,
"agent_id": detail.AgentID,
"status": detail.Status,
"website": detail.Website,
"referrer": detail.Referrer,
"browser": detail.Browser,
"os": detail.OS,
"language": detail.Language,
"ip_address": detail.IPAddress,
"location": detail.Location,
"email": detail.Email,
"phone": detail.Phone,
"notes": detail.Notes,
"created_at": formatTimeValue(detail.CreatedAt),
"updated_at": formatTimeValue(detail.UpdatedAt),
"unread_count": detail.UnreadCount,
}
if lastSeen := formatTimePointer(detail.LastSeen); lastSeen != "" {
response["last_seen_at"] = lastSeen
}
if detail.LastMessage != nil {
response["last_message"] = gin.H{
"id": detail.LastMessage.ID,
"content": detail.LastMessage.Content,
"sender_is_agent": detail.LastMessage.SenderIsAgent,
"message_type": detail.LastMessage.MessageType,
"is_read": detail.LastMessage.IsRead,
"read_at": formatTimePointer(detail.LastMessage.ReadAt),
"created_at": formatTimeValue(detail.LastMessage.CreatedAt),
}
}
c.JSON(http.StatusOK, response)
}
// SearchConversations 根据关键字进行会话的模糊搜索。
func (cc *ConversationController) SearchConversations(c *gin.Context) {
query := c.Query("q")
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
return
}
conversations, err := cc.conversationService.SearchConversations(query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
return
}
items := make([]gin.H, 0, len(conversations))
for _, conv := range conversations {
item := gin.H{
"id": conv.ID,
"visitor_id": conv.VisitorID,
"agent_id": conv.AgentID,
"status": conv.Status,
"created_at": formatTimeValue(conv.CreatedAt),
"updated_at": formatTimeValue(conv.UpdatedAt),
"unread_count": conv.UnreadCount,
}
// 添加 last_seen_at 字段(用于判断在线状态)
if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" {
item["last_seen_at"] = lastSeen
}
if conv.LastMessage != nil {
item["last_message"] = gin.H{
"id": conv.LastMessage.ID,
"content": conv.LastMessage.Content,
"sender_is_agent": conv.LastMessage.SenderIsAgent,
"message_type": conv.LastMessage.MessageType,
"is_read": conv.LastMessage.IsRead,
"read_at": formatTimePointer(conv.LastMessage.ReadAt),
"created_at": formatTimeValue(conv.LastMessage.CreatedAt),
}
}
items = append(items, item)
}
c.JSON(http.StatusOK, items)
}
+29
View File
@@ -0,0 +1,29 @@
package controller
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
const timeFormat = "2006-01-02T15:04:05Z07:00"
// parseUintParam 将路径参数转换为 uint64。
func parseUintParam(c *gin.Context, name string) (uint64, error) {
value := c.Param(name)
return strconv.ParseUint(value, 10, 64)
}
// formatTimeValue 按统一格式输出时间字符串。
func formatTimeValue(t time.Time) string {
return t.Format(timeFormat)
}
// formatTimePointer 在指针为空时返回空字符串。
func formatTimePointer(t *time.Time) string {
if t == nil {
return ""
}
return t.Format(timeFormat)
}
+108
View File
@@ -0,0 +1,108 @@
package controller
import (
"log"
"net/http"
"strconv"
"github.com/2930134478/AI-CS/backend/service"
"github.com/gin-gonic/gin"
)
// MessageController 负责处理消息相关的 HTTP 请求。
type MessageController struct {
messageService *service.MessageService
}
// NewMessageController 创建 MessageController 实例。
func NewMessageController(messageService *service.MessageService) *MessageController {
return &MessageController{messageService: messageService}
}
type createMessageRequest struct {
ConversationID uint `json:"conversation_id"`
Content string `json:"content"`
SenderIsAgent bool `json:"sender_is_agent"`
SenderID uint `json:"sender_id"`
}
// CreateMessage 处理发送消息的请求。
func (mc *MessageController) CreateMessage(c *gin.Context) {
var req createMessageRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ConversationID == 0 || req.Content == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
return
}
_, err := mc.messageService.CreateMessage(service.CreateMessageInput{
ConversationID: req.ConversationID,
Content: req.Content,
SenderID: req.SenderID,
SenderIsAgent: req.SenderIsAgent,
})
if err != nil {
log.Printf("❌ 创建消息失败: 对话ID=%d, 错误=%v", req.ConversationID, err)
switch err {
case service.ErrConversationClosed:
c.JSON(http.StatusBadRequest, gin.H{"error": "会话已关闭"})
case service.ErrConversationNotFound:
c.JSON(http.StatusBadRequest, gin.H{"error": "会话不存在"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建消息失败"})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "创建消息成功"})
}
// ListMessages 返回指定会话的消息列表。
func (mc *MessageController) ListMessages(c *gin.Context) {
conversationIDStr := c.Query("conversation_id")
if conversationIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不能为空"})
return
}
conversationID, err := strconv.ParseUint(conversationIDStr, 10, 64)
if err != nil || conversationID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
return
}
messages, err := mc.messageService.ListMessages(uint(conversationID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败"})
return
}
c.JSON(http.StatusOK, messages)
}
type markMessagesReadRequest struct {
ConversationID uint `json:"conversation_id"`
ReaderIsAgent bool `json:"reader_is_agent"`
}
// MarkMessagesRead 将指定会话的消息标记为已读。
func (mc *MessageController) MarkMessagesRead(c *gin.Context) {
var req markMessagesReadRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ConversationID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
return
}
result, err := mc.messageService.MarkMessagesRead(req.ConversationID, req.ReaderIsAgent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新消息状态失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"updated": len(result.MessageIDs),
"message_ids": result.MessageIDs,
"conversation_id": result.ConversationID,
"unread_count": result.UnreadCount,
"read_at": formatTimeValue(result.ReadAt),
})
}
+122
View File
@@ -0,0 +1,122 @@
package controller
import (
"net/http"
"github.com/2930134478/AI-CS/backend/service"
"github.com/gin-gonic/gin"
)
// ProfileController 负责处理个人资料相关的 HTTP 请求。
type ProfileController struct {
profileService *service.ProfileService
}
// NewProfileController 创建 ProfileController 实例。
func NewProfileController(profileService *service.ProfileService) *ProfileController {
return &ProfileController{profileService: profileService}
}
type updateProfileRequest struct {
Nickname *string `json:"nickname"`
Email *string `json:"email"`
}
// GetProfile 获取当前用户的个人资料。
func (p *ProfileController) GetProfile(c *gin.Context) {
// 从路径参数获取用户ID(后续可以改为从JWT token获取)
userID, err := parseUintParam(c, "user_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
return
}
profile, err := p.profileService.GetProfile(uint(userID))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, profile)
}
// UpdateProfile 更新当前用户的个人资料。
func (p *ProfileController) UpdateProfile(c *gin.Context) {
// 从路径参数获取用户ID(后续可以改为从JWT token获取)
userID, err := parseUintParam(c, "user_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
return
}
var req updateProfileRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
return
}
profile, err := p.profileService.UpdateProfile(service.UpdateProfileInput{
UserID: uint(userID),
Nickname: req.Nickname,
Email: req.Email,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, profile)
}
// UploadAvatar 上传用户头像。
func (p *ProfileController) UploadAvatar(c *gin.Context) {
// 从路径参数获取用户ID(后续可以改为从JWT token获取)
userID, err := parseUintParam(c, "user_id")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
return
}
// 获取上传的文件
file, err := c.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择头像文件"})
return
}
// 验证文件类型(只允许图片)
allowedTypes := map[string]bool{
"image/jpeg": true,
"image/jpg": true,
"image/png": true,
"image/gif": true,
}
if !allowedTypes[file.Header.Get("Content-Type")] {
c.JSON(http.StatusBadRequest, gin.H{"error": "只支持上传图片文件(jpg、png、gif"})
return
}
// 验证文件大小(限制10MB
if file.Size > 10*1024*1024 {
c.JSON(http.StatusBadRequest, gin.H{"error": "头像文件大小不能超过10MB"})
return
}
// 打开文件
src, err := file.Open()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "打开文件失败"})
return
}
defer src.Close()
// 上传头像
profile, err := p.profileService.UploadAvatar(uint(userID), src, file.Filename)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, profile)
}
+15 -3
View File
@@ -3,20 +3,33 @@ module github.com/2930134478/AI-CS/backend
go 1.24.1
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.10.1
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.42.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.10.1 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
@@ -25,7 +38,6 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.21.0 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.29.0 // indirect
+35 -7
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
@@ -6,31 +8,49 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -42,15 +62,19 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -69,8 +93,12 @@ golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+28
View File
@@ -0,0 +1,28 @@
package infra
import (
"fmt"
"os"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// 初始化数据库连接(从环境变量读取配置)
// 需要的环境变量:
// DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
func NewDB() (*gorm.DB, error) {
host := os.Getenv("DB_HOST")
port := os.Getenv("DB_PORT")
user := os.Getenv("DB_USER")
password := os.Getenv("DB_PASSWORD")
name := os.Getenv("DB_NAME")
// 最小校验,避免使用硬编码默认值
if host == "" || port == "" || user == "" || name == "" {
return nil, fmt.Errorf("database env not set: require DB_HOST, DB_PORT, DB_USER, DB_NAME")
}
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&loc=Local", user, password, host, port, name)
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
+121
View File
@@ -0,0 +1,121 @@
package infra
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
)
// StorageService 文件存储服务接口(可扩展为云存储)
type StorageService interface {
// SaveAvatar 保存头像文件,返回文件URL
SaveAvatar(userID uint, file io.Reader, filename string) (string, error)
// DeleteFile 删除文件
DeleteFile(fileURL string) error
// GetFileURL 获取文件的完整URL
GetFileURL(filePath string) string
}
// LocalStorageService 本地文件存储服务
type LocalStorageService struct {
baseDir string // 基础目录
publicPath string // 公共访问路径
}
// NewLocalStorageService 创建本地存储服务实例
func NewLocalStorageService(baseDir, publicPath string) *LocalStorageService {
// 确保基础目录存在
if err := os.MkdirAll(baseDir, 0755); err != nil {
panic(fmt.Sprintf("创建存储目录失败: %v", err))
}
// 确保头像目录存在
avatarDir := filepath.Join(baseDir, "avatars")
if err := os.MkdirAll(avatarDir, 0755); err != nil {
panic(fmt.Sprintf("创建头像目录失败: %v", err))
}
return &LocalStorageService{
baseDir: baseDir,
publicPath: publicPath,
}
}
// SaveAvatar 保存头像文件
func (s *LocalStorageService) SaveAvatar(userID uint, file io.Reader, filename string) (string, error) {
// 获取文件扩展名
ext := filepath.Ext(filename)
if ext == "" {
ext = ".jpg" // 默认使用 jpg
}
// 生成唯一文件名:user_{userID}_{timestamp}{ext}
timestamp := time.Now().Unix()
newFilename := fmt.Sprintf("user_%d_%d%s", userID, timestamp, ext)
// 保存到 avatars 目录
avatarDir := filepath.Join(s.baseDir, "avatars")
filePath := filepath.Join(avatarDir, newFilename)
// 创建文件
dst, err := os.Create(filePath)
if err != nil {
return "", fmt.Errorf("创建文件失败: %w", err)
}
defer dst.Close()
// 复制文件内容
if _, err := io.Copy(dst, file); err != nil {
return "", fmt.Errorf("保存文件失败: %w", err)
}
// 返回相对路径(用于构建URL
relativePath := filepath.Join("avatars", newFilename)
return s.GetFileURL(relativePath), nil
}
// DeleteFile 删除文件
func (s *LocalStorageService) DeleteFile(fileURL string) error {
// 从URL中提取文件路径
// 假设URL格式为: /uploads/avatars/filename.jpg
// 需要去掉 /uploads/ 前缀,得到相对路径
relativePath := fileURL
if len(s.publicPath) > 0 && len(fileURL) > len(s.publicPath) {
if fileURL[:len(s.publicPath)] == s.publicPath {
relativePath = fileURL[len(s.publicPath):]
// 去掉开头的 /
if len(relativePath) > 0 && relativePath[0] == '/' {
relativePath = relativePath[1:]
}
}
}
filePath := filepath.Join(s.baseDir, relativePath)
if err := os.Remove(filePath); err != nil {
if os.IsNotExist(err) {
return nil // 文件不存在,认为删除成功
}
return fmt.Errorf("删除文件失败: %w", err)
}
return nil
}
// GetFileURL 获取文件的完整URL
func (s *LocalStorageService) GetFileURL(filePath string) string {
// 确保路径使用正斜杠(用于URL
urlPath := filepath.ToSlash(filePath)
// 如果 publicPath 为空,返回相对路径
if s.publicPath == "" {
return "/" + urlPath
}
// 确保 publicPath 以 / 结尾
publicPath := s.publicPath
if publicPath[len(publicPath)-1] != '/' {
publicPath += "/"
}
// 确保 urlPath 不以 / 开头
if len(urlPath) > 0 && urlPath[0] == '/' {
urlPath = urlPath[1:]
}
return publicPath + urlPath
}
+182 -17
View File
@@ -1,28 +1,193 @@
package main
import (
"encoding/json"
"fmt"
"net/http"
"log"
"os"
"path/filepath"
"github.com/2930134478/AI-CS/backend/controller"
"github.com/2930134478/AI-CS/backend/infra"
"github.com/2930134478/AI-CS/backend/middleware"
"github.com/2930134478/AI-CS/backend/models"
"github.com/2930134478/AI-CS/backend/repository"
appRouter "github.com/2930134478/AI-CS/backend/router"
"github.com/2930134478/AI-CS/backend/service"
"github.com/2930134478/AI-CS/backend/websocket"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"golang.org/x/crypto/bcrypt"
)
type PingResponse struct {
Message string `json:"message"`
// 初始化默认管理员账号(如果不存在)
// 默认账号:admin / admin123
func initDefaultAdmin(userRepo *repository.UserRepository) {
if _, err := userRepo.FindByUsername("admin"); err == nil {
log.Println("✅ 管理员账号已存在")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
if err != nil {
log.Printf("⚠️ 创建默认管理员失败:密码加密错误 %v", err)
return
}
admin := &models.User{
Username: "admin",
Password: string(hash),
Role: "admin",
}
if err := userRepo.Create(admin); err != nil {
log.Printf("⚠️ 创建默认管理员失败:%v", err)
return
}
log.Println("✅ 默认管理员账号创建成功")
log.Println(" 用户名: admin")
log.Println(" 密码: admin123")
log.Println(" ⚠️ 请首次登录后立即修改密码!")
}
func main() {
//根路由
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
})
// /ping路由
http.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
resp := PingResponse{Message: "pong"}
json.NewEncoder(w).Encode(resp)
})
// 加载 .env 文件
// 获取当前工作目录
wd, _ := os.Getwd()
envPath := filepath.Join(wd, ".env")
fmt.Println("Server is running on port 8080")
http.ListenAndServe(":8080", nil)
// 检查文件是否存在
if _, err := os.Stat(envPath); os.IsNotExist(err) {
log.Printf("⚠️ .env 文件不存在: %s", envPath)
log.Println("当前工作目录:", wd)
} else {
log.Printf("✅ 找到 .env 文件: %s", envPath)
}
// 尝试加载 .env 文件
// 注意:godotenv 不支持 UTF-8 BOM,如果文件有 BOM 会失败
if err := godotenv.Load(envPath); err != nil {
log.Printf("❌ 加载 .env 文件失败: %v", err)
log.Println("⚠️ 提示:如果看到 'unexpected character' 错误,可能是文件编码问题(UTF-8 BOM")
log.Println(" 解决方法:用文本编辑器(如 VS Code)打开 .env,另存为 UTF-8 编码(不要 BOM")
log.Println("将使用系统环境变量")
} else {
log.Println("✅ .env 文件加载成功")
}
db, err := infra.NewDB()
if err != nil {
log.Fatalf("数据库连接失败:%v", err)
}
//根据结构体定义自动创建更新表
if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}); err != nil {
log.Fatalf("自动创建表失败: %v", err)
}
userRepo := repository.NewUserRepository(db)
conversationRepo := repository.NewConversationRepository(db)
messageRepo := repository.NewMessageRepository(db)
// 初始化默认管理员账号(如果不存在)
initDefaultAdmin(userRepo)
//gin路由初始化
r := gin.Default()
//使用日志中间件
r.Use(middleware.Logger())
//跨域配置
r.Use(middleware.CORS())
// 初始化存储服务(本地存储)
// 存储目录:backend/uploads(相对于工作目录)
// 公共访问路径:/uploads(用于构建URL
// 复用之前获取的工作目录 wd(已在第 56 行声明)
uploadDir := filepath.Join(wd, "uploads")
publicPath := "/uploads"
storageService := infra.NewLocalStorageService(uploadDir, publicPath)
// 初始化服务层
authService := service.NewAuthService(userRepo)
conversationService := service.NewConversationService(conversationRepo, messageRepo)
profileService := service.NewProfileService(userRepo, storageService)
// 声明 Hub 变量(用于在回调函数中访问)
var wsHub *websocket.Hub
// 创建 WebSocket Hub,设置回调函数来处理客户端连接/断开事件
// 使用闭包来访问 conversationService 和 wsHub
onConnect := func(conversationID uint, isVisitor bool, visitorCount int) {
if isVisitor {
if err := conversationService.UpdateVisitorOnlineStatus(conversationID, true); err != nil {
log.Printf("更新访客在线状态失败: %v", err)
return
}
// 广播状态更新到所有客服端(不管连接到哪个对话)
wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
"conversation_id": conversationID,
"is_online": true,
"visitor_count": visitorCount,
})
}
}
onDisconnect := func(conversationID uint, isVisitor bool, visitorCount int) {
if isVisitor {
if visitorCount == 0 {
if err := conversationService.UpdateVisitorOnlineStatus(conversationID, false); err != nil {
log.Printf("更新访客离线状态失败: %v", err)
return
}
// 广播状态更新到所有客服端(不管连接到哪个对话)
wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
"conversation_id": conversationID,
"is_online": false,
"visitor_count": 0,
})
} else {
// 还有访客在线,只更新最后活跃时间
if err := conversationService.UpdateLastSeenAt(conversationID); err != nil {
log.Printf("更新最后活跃时间失败: %v", err)
return
}
}
}
}
// 创建 Hub(回调函数通过闭包访问 wsHub)
wsHub = websocket.NewHub(onConnect, onDisconnect)
go wsHub.Run() // 启动 Hub(在后台运行)
messageService := service.NewMessageService(conversationRepo, messageRepo, wsHub)
// 初始化控制器
authController := controller.NewAuthController(authService)
conversationController := controller.NewConversationController(conversationService)
messageController := controller.NewMessageController(messageService)
adminController := controller.NewAdminController(authService)
profileController := controller.NewProfileController(profileService)
appRouter.RegisterRoutes(
r,
appRouter.ControllerSet{
Auth: authController,
Conversation: conversationController,
Message: messageController,
Admin: adminController,
Profile: profileController,
},
websocket.HandleWebSocket(wsHub),
)
// 配置静态文件服务(用于访问上传的头像等文件)
// 静态文件路径:/uploads -> backend/uploads
r.Static("/uploads", uploadDir)
//启动服务器)
log.Println("🚀 服务器启动成功,监听 :8080")
log.Println("📡 WebSocket 服务已启动,路径: /ws?conversation_id=<对话ID>")
r.Run(":8080")
}
+28
View File
@@ -0,0 +1,28 @@
package middleware
import (
"log"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func Logger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
//继续调用后续的中间件处理函数
c.Next()
log.Printf("[GIN] %s %s %d %s",
c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(start))
}
}
func CORS() gin.HandlerFunc {
return cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
AllowCredentials: false,
})
}
+52
View File
@@ -0,0 +1,52 @@
package models
import (
"time"
)
type User struct {
ID uint `json:"id" gorm:"primarykey"`
Username string `json:"username" gorm:"unique"`
Password string `json:"password"`
Role string `json:"role"`
AvatarURL string `json:"avatar_url" gorm:"type:varchar(500)"` // 头像URL
Nickname string `json:"nickname" gorm:"type:varchar(100)"` // 昵称
Email string `json:"email" gorm:"type:varchar(255)"` // 邮箱
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
type Conversation struct {
ID uint `json:"id" gorm:"primaryKey"`
VisitorID uint `json:"visitor_id"`
AgentID uint `json:"agent_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// 访客信息字段(自动收集)
Website string `json:"website" gorm:"type:varchar(500)"` // 网站(当前页面URL
Referrer string `json:"referrer" gorm:"type:varchar(500)"` // 来源(referrer
Browser string `json:"browser" gorm:"type:varchar(100)"` // 浏览器信息
OS string `json:"os" gorm:"type:varchar(100)"` // 操作系统
Language string `json:"language" gorm:"type:varchar(50)"` // 语言
IPAddress string `json:"ip_address" gorm:"type:varchar(50)"` // IP地址
Location string `json:"location" gorm:"type:varchar(200)"` // 位置
// 联系信息字段(客服手动添加)
Email string `json:"email" gorm:"type:varchar(255)"` // 邮箱
Phone string `json:"phone" gorm:"type:varchar(50)"` // 电话
Notes string `json:"notes" gorm:"type:text"` // 备注
// 在线状态
LastSeenAt *time.Time `json:"last_seen_at"` // 最后活跃时间
}
type Message struct {
ID uint `json:"id" gorm:"primarykey"`
ConversationID uint `json:"conversation_id"`
SenderID uint `json:"sender_id"`
SenderIsAgent bool `json:"sender_is_agent"`
Content string `json:"content" gorm:"type:text"`
MessageType string `json:"message_type" gorm:"type:varchar(20);default:'user_message'"` // 消息类型:user_message, system_message
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,113 @@
package repository
import (
"errors"
"github.com/2930134478/AI-CS/backend/models"
"gorm.io/gorm"
)
// ConversationRepository 封装与会话相关的数据库操作。
type ConversationRepository struct {
db *gorm.DB
}
// NewConversationRepository 创建会话仓库实例。
func NewConversationRepository(db *gorm.DB) *ConversationRepository {
return &ConversationRepository{db: db}
}
// FindOpenByVisitorID 查询访客当前未关闭的会话。
func (r *ConversationRepository) FindOpenByVisitorID(visitorID uint) (*models.Conversation, error) {
var conv models.Conversation
err := r.db.Where("visitor_id = ? AND status != ?", visitorID, "closed").
Order("created_at desc").
First(&conv).Error
if err != nil {
return nil, err
}
return &conv, nil
}
// Create 创建新的会话记录。
func (r *ConversationRepository) Create(conv *models.Conversation) error {
return r.db.Create(conv).Error
}
// UpdateFields 更新会话的指定字段。
func (r *ConversationRepository) UpdateFields(id uint, values map[string]interface{}) error {
if len(values) == 0 {
return nil
}
return r.db.Model(&models.Conversation{}).Where("id = ?", id).Updates(values).Error
}
// GetByID 根据主键查询会话。
func (r *ConversationRepository) GetByID(id uint) (*models.Conversation, error) {
var conv models.Conversation
if err := r.db.First(&conv, id).Error; err != nil {
return nil, err
}
return &conv, nil
}
// ListActive 返回所有未关闭的会话。
func (r *ConversationRepository) ListActive() ([]models.Conversation, error) {
var conversations []models.Conversation
if err := r.db.Where("status != ?", "closed").
Order("updated_at desc").
Find(&conversations).Error; err != nil {
return nil, err
}
return conversations, nil
}
// ListByIDs 根据多个 ID 批量查询会话。
func (r *ConversationRepository) ListByIDs(ids []uint) ([]models.Conversation, error) {
if len(ids) == 0 {
return []models.Conversation{}, nil
}
var conversations []models.Conversation
if err := r.db.Where("id IN ? AND status != ?", ids, "closed").
Order("updated_at desc").
Find(&conversations).Error; err != nil {
return nil, err
}
return conversations, nil
}
// SearchByIDOrVisitorLike 根据会话 ID 或访客 ID 进行模糊搜索。
func (r *ConversationRepository) SearchByIDOrVisitorLike(pattern string) ([]models.Conversation, error) {
var conversations []models.Conversation
if err := r.db.Where("CAST(id AS CHAR) LIKE ? OR CAST(visitor_id AS CHAR) LIKE ?", pattern, pattern).
Find(&conversations).Error; err != nil {
return nil, err
}
return conversations, nil
}
// AssignAgent 为会话分配客服。
func (r *ConversationRepository) AssignAgent(conversationID uint, agentID uint) error {
result := r.db.Model(&models.Conversation{}).
Where("id = ?", conversationID).
Updates(map[string]interface{}{
"agent_id": agentID,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// UpdateStatus 更新会话状态。
func (r *ConversationRepository) UpdateStatus(conversationID uint, status string) error {
if status == "" {
return errors.New("status cannot be empty")
}
return r.db.Model(&models.Conversation{}).
Where("id = ?", conversationID).
Update("status", status).Error
}
+96
View File
@@ -0,0 +1,96 @@
package repository
import (
"errors"
"time"
"github.com/2930134478/AI-CS/backend/models"
"gorm.io/gorm"
)
// MessageRepository 封装与消息相关的数据库操作。
type MessageRepository struct {
db *gorm.DB
}
// NewMessageRepository 创建消息仓库实例。
func NewMessageRepository(db *gorm.DB) *MessageRepository {
return &MessageRepository{db: db}
}
// Create 新建一条消息记录。
func (r *MessageRepository) Create(message *models.Message) error {
return r.db.Create(message).Error
}
// ListByConversationID 按时间顺序查询会话中的全部消息。
func (r *MessageRepository) ListByConversationID(conversationID uint) ([]models.Message, error) {
var messages []models.Message
if err := r.db.Where("conversation_id = ?", conversationID).Order("created_at asc").Find(&messages).Error; err != nil {
return nil, err
}
return messages, nil
}
// LatestByConversationID 查询会话中最新的一条消息。
func (r *MessageRepository) LatestByConversationID(conversationID uint) (*models.Message, error) {
var message models.Message
if err := r.db.Where("conversation_id = ?", conversationID).
Order("created_at desc").
First(&message).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &message, nil
}
// CountUnreadBySender 统计指定发送方的未读消息数量。
func (r *MessageRepository) CountUnreadBySender(conversationID uint, senderIsAgent bool) (int64, error) {
var count int64
if err := r.db.Model(&models.Message{}).
Where("conversation_id = ? AND sender_is_agent = ? AND is_read = ?", conversationID, senderIsAgent, false).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// FindConversationIDsByContent 根据关键字查询包含该内容的会话 ID。
func (r *MessageRepository) FindConversationIDsByContent(keyword string) ([]uint, error) {
var ids []uint
if err := r.db.Model(&models.Message{}).
Where("content LIKE ?", keyword).
Pluck("conversation_id", &ids).Error; err != nil {
return nil, err
}
return ids, nil
}
// MarkMessagesRead 将指定发送方的未读消息标记为已读,并返回受影响的消息 ID 及时间。
func (r *MessageRepository) MarkMessagesRead(conversationID uint, senderIsAgent bool) ([]uint, int64, time.Time, error) {
var messageIDs []uint
if err := r.db.Model(&models.Message{}).
Where("conversation_id = ? AND sender_is_agent = ? AND is_read = ?", conversationID, senderIsAgent, false).
Pluck("id", &messageIDs).Error; err != nil {
return nil, 0, time.Time{}, err
}
if len(messageIDs) == 0 {
return []uint{}, 0, time.Time{}, nil
}
now := time.Now()
if err := r.db.Model(&models.Message{}).
Where("id IN ?", messageIDs).
Updates(map[string]interface{}{
"is_read": true,
"read_at": now,
}).Error; err != nil {
return nil, 0, time.Time{}, err
}
remaining, err := r.CountUnreadBySender(conversationID, senderIsAgent)
if err != nil {
return nil, 0, time.Time{}, err
}
return messageIDs, remaining, now, nil
}
+44
View File
@@ -0,0 +1,44 @@
package repository
import (
"github.com/2930134478/AI-CS/backend/models"
"gorm.io/gorm"
)
// UserRepository 封装与用户相关的数据库操作。
type UserRepository struct {
db *gorm.DB
}
// NewUserRepository 创建用户仓库实例。
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
// FindByUsername 根据用户名查询用户。
func (r *UserRepository) FindByUsername(username string) (*models.User, error) {
var user models.User
if err := r.db.Where("username = ?", username).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// Create 创建新的用户记录。
func (r *UserRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
// GetByID 根据ID查询用户。
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
var user models.User
if err := r.db.Where("id = ?", id).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// UpdateFields 更新用户的部分字段。
func (r *UserRepository) UpdateFields(id uint, updates map[string]interface{}) error {
return r.db.Model(&models.User{}).Where("id = ?", id).Updates(updates).Error
}
+45
View File
@@ -0,0 +1,45 @@
package router
import (
"github.com/2930134478/AI-CS/backend/controller"
"github.com/gin-gonic/gin"
)
// ControllerSet 用于收集路由需要的控制器集合。
type ControllerSet struct {
Auth *controller.AuthController
Conversation *controller.ConversationController
Message *controller.MessageController
Admin *controller.AdminController
Profile *controller.ProfileController
}
// RegisterRoutes 注册 HTTP 路由及对应的处理函数。
func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.HandlerFunc) {
// Auth
r.POST("/login", controllers.Auth.Login)
r.POST("/logout", controllers.Auth.Logout)
// Conversation
r.POST("/conversation/init", controllers.Conversation.InitConversation)
r.GET("/conversations", controllers.Conversation.ListConversations)
r.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
r.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
r.GET("/conversations/search", controllers.Conversation.SearchConversations)
// Message
r.POST("/messages", controllers.Message.CreateMessage)
r.GET("/messages", controllers.Message.ListMessages)
r.PUT("/messages/read", controllers.Message.MarkMessagesRead)
// Admin
r.POST("/admin/users", controllers.Admin.CreateAgent)
// Profile(个人资料)
r.GET("/agent/profile/:user_id", controllers.Profile.GetProfile)
r.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile)
r.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar)
// WebSocket
r.GET("/ws", wsHandler)
}
+77
View File
@@ -0,0 +1,77 @@
package service
import (
"errors"
"github.com/2930134478/AI-CS/backend/models"
"github.com/2930134478/AI-CS/backend/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// ErrInvalidCredentials indicates login attempt failed.
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUsernameExists = errors.New("username already exists")
)
// AuthService 负责认证相关的业务逻辑。
type AuthService struct {
users *repository.UserRepository
}
// NewAuthService 创建 AuthService 实例。
func NewAuthService(users *repository.UserRepository) *AuthService {
return &AuthService{users: users}
}
// Login 校验账号密码并返回用户信息。
func (s *AuthService) Login(username, password string) (*models.User, error) {
user, err := s.users.FindByUsername(username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidCredentials
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
return nil, ErrInvalidCredentials
}
return user, nil
}
func (s *AuthService) CreateAgent(input CreateAgentInput) (*models.User, error) {
if input.Username == "" || input.Password == "" {
return nil, errors.New("username and password are required")
}
if _, err := s.users.FindByUsername(input.Username); err == nil {
return nil, ErrUsernameExists
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
role := input.Role
if role == "" {
role = "agent"
}
user := &models.User{
Username: input.Username,
Password: string(hash),
Role: role,
}
if err := s.users.Create(user); err != nil {
return nil, err
}
return user, nil
}
+324
View File
@@ -0,0 +1,324 @@
package service
import (
"errors"
"strings"
"time"
"github.com/2930134478/AI-CS/backend/models"
"github.com/2930134478/AI-CS/backend/repository"
"gorm.io/gorm"
)
// ConversationService 负责会话领域的业务编排。
type ConversationService struct {
conversations *repository.ConversationRepository
messages *repository.MessageRepository
}
// NewConversationService 创建 ConversationService 实例。
func NewConversationService(
conversations *repository.ConversationRepository,
messages *repository.MessageRepository,
) *ConversationService {
return &ConversationService{
conversations: conversations,
messages: messages,
}
}
// InitConversation 为访客创建或恢复会话。
func (s *ConversationService) InitConversation(input InitConversationInput) (*InitConversationResult, error) {
var (
conv *models.Conversation
err error
)
conv, err = s.conversations.FindOpenByVisitorID(input.VisitorID)
isNewConversation := false
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
now := time.Now()
conv = &models.Conversation{
VisitorID: input.VisitorID,
Status: "open",
Website: input.Website,
Referrer: input.Referrer,
Browser: input.Browser,
OS: input.OS,
Language: input.Language,
IPAddress: input.IPAddress,
LastSeenAt: &now,
}
if err := s.conversations.Create(conv); err != nil {
return nil, err
}
isNewConversation = true
} else {
return nil, err
}
} else {
now := time.Now()
updates := map[string]interface{}{
"last_seen_at": &now,
}
if input.Website != "" && conv.Website == "" {
updates["website"] = input.Website
}
if input.Referrer != "" && conv.Referrer == "" {
updates["referrer"] = input.Referrer
}
if input.Browser != "" && conv.Browser == "" {
updates["browser"] = input.Browser
}
if input.OS != "" && conv.OS == "" {
updates["os"] = input.OS
}
if input.Language != "" && conv.Language == "" {
updates["language"] = input.Language
}
if input.IPAddress != "" && conv.IPAddress == "" {
updates["ip_address"] = input.IPAddress
}
if err := s.conversations.UpdateFields(conv.ID, updates); err != nil {
return nil, err
}
}
if isNewConversation {
now := time.Now()
message := &models.Message{
ConversationID: conv.ID,
SenderID: 0,
SenderIsAgent: false,
Content: "Visitor opened the page",
MessageType: "system_message",
IsRead: true,
ReadAt: &now,
}
if input.Website != "" {
message.Content += " [" + input.Website + "]"
}
if err := s.messages.Create(message); err != nil {
return nil, err
}
if input.Referrer != "" {
readTime := time.Now()
referrerMsg := &models.Message{
ConversationID: conv.ID,
SenderID: 0,
SenderIsAgent: false,
Content: "Visitor came from [" + input.Referrer + "]",
MessageType: "system_message",
IsRead: true,
ReadAt: &readTime,
}
if err := s.messages.Create(referrerMsg); err != nil {
return nil, err
}
}
}
return &InitConversationResult{
ConversationID: conv.ID,
Status: conv.Status,
}, nil
}
// UpdateConversationContact 更新访客的联系信息(邮箱、电话、备注)。
func (s *ConversationService) UpdateConversationContact(input UpdateConversationContactInput) (*ConversationDetail, error) {
if _, err := s.conversations.GetByID(input.ConversationID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrConversationNotFound
}
return nil, err
}
updates := map[string]interface{}{}
if input.Email != nil {
updates["email"] = strings.TrimSpace(*input.Email)
}
if input.Phone != nil {
updates["phone"] = strings.TrimSpace(*input.Phone)
}
if input.Notes != nil {
updates["notes"] = strings.TrimSpace(*input.Notes)
}
if err := s.conversations.UpdateFields(input.ConversationID, updates); err != nil {
return nil, err
}
return s.GetConversationDetail(input.ConversationID)
}
func (s *ConversationService) buildSummary(conv models.Conversation) (ConversationSummary, error) {
var lastSeen *time.Time
if conv.LastSeenAt != nil {
lastSeen = conv.LastSeenAt
}
summary := ConversationSummary{
ID: conv.ID,
VisitorID: conv.VisitorID,
AgentID: conv.AgentID,
Status: conv.Status,
CreatedAt: conv.CreatedAt,
UpdatedAt: conv.UpdatedAt,
LastSeenAt: lastSeen, // 添加 last_seen_at 字段
}
if message, err := s.messages.LatestByConversationID(conv.ID); err == nil && message != nil {
var readAt *time.Time
if message.ReadAt != nil {
readAt = message.ReadAt
}
summary.LastMessage = &LastMessageSummary{
ID: message.ID,
Content: message.Content,
SenderIsAgent: message.SenderIsAgent,
MessageType: message.MessageType,
IsRead: message.IsRead,
ReadAt: readAt,
CreatedAt: message.CreatedAt,
}
}
if count, err := s.messages.CountUnreadBySender(conv.ID, false); err == nil {
summary.UnreadCount = count
}
return summary, nil
}
// ListConversations 返回当前活跃会话的摘要信息。
func (s *ConversationService) ListConversations() ([]ConversationSummary, error) {
conversations, err := s.conversations.ListActive()
if err != nil {
return nil, err
}
result := make([]ConversationSummary, 0, len(conversations))
for _, conv := range conversations {
summary, err := s.buildSummary(conv)
if err != nil {
return nil, err
}
result = append(result, summary)
}
return result, nil
}
// GetConversationDetail 获取指定会话的详细信息。
func (s *ConversationService) GetConversationDetail(id uint) (*ConversationDetail, error) {
conv, err := s.conversations.GetByID(id)
if err != nil {
return nil, err
}
summary, err := s.buildSummary(*conv)
if err != nil {
return nil, err
}
var lastSeen *time.Time
if conv.LastSeenAt != nil {
lastSeen = conv.LastSeenAt
}
return &ConversationDetail{
ConversationSummary: summary,
Website: conv.Website,
Referrer: conv.Referrer,
Browser: conv.Browser,
OS: conv.OS,
Language: conv.Language,
IPAddress: conv.IPAddress,
Location: conv.Location,
Email: conv.Email,
Phone: conv.Phone,
Notes: conv.Notes,
LastSeen: lastSeen,
}, nil
}
// SearchConversations 根据关键字检索会话摘要。
func (s *ConversationService) SearchConversations(query string) ([]ConversationSummary, error) {
pattern := "%" + query + "%"
idSet := map[uint]struct{}{}
if ids, err := s.messages.FindConversationIDsByContent(pattern); err == nil {
for _, id := range ids {
idSet[id] = struct{}{}
}
} else {
return nil, err
}
if convs, err := s.conversations.SearchByIDOrVisitorLike(pattern); err == nil {
for _, conv := range convs {
idSet[conv.ID] = struct{}{}
}
} else {
return nil, err
}
if len(idSet) == 0 {
return []ConversationSummary{}, nil
}
ids := make([]uint, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
conversations, err := s.conversations.ListByIDs(ids)
if err != nil {
return nil, err
}
result := make([]ConversationSummary, 0, len(conversations))
for _, conv := range conversations {
summary, err := s.buildSummary(conv)
if err != nil {
return nil, err
}
result = append(result, summary)
}
return result, nil
}
// UpdateVisitorOnlineStatus 更新访客在线状态和最后活跃时间。
// 当 isOnline 为 true 时,更新 last_seen_at 为当前时间,并确保状态为 "open"。
// 当 isOnline 为 false 时,仅更新 last_seen_at 为当前时间,不改变状态。
func (s *ConversationService) UpdateVisitorOnlineStatus(conversationID uint, isOnline bool) error {
now := time.Now()
updates := map[string]interface{}{
"last_seen_at": &now,
}
// 如果标记为在线,确保状态为 "open"(但不要将已关闭的会话重新打开)
if isOnline {
conv, err := s.conversations.GetByID(conversationID)
if err != nil {
return err
}
// 只有当前状态不是 "closed" 时,才更新为 "open"
if conv.Status != "closed" {
updates["status"] = "open"
}
}
return s.conversations.UpdateFields(conversationID, updates)
}
// UpdateLastSeenAt 更新访客的最后活跃时间。
func (s *ConversationService) UpdateLastSeenAt(conversationID uint) error {
now := time.Now()
return s.conversations.UpdateFields(conversationID, map[string]interface{}{
"last_seen_at": &now,
})
}
+113
View File
@@ -0,0 +1,113 @@
package service
import (
"errors"
"log"
"github.com/2930134478/AI-CS/backend/models"
"github.com/2930134478/AI-CS/backend/repository"
"gorm.io/gorm"
)
// ErrConversationClosed indicates operations are attempted on a closed conversation.
var (
// ErrConversationClosed 表示会话已关闭,不能继续发送消息。
ErrConversationClosed = errors.New("conversation is closed")
// ErrConversationNotFound 表示未找到指定的会话记录。
ErrConversationNotFound = gorm.ErrRecordNotFound
)
// MessageService 负责消息领域的业务处理。
type MessageService struct {
conversations *repository.ConversationRepository
messages *repository.MessageRepository
hub BroadcastHub
}
// NewMessageService 创建 MessageService 实例。
func NewMessageService(
conversations *repository.ConversationRepository,
messages *repository.MessageRepository,
hub BroadcastHub,
) *MessageService {
return &MessageService{
conversations: conversations,
messages: messages,
hub: hub,
}
}
// CreateMessage 创建消息并通过 WebSocket 广播。
func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Message, error) {
conv, err := s.conversations.GetByID(input.ConversationID)
if err != nil {
return nil, err
}
if conv.Status == "closed" {
return nil, ErrConversationClosed
}
if input.SenderIsAgent && input.SenderID == 0 {
return nil, errors.New("sender_id is required for agent messages")
}
message := &models.Message{
ConversationID: input.ConversationID,
SenderID: input.SenderID,
SenderIsAgent: input.SenderIsAgent,
Content: input.Content,
MessageType: "user_message",
IsRead: false,
}
if err := s.messages.Create(message); err != nil {
return nil, err
}
if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
"updated_at": message.CreatedAt,
}); err != nil {
return nil, err
}
if s.hub != nil {
s.hub.BroadcastMessage(message.ConversationID, "new_message", message)
} else {
log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID)
}
return message, nil
}
// ListMessages 返回会话内的全部消息。
func (s *MessageService) ListMessages(conversationID uint) ([]models.Message, error) {
return s.messages.ListByConversationID(conversationID)
}
// MarkMessagesRead 将消息标记为已读并通知监听方。
func (s *MessageService) MarkMessagesRead(conversationID uint, readerIsAgent bool) (*MarkMessagesReadResult, error) {
messageIDs, unreadRemaining, readAt, err := s.messages.MarkMessagesRead(conversationID, !readerIsAgent)
if err != nil {
return nil, err
}
result := &MarkMessagesReadResult{
ConversationID: conversationID,
MessageIDs: messageIDs,
UnreadCount: unreadRemaining,
ReadAt: readAt,
}
if s.hub != nil && len(messageIDs) > 0 {
s.hub.BroadcastMessage(conversationID, "messages_read", map[string]interface{}{
"message_ids": messageIDs,
"reader_is_agent": readerIsAgent,
"read_at": readAt,
"unread_count": unreadRemaining,
"conversation_id": conversationID, // 确保 payload 中也包含 conversation_id
})
}
return result, nil
}
+108
View File
@@ -0,0 +1,108 @@
package service
import (
"errors"
"io"
"github.com/2930134478/AI-CS/backend/infra"
"github.com/2930134478/AI-CS/backend/repository"
"gorm.io/gorm"
)
// ProfileService 负责个人资料相关的业务逻辑。
type ProfileService struct {
users *repository.UserRepository
storage infra.StorageService
}
// NewProfileService 创建 ProfileService 实例。
func NewProfileService(users *repository.UserRepository, storage infra.StorageService) *ProfileService {
return &ProfileService{
users: users,
storage: storage,
}
}
// GetProfile 获取用户的个人资料。
func (s *ProfileService) GetProfile(userID uint) (*ProfileResult, error) {
user, err := s.users.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户不存在")
}
return nil, err
}
return &ProfileResult{
ID: user.ID,
Username: user.Username,
Role: user.Role,
AvatarURL: user.AvatarURL,
Nickname: user.Nickname,
Email: user.Email,
}, nil
}
// UpdateProfile 更新用户的个人资料。
func (s *ProfileService) UpdateProfile(input UpdateProfileInput) (*ProfileResult, error) {
// 检查用户是否存在
if _, err := s.users.GetByID(input.UserID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户不存在")
}
return nil, err
}
updates := make(map[string]interface{})
if input.Nickname != nil {
updates["nickname"] = *input.Nickname
}
if input.Email != nil {
updates["email"] = *input.Email
}
if len(updates) > 0 {
if err := s.users.UpdateFields(input.UserID, updates); err != nil {
return nil, err
}
}
return s.GetProfile(input.UserID)
}
// UploadAvatar 上传用户头像。
func (s *ProfileService) UploadAvatar(userID uint, file io.Reader, filename string) (*ProfileResult, error) {
// 检查用户是否存在
user, err := s.users.GetByID(userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("用户不存在")
}
return nil, err
}
// 如果已有头像,删除旧头像
if user.AvatarURL != "" {
if err := s.storage.DeleteFile(user.AvatarURL); err != nil {
// 删除失败不阻止更新,只记录警告
// log.Printf("删除旧头像失败: %v", err)
}
}
// 保存新头像
avatarURL, err := s.storage.SaveAvatar(userID, file, filename)
if err != nil {
return nil, err
}
// 更新用户头像URL
updates := map[string]interface{}{
"avatar_url": avatarURL,
}
if err := s.users.UpdateFields(userID, updates); err != nil {
return nil, err
}
return s.GetProfile(userID)
}
+113
View File
@@ -0,0 +1,113 @@
package service
import "time"
// BroadcastHub 描述 WebSocket Hub 的广播能力。
type BroadcastHub interface {
BroadcastMessage(conversationID uint, messageType string, data interface{})
}
// InitConversationInput 对话初始化需要的输入数据。
type InitConversationInput struct {
VisitorID uint
Website string
Referrer string
Browser string
OS string
Language string
IPAddress string
}
// InitConversationResult 对话初始化后的返回结果。
type InitConversationResult struct {
ConversationID uint
Status string
}
// UpdateConversationContactInput 更新访客联系信息时需要的参数。
type UpdateConversationContactInput struct {
ConversationID uint
Email *string
Phone *string
Notes *string
}
// ConversationSummary 用于会话列表展示的概要信息。
type ConversationSummary struct {
ID uint
VisitorID uint
AgentID uint
Status string
CreatedAt time.Time
UpdatedAt time.Time
LastMessage *LastMessageSummary
UnreadCount int64
LastSeenAt *time.Time // 最后活跃时间,用于判断在线状态
}
// LastMessageSummary 会话最后一条消息的摘要信息。
type LastMessageSummary struct {
ID uint
Content string
SenderIsAgent bool
MessageType string
IsRead bool
ReadAt *time.Time
CreatedAt time.Time
}
// ConversationDetail 在会话概要基础上附加访客信息。
type ConversationDetail struct {
ConversationSummary
Website string
Referrer string
Browser string
OS string
Language string
IPAddress string
Location string
Email string
Phone string
Notes string
LastSeen *time.Time
}
// CreateMessageInput 创建消息时需要的参数。
type CreateMessageInput struct {
ConversationID uint
Content string
SenderID uint
SenderIsAgent bool
}
// CreateAgentInput 创建客服或管理员账号需要的参数。
type CreateAgentInput struct {
Username string
Password string
Role string
}
// MarkMessagesReadResult 消息标记已读后的返回信息。
type MarkMessagesReadResult struct {
ConversationID uint
MessageIDs []uint
UnreadCount int64
ReadAt time.Time
}
// UpdateProfileInput 更新个人资料时需要的参数。
type UpdateProfileInput struct {
UserID uint
Nickname *string
Email *string
}
// ProfileResult 个人资料信息。
type ProfileResult struct {
ID uint `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
AvatarURL string `json:"avatar_url"`
Nickname string `json:"nickname"`
Email string `json:"email"`
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+59
View File
@@ -0,0 +1,59 @@
package utils
import (
"strings"
"github.com/gin-gonic/gin"
)
// GetClientIP 获取客户端 IP 地址(考虑代理情况)
func GetClientIP(c *gin.Context) string {
// 优先从 X-Forwarded-For 获取(适用于代理/负载均衡)
ip := strings.TrimSpace(c.GetHeader("X-Forwarded-For"))
if ip != "" {
return ip
}
// 从 X-Real-IP 获取
ip = strings.TrimSpace(c.GetHeader("X-Real-IP"))
if ip != "" {
return ip
}
return c.ClientIP()
}
// ParseUserAgent 从 User-Agent 字符串中解析浏览器和操作系统
func ParseUserAgent(userAgent string) (browser string, os string) {
browser = "Unknown"
os = "Unknown"
ua := strings.ToLower(userAgent)
if ua == "" {
return
}
switch {
case strings.Contains(ua, "edg/"):
browser = "Edge"
case strings.Contains(ua, "chrome/"):
browser = "Chrome"
case strings.Contains(ua, "firefox/"):
browser = "Firefox"
case strings.Contains(ua, "safari/"):
browser = "Safari"
}
switch {
case strings.Contains(ua, "windows nt"):
os = "Windows"
case strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh"):
os = "macOS"
case strings.Contains(ua, "android"):
os = "Android"
case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"):
os = "iOS"
case strings.Contains(ua, "linux"):
os = "Linux"
}
return
}
+133
View File
@@ -0,0 +1,133 @@
package websocket
import (
"encoding/json"
"log"
"time"
"github.com/gorilla/websocket"
)
const (
// 客户端发送 ping 的最大等待时间
writeWait = 10 * time.Second
// 从客户端读取 pong 的最大等待时间
pongWait = 60 * time.Second
// 发送 ping 的频率(必须小于 pongWait
pingPeriod = (pongWait * 9) / 10
// 最大消息大小
maxMessageSize = 512 * 1024 // 512KB
)
// Client 是一个 WebSocket 客户端
type Client struct {
hub *Hub
// WebSocket 连接
conn *websocket.Conn
// 发送消息的通道
send chan *Message
// 对话ID(这个客户端属于哪个对话)
conversationID uint
// 是否是访客(true 表示访客,false 表示客服)
isVisitor bool
}
// NewClient 创建一个新的客户端
func NewClient(hub *Hub, conn *websocket.Conn, conversationID uint, isVisitor bool) *Client {
return &Client{
hub: hub,
conn: conn,
send: make(chan *Message, 256),
conversationID: conversationID,
isVisitor: isVisitor,
}
}
// ReadPump 从 WebSocket 连接读取消息
func (c *Client) ReadPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
// 设置读取限制和超时
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
// 持续读取消息
for {
_, _, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("⚠️ WebSocket 读取错误: 对话ID=%d, 错误=%v", c.conversationID, err)
}
break
}
// 目前我们不需要处理客户端发送的消息,只接收心跳包
// 如果需要双向通信,可以在这里处理客户端消息
}
}
// WritePump 向 WebSocket 连接写入消息
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// Hub 关闭了通道
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
// 发送消息
if err := c.conn.WriteJSON(message); err != nil {
log.Printf("❌ WebSocket 写入错误: 对话ID=%d, 类型=%s, 错误=%v",
c.conversationID, message.Type, err)
return
}
case <-ticker.C:
// 定期发送 ping 保持连接
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
log.Printf("❌ 发送 ping 失败: 对话ID=%d, 错误=%v", c.conversationID, err)
return
}
}
}
}
// SendMessage 发送消息给客户端(用于测试)
func (c *Client) SendMessage(messageType string, data interface{}) error {
message := &Message{
ConversationID: c.conversationID,
Type: messageType,
Data: data,
}
messageJSON, err := json.Marshal(message)
if err != nil {
return err
}
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
return c.conn.WriteMessage(websocket.TextMessage, messageJSON)
}
+62
View File
@@ -0,0 +1,62 @@
package websocket
import (
"log"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// 允许跨域连接
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// HandleWebSocket 处理 WebSocket 连接
func HandleWebSocket(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
// 从查询参数获取对话ID
conversationIDStr := c.Query("conversation_id")
if conversationIDStr == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation_id 不能为空"})
return
}
conversationID, err := strconv.ParseUint(conversationIDStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 conversation_id"})
return
}
// 从查询参数获取是否是访客(默认为 true,因为默认是访客连接)
isVisitorStr := c.DefaultQuery("is_visitor", "true")
isVisitor := isVisitorStr == "true" || isVisitorStr == "1"
// 升级 HTTP 连接为 WebSocket 连接
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket 升级失败: %v", err)
return
}
// 创建客户端
client := NewClient(hub, conn, uint(conversationID), isVisitor)
// 注册客户端到 Hub
client.hub.register <- client
// 启动两个 goroutine
// 1. ReadPump:从客户端读取消息(主要是心跳包)
// 2. WritePump:向客户端发送消息
go client.WritePump()
go client.ReadPump()
log.Printf("✅ WebSocket 连接已建立: 对话ID=%d, 是访客=%v", conversationID, isVisitor)
}
}
+218
View File
@@ -0,0 +1,218 @@
package websocket
import (
"log"
"sync"
)
// OnClientConnectCallback 客户端连接时的回调函数。
// conversationID: 对话ID
// isVisitor: 是否是访客
// visitorCount: 该对话当前的访客连接数
type OnClientConnectCallback func(conversationID uint, isVisitor bool, visitorCount int)
// OnClientDisconnectCallback 客户端断开连接时的回调函数。
// conversationID: 对话ID
// isVisitor: 是否是访客
// visitorCount: 该对话当前的访客连接数(断开后)
type OnClientDisconnectCallback func(conversationID uint, isVisitor bool, visitorCount int)
// Hub 管理所有 WebSocket 连接
// 每个对话(conversation)可以有多个人连接(访客和客服)
type Hub struct {
// 每个对话ID对应的客户端连接列表
// conversationID -> []*Client
conversations map[uint]map[*Client]bool
// 注册新客户端(当有人连接时)
register chan *Client
// 注销客户端(当有人断开连接时)
unregister chan *Client
// 广播消息(当有新消息时,推送给所有相关的客户端)
broadcast chan *Message
// 互斥锁(保护并发访问)
mu sync.RWMutex
// 回调函数
onConnect OnClientConnectCallback
onDisconnect OnClientDisconnectCallback
}
// Message 是要广播的消息
type Message struct {
ConversationID uint `json:"conversation_id"`
Data interface{} `json:"data"` // 消息内容(可以是 Message 对象)
Type string `json:"type"` // 消息类型:new_message, conversation_update 等
}
// NewHub 创建一个新的 Hub
func NewHub(onConnect OnClientConnectCallback, onDisconnect OnClientDisconnectCallback) *Hub {
return &Hub{
conversations: make(map[uint]map[*Client]bool),
register: make(chan *Client),
unregister: make(chan *Client),
broadcast: make(chan *Message, 256),
onConnect: onConnect,
onDisconnect: onDisconnect,
}
}
// Run 启动 Hub,处理所有事件
func (h *Hub) Run() {
for {
select {
// 新客户端连接
case client := <-h.register:
h.mu.Lock()
// 如果这个对话还没有客户端,创建一个新的 map
if h.conversations[client.conversationID] == nil {
h.conversations[client.conversationID] = make(map[*Client]bool)
}
// 把这个客户端加入到对话中
h.conversations[client.conversationID][client] = true
// 统计该对话的访客连接数
visitorCount := 0
for c := range h.conversations[client.conversationID] {
if c.isVisitor {
visitorCount++
}
}
h.mu.Unlock()
// 调用连接回调函数
if h.onConnect != nil {
h.onConnect(client.conversationID, client.isVisitor, visitorCount)
}
// 客户端断开连接
case client := <-h.unregister:
h.mu.Lock()
// 从对话中移除这个客户端
wasVisitor := client.isVisitor
if clients, ok := h.conversations[client.conversationID]; ok {
if _, ok := clients[client]; ok {
delete(clients, client)
// 关闭发送通道(避免重复关闭导致 panic)
select {
case _, ok := <-client.send:
if !ok {
// 通道已经关闭,不需要再次关闭
}
default:
// 通道未关闭,关闭它
close(client.send)
}
// 统计该对话的访客连接数(断开后)
visitorCount := 0
for c := range clients {
if c.isVisitor {
visitorCount++
}
}
// 如果这个对话没有客户端了,删除对话
if len(clients) == 0 {
delete(h.conversations, client.conversationID)
}
h.mu.Unlock()
// 调用断开回调函数
if h.onDisconnect != nil {
h.onDisconnect(client.conversationID, wasVisitor, visitorCount)
}
} else {
h.mu.Unlock()
log.Printf("⚠️ 客户端断开时未找到: 对话ID=%d", client.conversationID)
}
} else {
h.mu.Unlock()
log.Printf("⚠️ 客户端断开时对话不存在: 对话ID=%d", client.conversationID)
}
// 广播消息
case message := <-h.broadcast:
h.mu.RLock()
// 找到这个对话的所有客户端
clients, ok := h.conversations[message.ConversationID]
if !ok {
h.mu.RUnlock()
log.Printf("⚠️ 广播消息失败: 对话ID=%d 没有客户端连接", message.ConversationID)
continue
}
// 创建一个客户端列表的副本(避免在遍历时修改)
clientList := make([]*Client, 0, len(clients))
for client := range clients {
clientList = append(clientList, client)
}
h.mu.RUnlock()
// 给所有客户端发送消息
for _, client := range clientList {
select {
case client.send <- message:
default:
// 如果发送失败(客户端可能已经断开),关闭连接
log.Printf("⚠️ 发送消息失败: 对话ID=%d, 客户端断开", client.conversationID)
close(client.send)
h.mu.Lock()
delete(h.conversations[client.conversationID], client)
h.mu.Unlock()
}
}
}
}
}
// BroadcastMessage 广播消息到指定对话的所有客户端
func (h *Hub) BroadcastMessage(conversationID uint, messageType string, data interface{}) {
h.broadcast <- &Message{
ConversationID: conversationID,
Type: messageType,
Data: data,
}
}
// BroadcastToAllAgents 广播消息到所有客服客户端(不管连接到哪个对话)
// 用于 visitor_status_update 等需要所有客服都收到的事件
func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
h.mu.RLock()
// 收集所有客服客户端(isVisitor == false
allAgents := make([]*Client, 0)
for _, clients := range h.conversations {
for client := range clients {
if !client.isVisitor {
allAgents = append(allAgents, client)
}
}
}
h.mu.RUnlock()
// 为每个客服客户端创建消息并发送
for _, client := range allAgents {
message := &Message{
ConversationID: client.conversationID, // 使用客户端连接的对话ID
Type: messageType,
Data: data,
}
select {
case client.send <- message:
default:
// 如果发送失败(客户端可能已经断开),关闭连接
log.Printf("⚠️ 发送消息到客服失败: 对话ID=%d, 客户端断开", client.conversationID)
close(client.send)
h.mu.Lock()
if clients, ok := h.conversations[client.conversationID]; ok {
delete(clients, client)
if len(clients) == 0 {
delete(h.conversations, client.conversationID)
}
}
h.mu.Unlock()
}
}
}
+823
View File
@@ -0,0 +1,823 @@
# 开发日志
> 📋 待实现需求清单请查看:[待实现需求清单.md](./待实现需求清单.md)
## 2025-01-13 12:00:00 UTC
### 完成的工作
1. **实现客服个人资料管理功能**
- **后端**
- User 模型增加字段:`avatar_url`(头像URL)、`nickname`(昵称)、`email`(邮箱)、`created_at``updated_at`
- 创建文件存储服务(`backend/infra/storage.go`):本地存储服务,可扩展为云存储
- 创建个人资料服务(`backend/service/profile_service.go`):提供获取、更新个人资料和上传头像功能
- 创建个人资料控制器(`backend/controller/profile_controller.go`):处理 HTTP 请求
- 新增接口:
- `GET /agent/profile/:user_id`:获取个人资料
- `PUT /agent/profile/:user_id`:更新个人资料(昵称、邮箱)
- `POST /agent/avatar/:user_id`:上传头像(支持 jpg、png、gif,最大 10MB
- 配置静态文件服务:`/uploads` 路径用于访问上传的头像等文件
- **前端**
- 创建个人资料 API 服务(`frontend/features/agent/services/profileApi.ts`
- 创建个人资料 Hook`frontend/features/agent/hooks/useProfile.ts`):管理个人资料状态
- 创建个人资料弹窗组件(`frontend/components/dashboard/ProfileModal.tsx`):
- 显示和编辑昵称、邮箱
- 上传头像(支持预览、上传进度、错误提示)
- 实时更新个人资料
- 更新 DashboardHeader:显示头像和设置按钮,点击打开个人资料弹窗
- 创建头像工具函数(`frontend/utils/avatar.ts`):
- `getAvatarUrl`:拼接完整的头像 URL
- `getAvatarColor`:根据种子值生成头像颜色
- `getAvatarInitial`:获取头像显示文本(首字母)
- 集成个人资料功能到 DashboardShell
- **功能特性**
- 支持头像上传(jpg、png、gif,最大 10MB
- 支持修改昵称和邮箱
- 头像实时预览
- 如果没有上传头像,显示彩色圆形头像(基于用户ID生成颜色)
- 头像显示在 DashboardHeader 中
- **技术要点**
- 文件存储采用可扩展设计,目前使用本地存储,后续可切换为云存储(OSS、S3 等)
- 头像 URL 拼接逻辑:如果后端返回相对路径,前端自动拼接 API_BASE_URL
- 头像上传支持文件类型和大小验证
- 个人资料更新实时刷新 UI
---
## 2025-11-12 19:35:00 UTC
### 完成的工作
1. **修复访客端只有回复消息时才标记客服消息为已读的问题**
- 问题:访客收到客服的消息后,如果访客不回复,消息就一直显示未读;只有访客回复后,客服端才能看到自己的消息变成了已读
- 原因:
- 访客端设置了 `disableAutoScroll={true}`,导致滚动监听被禁用
- 只有在发送消息时(触发自动滚动到底部),才会标记消息为已读
- 如果访客不回复,即使已经在底部附近查看消息,也不会标记为已读
- 解决方案:
- 移除 `disableAutoScroll` 在滚动监听 `useEffect` 中的检查,即使 `disableAutoScroll` 为 true,也应该允许通过滚动来标记消息为已读
- 优化消息列表更新时的已读标记逻辑:即使没有自动滚动,如果用户已经在底部附近,也应该标记为已读
- 这样确保:
- 访客通过滚动到底部查看消息时,会自动标记为已读
- 访客接收到新消息且已经在底部附近时,会自动标记为已读
- 访客发送消息时,也会标记为已读(保持原有行为)
- 实现方式:
- `frontend/components/dashboard/MessageList.tsx`
- 移除 `disableAutoScroll` 在第一个滚动监听 `useEffect` 中的检查
- 优化第二个消息列表更新 `useEffect` 中的已读标记逻辑:如果用户已经在底部附近(`isNearBottom`),即使没有自动滚动,也应该标记为已读
---
## 2025-11-12 19:30:00 UTC
### 完成的工作
1. **修复客服端已读状态有时显示不准确的问题**
- 问题:客服端发送消息后,访客明明已经看过了,但有时候显示已读,有时候还是未读状态
- 原因:
- 当消息已存在时,`handleNewMessage` 直接返回,不会更新消息的已读状态
- 如果 `new_message` 事件在 `messages_read` 事件之后到达,会覆盖已读状态
- `handleMessagesReadBroadcast` 没有检查是否有需要更新的消息,可能进行不必要的状态更新
- 解决方案:
- 优化 `handleNewMessage`:当消息已存在时,更新消息内容(包括已读状态),确保保持最新的已读状态
- 优化 `handleMessagesReadBroadcast`:增加检查是否有需要更新的消息,避免不必要的状态更新
- 这样确保:
- 如果消息已经被标记为已读,即使后续收到 `new_message` 事件,也会保持已读状态
- 如果消息列表中没有需要更新的消息,不会触发不必要的状态更新
- 实现方式:
- `frontend/features/agent/hooks/useMessages.ts`
- 在 `handleNewMessage` 中,当消息已存在时,更新消息内容(包括已读状态)
- 在 `handleMessagesReadBroadcast` 中,增加检查是否有需要更新的消息,避免不必要的状态更新
---
## 2025-11-12 15:25:00 UTC
### 完成的工作
1. **修复客服端已读状态不实时更新的问题**
- 问题:客服端发送消息后,必须手动刷新网页,消息才会变成已读状态
- 原因:
- 后端在广播 `messages_read` 事件时,payload 中没有包含 `conversation_id`
- 前端的 `handleMessagesReadBroadcast` 在更新消息列表时,没有检查 `conversation_id` 是否匹配当前对话
- 解决方案:
- 后端:在 `messages_read` 事件的 payload 中添加 `conversation_id` 字段
- 前端:在 `handleMessagesReadBroadcast` 中,只有当 `conversation_id === conversationId` 时才更新消息列表
- 这样确保只有当前对话的消息才会被更新,避免误更新其他对话的消息
- 实现方式:
- `backend/service/message_service.go`
- 在 `MarkMessagesRead` 方法中,在广播 `messages_read` 事件时,在 payload 中添加 `conversation_id` 字段
- `frontend/features/agent/hooks/useMessages.ts`
- 在 `handleMessagesReadBroadcast` 中,添加 `conversation_id === conversationId` 的检查,只有当匹配时才更新消息列表
---
## 2025-11-12 15:20:00 UTC
### 完成的工作
1. **修复访客端发送消息后不自动滚动的问题**
- 问题:访客端发送完消息后不会自动滚动到底部
- 原因:访客端使用了 `disableAutoScroll={true}`,导致整个滚动逻辑被禁用
- 解决方案:
- 修改 `disableAutoScroll` 的行为:不再完全禁用滚动逻辑
- 当 `disableAutoScroll` 为 true 时,只禁用"收到对方消息时的自动滚动"
- 当最后一条消息是自己发送的时,无论 `disableAutoScroll` 是什么值,都会自动滚动到底部
- 这样确保:
- 查看历史消息时(`disableAutoScroll` 为 true),收到对方消息不会自动滚动
- 自己发送消息后,无论 `disableAutoScroll` 是什么值,都会自动滚动到底部
- 实现方式:
- `frontend/components/dashboard/MessageList.tsx`
- 移除 `disableAutoScroll``useEffect` 开头的早期返回
- 修改滚动判断逻辑:`shouldAutoScroll = hasNewMessage && (isLastMessageFromCurrentUser || (!disableAutoScroll && isNearBottom))`
- 这样确保自己发送的消息总是会触发滚动,而对方发送的消息只有在 `disableAutoScroll` 为 false 且在底部附近时才会滚动
---
## 2025-11-12 15:15:00 UTC
### 完成的工作
1. **优化滚动逻辑,改善查看历史消息的体验**
- 问题:在查看历史消息时,收到对方发送的新消息会自动滚动到底部,打断用户的浏览
- 需求:
- 查看历史消息时,收到对方消息不应该自动滚动到底部
- 自己发送消息后,应该自动滚动到底部
- 解决方案:
- 在消息更新时,通过比较消息ID和数量检查是否有新消息
- 使用 `requestAnimationFrame` 确保 DOM 已更新后再检查位置
- 在 DOM 更新后检查当前位置(距离底部 < 100px 视为在底部附近)
- 滚动逻辑:
1. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部
2. 如果最后一条消息是对方发送的,只有在底部附近时才自动滚动到底部(保持底部状态)
3. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
- 这样确保:
- 查看历史消息时(不在底部),收到对方消息不会自动滚动,不会打断浏览
- 自己发送消息后,会自动滚动到底部,可以看到自己发送的消息
- 如果已经在底部查看最新消息,收到对方消息时保持滚动到底部
- 实现方式:
- `frontend/components/dashboard/MessageList.tsx`
- 添加 `lastMessageIdRef``lastMessageCountRef` 来跟踪最后一条消息
- 在消息更新时检查是否有新消息(通过比较消息ID和数量)
- 使用 `requestAnimationFrame` 确保 DOM 已更新后再检查位置和决定是否滚动
- 优化滚动逻辑,区分自己发送的消息和对方发送的消息
- 在 `requestAnimationFrame` 回调中从 `containerRef.current` 重新获取容器,确保使用最新的 DOM 元素
---
## 2025-11-12 15:00:00 UTC
### 完成的工作
1. **修复已读状态逻辑问题**
- 问题1:访客端的已读状态不更新,即使客服已经读取了消息,访客端仍然显示未读,需要刷新页面才能显示已读
- 问题2:客服端的已读逻辑不正确,加载消息时自动标记为已读,但实际上应该在滚动到底部时才标记为已读
- 问题3:访客端收到客服消息时自动标记为已读,但实际上应该在滚动到底部时才标记为已读
- 解决方案:
- 移除加载消息时自动标记为已读的逻辑(客服端和访客端)
- 移除收到新消息时自动标记为已读的逻辑(客服端和访客端)
- 在 `MessageList` 组件中添加滚动检测逻辑,当用户滚动到底部附近(距离底部 < 100px)时,延迟 500ms 后标记未读消息为已读
- 当消息列表更新且自动滚动到底部时,延迟 800ms 后标记未读消息为已读(避免频繁调用,至少间隔 2 秒)
- 修复访客端的 WebSocket `messages_read` 事件处理,确保正确更新已读状态
- 修复客服端的 WebSocket `messages_read` 事件处理,确保只更新客服消息的已读状态(当 `reader_is_agent === false` 时)
- 实现方式:
- `frontend/app/chat/page.tsx`:移除自动标记为已读的逻辑,添加 `onMarkMessagesRead` 回调
- `frontend/app/agent/chat/[conversationId]/page.tsx`:移除自动标记为已读的逻辑,修复 `handleMessagesReadEvent` 函数
- `frontend/features/agent/hooks/useMessages.ts`:移除自动标记为已读的逻辑
- `frontend/components/dashboard/MessageList.tsx`:添加滚动检测逻辑,当滚动到底部时标记消息为已读
- `frontend/components/dashboard/DashboardShell.tsx`:传递 `onMarkMessagesRead` 回调给 `MessageList` 组件
---
## 2025-11-12 14:45:00 UTC
### 完成的工作
1. **优化日志记录,仅保留关键错误**
- 问题:日志过于详细,包含大量正常流程的日志,影响性能和可读性
- 解决方案:移除详细日志,仅保留关键错误和警告日志
- 实现方式:
- 后端:移除正常流程的详细日志(收到发送消息请求、消息创建成功、客户端连接、客户端断开、广播消息等)
- 后端:保留关键错误日志(创建消息失败、WebSocket Hub 为空、广播消息失败、发送消息失败、WebSocket 读取错误、WebSocket 写入错误、发送 ping 失败)
- 后端:保留关键警告日志(客户端断开时未找到、客户端断开时对话不存在、发送消息失败)
- 前端:移除正常流程的详细日志(发送消息、消息发送成功、收到 WebSocket 消息、处理 WebSocket 消息、处理新消息、处理已读事件、添加新消息等)
- 前端:保留关键错误日志(发送消息失败、解析 WebSocket 消息失败、WebSocket 错误、创建 WebSocket 连接失败、WebSocket 重连次数已达上限)
- 前端:移除连接成功的日志(WebSocket 连接成功、WebSocket 连接关闭等)
2. **修复通道关闭问题**
- 问题:通道可能被重复关闭,导致 panic
- 解决方案:在关闭通道前检查通道是否已经关闭
- 实现方式:
- 在 `Hub.unregister` 中,使用 `select` 检查通道是否已经关闭
- 如果通道已经关闭,不再关闭
- 如果通道未关闭,关闭它
3. **优化 WebSocket 连接管理**
- 问题:在开发模式下,大量 WebSocket 连接可能导致问题
- 解决方案:改进断开连接逻辑,确保连接正确关闭
- 实现方式:
- 在 `WSClient.disconnect` 中,设置 `reconnectAttempts = maxReconnectAttempts` 避免重连
- 在关闭连接前,检查连接状态
- 移除断开连接的详细日志
---
## 2025-11-12 14:30:00 UTC
### 完成的工作
1. **添加详细日志用于调试消息广播问题**
- 问题:用户报告访客端发送消息后,客服端没有收到(后续确认消息发送正常)
- 解决方案:添加详细的日志来跟踪消息发送和广播过程,便于未来调试
- 注意:后续优化为仅保留关键错误日志(见 2025-11-12 14:45:00 UTC
### 技术细节
- 修改文件:
- `backend/controller/message_controller.go`:添加消息创建日志
- `backend/service/message_service.go`:添加消息广播日志
- `backend/websocket/hub.go`:添加广播消息日志、修复通道关闭问题
- `backend/websocket/client.go`:添加 WebSocket 发送消息日志、修复通道关闭问题
- `frontend/features/agent/services/messageApi.ts`:添加消息发送日志
- `frontend/app/chat/page.tsx`:添加消息发送和处理日志
- `frontend/features/agent/hooks/useMessages.ts`:添加消息处理日志
- `frontend/lib/websocket.ts`:添加 WebSocket 消息接收日志、优化断开连接逻辑
### 调试步骤
1. **测试消息发送**
- 在访客端发送消息
- 查看浏览器控制台日志:
- 应该看到 `📨 开始发送消息: 对话ID=X, 内容="..."`
- 应该看到 `📤 发送消息: 对话ID=X, 是客服=false, 发送者ID=0, 内容长度=X`
- 应该看到 `✅ 消息发送成功: 对话ID=X`
- 查看后端日志:
- 应该看到 `📨 收到发送消息请求: 对话ID=X, 发送者ID=0, 是客服=false, 内容长度=X`
- 应该看到 `✅ 消息创建成功: 消息ID=X, 对话ID=X, 已广播`
- 应该看到 `📤 准备通过 WebSocket 广播消息: 消息ID=X, 对话ID=X`
- 应该看到 `📤 准备广播消息: 对话ID=X, 类型=new_message`
- 应该看到 `📢 广播消息: 对话ID=X, 类型=new_message, 客户端数=X`
- 应该看到 `📤 WebSocket 消息已发送: 对话ID=X, 类型=new_message, 是访客=false`(客服端)
- 应该看到 `✅ 消息广播完成: 对话ID=X, 成功=X, 失败=0`
2. **测试消息接收**
- 在客服端查看浏览器控制台日志:
- 应该看到 `📨 收到 WebSocket 消息: 对话ID=X, 类型=new_message`
- 应该看到 `📨 处理 WebSocket 消息(客服端): 对话ID=X, 类型=new_message`
- 应该看到 `📨 处理新消息(客服端): {...}`
- 应该看到 `✅ 添加新消息: 消息ID=X, 内容="..."`
3. **如果消息没有发送**
- 检查浏览器控制台是否有错误
- 检查网络请求(Network 标签)是否有 `POST /messages` 请求
- 检查请求状态码(应该是 200)
4. **如果消息发送了但没有广播**
- 检查后端日志是否有 `📤 准备通过 WebSocket 广播消息` 日志
- 检查后端日志是否有 `📢 广播消息` 日志
- 检查后端日志是否有 `⚠️ WebSocket Hub 为空` 日志(如果有,说明 Hub 没有正确初始化)
5. **如果消息广播了但没有收到**
- 检查后端日志是否有 `📤 WebSocket 消息已发送` 日志
- 检查后端日志是否有 `⚠️ 发送消息失败` 日志
- 检查前端日志是否有 `📨 收到 WebSocket 消息` 日志
- 检查 WebSocket 连接是否正常(查看浏览器 Network 标签中的 WebSocket 连接)
### 后续优化
- 检查开发模式下大量 WebSocket 连接的问题(可能是 Next.js 热重载导致的)
- 优化连接管理,减少重复连接
- 添加连接数限制,防止连接数过多
---
## 2025-11-12 14:00:00 UTC
### 完成的工作
1. **修复 WebSocket 错误处理问题**
- 问题:WebSocket 连接错误显示为 `{}`,错误信息不够详细
- 解决方案:改进错误处理,提供更详细的错误信息
- 实现方式:
- 在 `onerror` 事件中检查 `readyState``url`,提供详细的错误信息
- 在 `onclose` 事件中获取关闭代码和原因,提供详细的关闭信息
- 只有在非正常关闭时才尝试重连(避免在开发模式下频繁重连)
- 使用 `useRef` 存储回调函数,避免因回调函数变化导致重新连接
- 在连接前检查是否已存在连接,避免重复连接
### 技术细节
- 修改文件:
- `frontend/lib/websocket.ts`:改进错误处理和关闭处理
- `frontend/features/agent/hooks/useWebSocket.ts`:使用 `useRef` 存储回调函数
- `frontend/app/chat/page.tsx`:明确设置 `isVisitor: true`
- 实现原理:
- 在 `onerror` 事件中,检查 `readyState``url`,提供详细错误信息
- 在 `onclose` 事件中,获取关闭代码(`code`)、原因(`reason`)和是否干净关闭(`wasClean`
- 只有在 `!wasClean && code !== 1000` 时才尝试重连
- 使用 `useRef` 存储回调函数,避免因回调函数变化导致 `useEffect` 重新执行
- 在 `connect()` 方法中,检查是否已存在连接,如果存在则先断开
- ✅ `npm run lint`frontend,无警告)
### 错误处理改进
- ✅ 提供详细的错误信息(状态、URL等)
- ✅ 提供详细的关闭信息(关闭代码、原因、是否干净关闭)
- ✅ 避免在开发模式下频繁重连
- ✅ 避免因回调函数变化导致重新连接
- ✅ 避免重复连接
### 测试状态
✅ 手动验证:WebSocket 错误处理和关闭处理正常,错误信息详细
---
## 2025-11-12 13:30:00 UTC
### 完成的工作
1. **修复输入框失去焦点问题**
- 问题:在访客端和客服端,发送完一条消息后,输入框失去焦点,需要再次点击输入框才能继续输入
- 解决方案:在 `MessageInput` 组件中添加自动聚焦功能
- 实现方式:
- 使用 `useRef` 引用输入框元素
- 使用 `useEffect` 监听 `sending` 状态变化
- 当 `sending``true` 变为 `false` 时(发送完成),自动聚焦到输入框
- 使用 `setTimeout` 确保 DOM 更新完成后再聚焦
### 技术细节
- 修改文件:
- `frontend/components/dashboard/MessageInput.tsx`:添加自动聚焦功能
- 实现原理:
- 使用 `useRef` 创建输入框引用 `inputRef`
- 使用 `useRef` 记录上一次的 `sending` 状态 `prevSendingRef`
- 在 `useEffect` 中监听 `sending` 状态变化
- 当 `prevSendingRef.current === true && sending === false` 时,说明刚刚发送完成
- 调用 `inputRef.current?.focus()` 聚焦到输入框
- 使用 `setTimeout(..., 0)` 确保 DOM 更新完成后再聚焦
- ✅ `npm run lint`frontend,无警告)
### 测试状态
✅ 手动验证:发送消息后,输入框自动聚焦,可以直接继续输入
### 用户体验改进
- ✅ 发送消息后,输入框自动聚焦,无需再次点击
- ✅ 用户可以连续发送多条消息,无需每次点击输入框
- ✅ 提升聊天体验,减少操作步骤
---
## 2025-11-12 13:00:00 UTC
### 完成的工作
1. **更新测试指南文档**
- 添加完整的测试指南,覆盖所有已实现功能
- 添加访客端测试流程(8个测试项)
- 添加客服端测试流程(16个测试项)
- 添加实时通信测试(WebSocket 实时推送、已读状态同步、在线状态更新)
- 添加搜索功能测试(关键词高亮、自动定位)
- 添加访客信息测试(信息收集、联系信息编辑)
- 添加界面交互测试(滚动行为、响应式布局)
- 添加错误处理测试(网络错误、数据验证)
- 添加性能测试(加载性能、实时性能)
- 添加兼容性测试(浏览器兼容、数据持久化)
- 添加快速测试流程(核心功能验证,约30分钟)
- 添加高级测试场景(8个场景:多访客、并发、长时间连接、网络中断、大量消息、搜索性能、并发编辑、多访客状态)
- 添加调试技巧(浏览器开发者工具、控制台、网络请求、后端日志)
- 添加常见问题(10个常见问题及解决方案)
- 添加完整测试检查清单(8个测试类别,100+ 测试项)
- 添加测试结果记录模板
### 技术细节
- 修改文件:
- `doc/测试指南.md`:完整重写,添加所有已实现功能的测试指南
- 文档结构:
- 一、准备工作(数据库配置、快速开始)
- 二、启动后端
- 三、启动前端
- 四、访客端测试流程(8个测试项)
- 五、客服端测试流程(16个测试项)
- 六、调试技巧
- 七、常见问题(10个常见问题)
- 八、完整测试检查清单(8个测试类别)
- 九、快速测试流程(核心功能验证)
- 十、高级测试场景(8个场景)
- 十一、测试结果记录
### 测试覆盖
- ✅ 基础功能测试(访客端、客服端)
- ✅ 实时通信测试(WebSocket 实时推送、已读状态同步、在线状态更新)
- ✅ 搜索功能测试(关键词高亮、自动定位)
- ✅ 访客信息测试(信息收集、联系信息编辑)
- ✅ 界面交互测试(滚动行为、响应式布局)
- ✅ 错误处理测试(网络错误、数据验证)
- ✅ 性能测试(加载性能、实时性能)
- ✅ 兼容性测试(浏览器兼容、数据持久化)
- ✅ 高级测试场景(多访客、并发、长时间连接、网络中断、大量消息、搜索性能、并发编辑、多访客状态)
### 后续优化
- 添加自动化测试(E2E 测试)
- 添加性能测试报告
- 添加测试覆盖率报告
---
## 2025-11-12 12:00:00 UTC
### 完成的工作
1. **对话状态管理(在线/离线)实时更新**
- 后端:WebSocket 连接建立时标记访客在线,断开时标记离线
- 后端:通过 WebSocket 推送 `visitor_status_update` 事件到客服端
- 后端:在 `ConversationService` 中添加 `UpdateVisitorOnlineStatus``UpdateLastSeenAt` 方法
- 后端:在 `Hub` 中添加回调机制,在客户端连接/断开时调用回调函数
- 后端:在 `Client` 中添加 `isVisitor` 字段,区分访客和客服
- 前端:WebSocket 客户端添加 `isVisitor` 参数,默认值为 `true`
- 前端:客服端 WebSocket 连接设置 `isVisitor=false`
- 前端:客服端接收 `visitor_status_update` 事件,刷新对话详情
- 前端:在对话列表中显示在线/离线图标(绿色圆点表示在线)
### 技术细节
- 修改文件:
- 后端:`backend/service/conversation_service.go``backend/websocket/hub.go``backend/websocket/client.go``backend/websocket/handler.go``backend/main.go`
- 前端:`frontend/lib/websocket.ts``frontend/features/agent/hooks/useWebSocket.ts``frontend/features/agent/hooks/useMessages.ts``frontend/features/agent/types.ts``frontend/components/dashboard/ConversationListItem.tsx`
- ✅ `npm run lint`frontend,无警告)
- ✅ `gofmt`backend,无错误)
### 实现原理
- 当访客连接 WebSocket 时,后端会调用 `UpdateVisitorOnlineStatus(conversationID, true)` 更新在线状态
- 当访客断开 WebSocket 时,后端会检查该对话是否还有其他访客连接,如果没有,则调用 `UpdateVisitorOnlineStatus(conversationID, false)` 更新离线状态
- 后端通过 WebSocket 广播 `visitor_status_update` 事件到该对话的所有客户端(包括客服)
- 客服端在收到 `visitor_status_update` 事件时,刷新当前对话详情,更新在线状态
- 对话列表中显示在线/离线图标(基于 `status === "open"` 判断)
### 测试状态
✅ 手动验证:访客连接/断开 WebSocket 时,客服端实时更新在线状态
### 后续优化
- 实现心跳机制(定期更新 `last_seen_at`
- 根据 `last_seen_at` 判断是否在线(例如,如果 `last_seen_at` 在最近 60 秒内,则认为在线)
- 在 `ConversationSummary` 中添加 `last_seen_at` 字段,以便在对话列表中显示最后活跃时间
- 定期轮询对话列表,更新所有对话的状态
---
## 2025-11-12 11:10:00 UTC
### 完成的工作
1. **修复已读状态同步问题**
- 访客端:修复 `handleMessagesReadEvent` 未判断 `reader_is_agent`,导致客服读取访客消息后,访客端无法更新已读状态
- 客服端:修复 `handleMessagesReadBroadcast` 未判断 `reader_is_agent`,导致访客读取客服消息后,客服端无法更新已读状态
- 访客端:只有当 `reader_is_agent === true` 时,才更新访客消息(`sender_is_agent === false`)的已读状态
- 客服端:只有当 `reader_is_agent === false` 时,才更新客服消息(`sender_is_agent === true`)的已读状态
### 技术细节
- 修改文件:`frontend/app/chat/page.tsx``frontend/features/agent/hooks/useMessages.ts`
- ✅ `npm run lint`frontend,无警告)
### 问题原因
- 后端通过 WebSocket 推送 `messages_read` 事件时,会包含 `reader_is_agent` 字段,表示读取者是客服还是访客
- 前端在接收 `messages_read` 事件时,没有判断 `reader_is_agent`,导致错误地更新了消息的已读状态
- 对于访客端:只有当客服读取了访客的消息(`reader_is_agent === true`)时,才应该更新访客消息的已读状态
- 对于客服端:只有当访客读取了客服的消息(`reader_is_agent === false`)时,才应该更新客服消息的已读状态
### 测试状态
✅ 手动验证:访客发送消息后,客服查看消息,访客端显示双对勾(已读状态)
---
## 2025-11-11 08:00:00 UTC
### 完成的工作
1. **访客联系信息编辑闭环**
- 后端新增 `PUT /conversations/:id/contact` 接口,`ConversationService.UpdateConversationContact` 落库邮箱/电话/备注
- 前端 `VisitorDetailPanel` 增加弹窗编辑,支持新增、修改、清空邮箱/电话/备注并即时刷新
2. **服务与 Hook 扩展**
- `conversationApi.updateConversationContact` 封装更新接口,统一返回结构
- `useMessages` 暴露 `updateContactInfo``DashboardShell``VisitorDetailPanel` 通过钩子完成联动
### 技术细节
- 新增/修改文件:`backend/controller/conversation_controller.go``backend/service/conversation_service.go``backend/router/router.go``backend/service/types.go`
- 前端涉及文件:`features/agent/services/conversationApi.ts``features/agent/hooks/useMessages.ts``components/dashboard/VisitorDetailPanel.tsx`
- ✅ `npm run lint`frontend
### 测试状态
✅ 手动验证:客服工作台编辑邮箱/电话/备注,数据保存后右栏即时更新
---
## 2025-11-10 07:30:00 UTC
### 完成的工作
1. **客服工作台前端架构拆分**
- `app/agent/dashboard/page.tsx` 只保留页面入口,改由 `DashboardShell` 负责布局编排
- 新增 `components/dashboard/*`,将导航栏、会话列表、消息区、访客详情拆分为独立组件
- 新增 `features/agent/hooks``features/agent/services`,分别承载状态逻辑与 API 调用
- 新增 `utils/format.ts``utils/highlight.tsx``utils/storage.ts`,统一时间格式、关键词高亮与本地存储操作
2. **会话/消息状态管理优化**
- `useAuth` 统一处理本地登录信息与退出逻辑
- `useConversations` 负责对话列表、搜索、防抖与排序
- `useMessages` + `useWebSocket` 负责消息拉取、已读回执、WebSocket 广播与高亮定位
3. **TypeScript 类型补全**
- `features/agent/types.ts` 汇总会话、消息、用户等公共类型
- `lib/websocket.ts``useWebSocket``useMessages` 改用强类型定义,消除 `any`
4. **旧版客服聊天页迁移**
- `/agent/chat/[conversationId]` 复用统一的消息组件、输入框与 WebSocket 逻辑
- 接入服务层 API 与 Hook,移除旧版冗余状态/样式代码
- 支持快速返回工作台,交互体验与四栏布局保持一致
5. **访客聊天页重构**
- `/chat` 页面改用统一的 `MessageList``MessageInput` 组件和消息服务
- 对话初始化、WebSocket、已读回执与客服端共享实现,减少重复逻辑
- 保留访客视角的气泡样式与默认提示,UI/状态与客服端保持一致
### 技术细节
- 新增目录:`components/dashboard/``features/agent/hooks/``features/agent/services/``utils/`
- 复用工具函数:消息预览截断、时间格式化、关键词高亮、localStorage 操作
- ESLint`npm run lint` 通过
### 测试状态
`npm run lint`frontend,无警告)
### 下一步计划
- 继续补充自动化测试(组件级、hook 级单元测试)
- 为 `services` 层补充基础错误处理与重试策略
- 为文件上传、AI 客服等后续功能预留组件与 Hook 模板
## 2025-11-10 05:00:00 UTC
### 完成的工作
1. **访客信息采集落地**
- 后端 `InitConversation` 接口接收并保存网站、来源、浏览器、系统、语言、IP 等信息
- 访客前端自动采集页面 URL、Referrer、User-Agent、语言信息并随初始化请求提交
- 对话表新增字段,支持持久化访客技术信息及联系信息占位
- 会话 `last_seen_at` 字段初始化,便于后续在线状态展示
2. **系统消息写入与展示**
- 消息表新增 `message_type` 字段,区分普通消息与系统消息
- 新对话自动生成系统消息(访问入口、来源页面)
- 客服工作台中,系统消息以灰色气泡居中展示,支持关键词高亮与定位
3. **客服工作台访客详情完善**
- 新增 `GET /conversations/:id` 接口返回完整访客信息
- 右侧访客详情面板展示网站、来源、浏览器、系统、语言、IP、最后活跃等数据
- 联系信息区域展示实际数据(暂无信息时提供提示),保留后续编辑入口
- 聊天头部显示更准确的 last seen 信息
4. **搜索体验优化**
- 搜索匹配的系统消息支持高亮与自动定位
- WebSocket 收到新消息时自动刷新会话详情,保持访客信息实时
5. **消息已读/未读状态(基础版)**
- 数据库新增 `is_read` / `read_at` 字段,支持已读记录
- 新增 `PUT /messages/read` 接口及 WebSocket `messages_read` 事件,同步状态
- 客服端/访客端聊天气泡显示单/双对勾,已读回执实时可见
- 对话列表同步显示最后一条消息的已读状态
### 技术细节
- MySQL 表结构:`conversations` 新增多项访客字段,`messages` 新增 `message_type`
- 后端:新增 `ConversationDetailRes``GetConversationDetail`,并统一时间格式输出
- 前端:新增访客详情状态管理、系统消息渲染分支、技术信息展示组件
- WebSocket:收到新消息后同步刷新会话详情,确保右侧数据与消息流一致
### 测试状态
✅ 通过手动测试:访问 `/chat` 生成新对话,确认数据库记录访客信息与系统消息
✅ 通过客服工作台验证:系统消息样式正常,访客详情与数据库数据一致
✅ 搜索“关键词”后点击结果,可自动定位系统消息并高亮
### 下一步计划
- 实现客服端联系信息的手动添加/编辑
- 基于 `last_seen_at` 和 WebSocket 心跳完成在线状态实时更新
- 继续扩展系统消息(如客服加入/离开、对话状态变化)
- 访客位置字段对接外部服务(基于 IP 定位)
## 2025-01-16 18:00:00 UTC
### 完成的工作
1. **客服工作台四栏布局实现**
- 实现最左侧导航菜单栏(固定宽度 64px,浅灰色背景)
- 实现左侧对话列表栏(固定宽度 320px,显示所有对话)
- 实现中间聊天内容栏(自适应宽度,集成完整聊天功能)
- 实现右侧访客详情栏(固定宽度 320px,显示联系信息和技术信息)
- 统一顶部栏高度(h-16),确保三栏对齐
2. **中间栏聊天功能集成**
- 集成消息显示功能(客服消息在右,访客消息在左)
- 集成消息发送功能(支持实时发送)
- 集成 WebSocket 实时通信(自动接收新消息)
- 实现自动滚动到底部(新消息自动可见)
- 实现智能时间格式化(今天显示时间,更早显示日期+时间)
- 实现消息加载状态和发送状态
3. **右侧栏访客详情实现**
- 显示访客头像(基于 visitor_id 生成颜色)
- 显示在线/离线状态(基于对话状态)
- 显示联系信息(邮箱、电话、备注,支持添加按钮)
- 显示技术信息(网站、来源、位置等,占位待实现)
- 实现刷新按钮(可刷新消息)
4. **UI 优化**
- 移除右侧栏重复的基础信息(对话ID、状态等已在左侧显示)
- 统一联系信息的交互方式(邮箱、电话、备注都有"+ Add"按钮)
- 优化导航栏颜色(与聊天内容背景一致)
- 优化对话列表显示(头像、ID、状态、时间)
### 技术细节
- 使用 React 状态管理多个对话和消息
- 使用 `useEffect` 实现对话切换时自动加载消息
- 使用 WebSocket 实现实时消息推送
- 使用 `useRef` 实现自动滚动功能
- 使用 Tailwind CSS 实现响应式布局
- 对话切换时自动清空消息列表并重新加载
### 用户体验
- 无需跳转页面,在同一页面内切换对话
- 实时接收新消息,无需手动刷新
- 消息发送后立即显示,体验流畅
- 界面布局清晰,信息层次分明
- 右侧栏专注于显示左侧看不到的详细信息
### 测试状态
✅ 功能测试通过:四栏布局、对话切换、消息发送、实时通信均正常工作
### 下一步计划
- 实现会话搜索功能(左侧栏搜索框)
- 实现客服个人资料管理(头像上传、信息修改)
- 实现访客信息自动收集(技术信息、来源页面等)
- 实现对话状态管理(在线/离线状态实时更新)
## 2025-01-15 21:00:00 UTC
### 完成的工作
1. **WebSocket 实时通信功能**
- 实现后端 WebSocket Hub 管理器:管理所有客户端连接,按对话ID分组
- 实现 WebSocket 客户端处理:处理连接、心跳检测、消息发送
- 实现 WebSocket 路由处理:升级 HTTP 连接为 WebSocket 连接
- 集成消息推送:消息创建后自动通过 WebSocket 推送给所有相关客户端
- 实现前端 WebSocket 客户端:封装连接、自动重连、消息接收
- 访客端和客服端都支持实时消息推送
2. **技术实现**
- 后端使用 `gorilla/websocket`
- 前端使用原生 WebSocket API
- 实现心跳检测(Ping/Pong)保持连接活跃
- 实现自动重连机制(最多 5 次)
- 消息去重,避免重复显示
3. **文档更新**
- 创建 `doc/WebSocket学习笔记.md`:详细解释 WebSocket 工作原理
- 包含前后端代码示例和完整流程说明
### 技术细节
- WebSocket 路由:`/ws?conversation_id=<对话ID>`
- 消息格式:`{ type: "new_message", conversation_id: number, data: Message }`
- 连接升级:HTTP 连接升级为 WebSocket 连接(101 状态码)
- 心跳间隔:每 54 秒发送一次 Ping
- 重连策略:断开后等待 3 秒,最多重试 5 次
### 用户体验改进
- ✅ 访客发送消息后,客服立即看到(无需刷新)
- ✅ 客服发送消息后,访客立即看到(无需刷新)
- ✅ 真正的实时双向通信
- ✅ 连接断开后自动重连
### 测试状态
✅ 功能实现完成,待测试
## 2025-01-15 20:00:00 UTC
### 完成的工作
1. **客服端功能完整实现**
- 实现客服登录页面(`/`):使用默认管理员账号(admin/admin123)登录
- 实现对话列表页面(`/agent/conversations`):显示所有未关闭的对话,支持点击进入聊天
- 实现客服聊天页面(`/agent/chat/[conversationId]`):客服可以查看和回复访客消息
- 实现登录状态检查:未登录自动跳转到登录页
- 实现退出登录功能:清除登录状态并跳转
2. **消息显示优化**
- 客服端消息布局:客服消息在右侧(蓝色气泡),访客消息在左侧(白色气泡)
- 从客服视角优化 UI,符合客服使用习惯
3. **后端功能完善**
- 实现默认管理员账号自动创建(首次启动时创建 admin/admin123
- 实现对话列表查询接口(`GET /conversations`):返回所有未关闭的对话
- 实现创建客服账号接口(`POST /admin/users`):管理员可以创建新的客服/管理员账号
- 实现退出登录接口(`POST /logout`):用于前端清除登录状态
4. **文档更新**
- 更新测试指南:添加完整的客服端测试流程
- 更新 CHANGELOG:记录客服端开发过程
### 技术细节
- 使用 `localStorage` 存储客服登录信息(`agent_user_id``agent_username``agent_role`
- 使用 Next.js 路由保护:检查登录状态,未登录自动跳转
- 客服聊天页面复用访客聊天页面的核心逻辑,调整消息显示位置
- 后端使用 `initDefaultAdmin` 函数在首次启动时自动创建默认管理员
### 用户体验
- 客服登录流程简单:使用默认账号即可登录
- 对话列表清晰:显示对话状态、访客ID、时间等信息
- 客服聊天界面直观:客服消息在右,访客消息在左
- 登录状态管理完善:未登录自动跳转,退出登录清除状态
### 测试状态
✅ 功能测试通过:登录、对话列表、客服聊天、退出登录均正常工作
### 下一步计划
- 添加实时消息推送(WebSocket)
- 添加对话状态管理(关闭对话、重新打开等)
- 优化 UI 和用户体验
## 2025-01-15 18:00:00 UTC
### 完成的工作
1. **访客聊天页面完整实现**
- 实现消息发送功能(`POST /messages`
- 实现消息拉取功能(`GET /messages`
- 实现消息列表展示(区分访客和客服消息)
- 实现自动滚动到底部(新消息自动可见)
- 实现时间格式化显示(今天显示时间,更早显示日期+时间)
- 优化 UI:删除多余标签,位置已能区分发送者
2. **代码优化和注释**
- 为所有代码添加详细的中文注释,解释每行代码的作用
- 使用通俗易懂的类比(如"数据盒子"、"书签"等)帮助理解
- 优化代码结构,确保逻辑清晰
3. **文档完善**
- 创建 `doc/前端学习笔记.md`:核心概念解释、英文单词记忆、常见错误
- 创建 `doc/测试指南.md`:完整的测试步骤和问题排查指南
- 创建 `doc/系统角色说明.md`:解释访客和客服的区别
- 修复测试指南中的 localStorage 说明(同一浏览器标签页共享)
4. **Bug修复**
- 修复 `.env` 文件 UTF-8 BOM 编码问题(godotenv 不支持 BOM
- 添加后端 `.env` 文件加载的详细调试信息
- 修复代码结构问题(消息列表和输入框位置错误)
### 技术细节
- 使用 `useState` 管理消息列表、输入框、加载状态
- 使用 `useEffect` 实现自动拉取消息和自动滚动
- 使用 `useRef` 实现自动滚动到底部的功能
- 使用 `localStorage` 持久化访客ID(同一浏览器标签页共享)
### 用户体验
- 访客无需登录,直接访问 `/chat` 即可使用
- 发送消息后自动滚动到底部,无需手动滚动
- 时间显示智能优化:今天只显示时间,更早显示日期+时间
- UI 简洁:位置已能区分发送者,无需额外标签
### 测试状态
✅ 功能测试通过:消息发送、接收、显示、自动滚动均正常工作
### 下一步计划
- 开发客服端功能(客服登录、对话列表、客服聊天页面)
- 或优化现有功能(消息状态、错误重试、UI 优化等)
## 2025-01-15 10:00:00 UTC
### 完成的工作
1. 前端配置化改进
- 创建 `frontend/lib/config.ts` 统一管理 API 地址配置
- 使用环境变量 `NEXT_PUBLIC_API_BASE_URL` 配置后端地址
- 移除前端所有硬编码 API 地址
- 支持通过 `.env.local` 配置不同环境的后端地址
2. 更新文档
- README 添加前端环境变量配置说明
- 补充部署环境切换的使用说明
### 意义
- 本地开发:无需配置,默认使用 `http://127.0.0.1:8080`
- 生产部署:只需修改 `.env.local` 中的 `NEXT_PUBLIC_API_BASE_URL` 为实际域名
- 好处:类比后端数据库配置,前端 API 配置也可通过环境变量灵活切换
## 2025-10-30 12:00:00 UTC
### 完成的工作
1. 统一后端接口路径与方法
- 新增规范路由:`POST /conversation/init``POST /messages``GET /messages`
- 删除旧路由:`/initconversation``/createmessage``/listmessage`
2. 数据库配置改为环境变量
- 从 `.env` 读取 `DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME`
- 移除硬编码 DSN,提升安全性与可配置性
3. 更新 README
- 补充接口文档与后端环境变量示例,增加 `.env` 使用说明
### 风险与注意
- 该变更为不向后兼容的变更,请确保前端使用新路由
- 需要在 `backend/.env` 正确配置数据库连接参数
## 2024-12-19 15:30:00 UTC
### 完成的工作
1. **完善对话初始化功能**
- 修复了 `InitConversation` 函数中不完整的代码逻辑
- 添加了完整的错误处理和响应返回
- 实现了查找现有对话或创建新对话的逻辑
2. **创建项目文档**
- 编写了详细的 README.md 文件
- 包含项目结构、功能说明、API接口文档
- 添加了对话初始化逻辑的详细解释
3. **代码优化**
- 统一了变量命名(req 替代 in)
- 改进了错误提示信息
- 添加了详细的中文注释
### 技术细节
- 对话初始化逻辑:先查找访客的现有开放对话,如果没有则创建新对话
- 使用 GORM 进行数据库操作
- 实现了完整的错误处理机制
### 下一步计划
- 完善发送消息功能
- 实现拉取消息功能
- 添加前端界面
+487
View File
@@ -0,0 +1,487 @@
# WebSocket 学习笔记(通俗版)
## 一、什么是 WebSocket
### 1. 类比理解
**HTTP 就像"打电话"**
- 你打电话 → 对方接听 → 你说完挂断
- 每次都要重新拨号才能通话
- 不能一直保持连接
**WebSocket 就像"对讲机"**
- 你按住按钮说话 → 对方立即听到
- 连接一直保持,随时可以说话
- 不需要每次都"拨号"
### 2. 为什么需要 WebSocket
**传统 HTTP 的问题**
- 访客发送消息 → 后端收到 → 但客服不知道有新消息
- 客服必须手动刷新页面才能看到新消息
- 无法实时推送消息
**WebSocket 的解决方案**
- 访客发送消息 → 后端收到 → 后端立即"喊话"给所有连接的客户端
- 客服不需要刷新,消息自动显示
- 真正的实时通信!
## 二、WebSocket 工作原理(整体流程)
### 1. 连接建立过程
```
1. 前端:我要连接 WebSocket(就像敲门)
ws = new WebSocket("ws://127.0.0.1:8080/ws?conversation_id=123")
2. 后端:检查请求,同意连接(就像开门)
→ 升级 HTTP 连接为 WebSocket 连接
3. 前端:连接成功!(可以开始"对话"了)
ws.onopen = () => { console.log("连接成功") }
```
**关键点**
- 第一次连接还是用 HTTP(就像敲门)
- 后端"升级"这个连接为 WebSocket(就像开门让你进来)
- 之后就可以双向通信了
### 2. 消息发送和接收
```
前端发送消息:
前端 → [HTTP POST] → 后端 → 保存到数据库 → 通过 WebSocket 广播
后端推送消息:
后端 → [WebSocket] → 所有连接的客户端 → 自动显示新消息
```
## 三、后端 WebSocket 工作原理
### 1. Hub(中心管理器)
**类比**:就像"会议室管理员"
```go
// backend/websocket/hub.go
type Hub struct {
// 每个对话ID对应哪些客户端连接
// 就像:会议室1有哪些人在里面
conversations map[uint]map[*Client]bool
// 注册新客户端(有人要加入)
register chan *Client
// 注销客户端(有人要离开)
unregister chan *Client
// 广播消息(有新消息要告诉所有人)
broadcast chan *Message
}
```
**工作流程**
```
1. 有人连接 → 发到 register 通道
2. Hub 收到 → 把这个客户端加到对应对话的列表里
3. 有新消息 → 发到 broadcast 通道
4. Hub 收到 → 找到这个对话的所有客户端 → 发送消息给每个人
```
### 2. Client(客户端连接)
**类比**:就像"会议室里的一个人"
```go
// backend/websocket/client.go
type Client struct {
hub *Hub // 属于哪个 Hub
conn *websocket.Conn // WebSocket 连接
send chan *Message // 发送消息的通道
conversationID uint // 属于哪个对话
}
```
**两个重要方法**
- `ReadPump()`:从客户端读取消息(比如心跳包)
- `WritePump()`:向客户端发送消息(比如新消息推送)
### 3. Handler(处理函数)
**类比**:就像"门卫",检查谁要进来
```go
// backend/websocket/handler.go
func HandleWebSocket(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 从 URL 获取对话ID
conversationID := c.Query("conversation_id")
// 2. 升级 HTTP 连接为 WebSocket 连接
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
// 3. 创建客户端
client := NewClient(hub, conn, conversationID)
// 4. 注册到 Hub
hub.register <- client
// 5. 启动读写循环
go client.WritePump()
go client.ReadPump()
}
}
```
**关键步骤**
1. `upgrader.Upgrade()`:把 HTTP 连接"升级"成 WebSocket 连接
2. `hub.register <- client`:告诉 Hub "我来了"
3. `go client.WritePump()`:启动一个 goroutine(后台线程)来发送消息
4. `go client.ReadPump()`:启动一个 goroutine 来接收消息
### 4. 消息推送流程
```go
// backend/service/service.go
func CreateMessage(db *gorm.DB, hub BroadcastHub) {
// 1. 创建消息
msg := models.Message{...}
db.Create(&msg)
// 2. 通过 WebSocket 推送
hub.BroadcastMessage(conversationID, "new_message", msg)
}
```
**推送过程**
```
1. CreateMessage 调用 hub.BroadcastMessage()
2. Hub 收到消息,放到 broadcast 通道
3. Hub.Run() 循环检测到有新消息
4. 找到这个对话的所有客户端
5. 把消息发送给每个客户端(通过 client.send 通道)
6. 每个客户端的 WritePump() 收到消息,通过 WebSocket 发送给前端
```
## 四、前端 WebSocket 工作原理
### 1. WebSocket 客户端类
**类比**:就像"对讲机"
```typescript
// frontend/lib/websocket.ts
class WSClient {
private ws: WebSocket | null = null;
private conversationId: number;
private onMessage?: (message: WSMessage) => void;
// 连接
connect() {
// 1. 创建 WebSocket 连接
const wsUrl = "ws://127.0.0.1:8080/ws?conversation_id=123";
this.ws = new WebSocket(wsUrl);
// 2. 设置事件监听
this.ws.onopen = () => {
console.log("连接成功");
};
this.ws.onmessage = (event) => {
// 收到消息
const message = JSON.parse(event.data);
if (this.onMessage) {
this.onMessage(message);
}
};
this.ws.onerror = (error) => {
console.error("连接错误", error);
};
this.ws.onclose = () => {
console.log("连接关闭");
// 尝试重连
this.attemptReconnect();
};
}
}
```
**关键点**
- `new WebSocket(url)`:创建连接(会自动发送 HTTP 升级请求)
- `ws.onopen`:连接成功时触发
- `ws.onmessage`:收到消息时触发
- `ws.onclose`:连接关闭时触发
### 2. 在 React 组件中使用
```typescript
// frontend/app/chat/page.tsx
useEffect(() => {
if (conversationId === null) return;
// 创建 WebSocket 客户端
const wsClient = new WSClient({
conversationId: conversationId,
// 收到消息时的回调
onMessage: (message) => {
if (message.type === "new_message") {
// 把新消息添加到消息列表
setMessages((prevMessages) => {
const newMsg = message.data;
// 检查是否已存在(避免重复)
const exists = prevMessages.some((msg) => msg.id === newMsg.id);
if (exists) return prevMessages;
// 添加到列表
return [...prevMessages, newMsg];
});
}
},
});
// 建立连接
wsClient.connect();
// 组件卸载时断开连接
return () => {
wsClient.disconnect();
};
}, [conversationId]);
```
**工作流程**
1. 页面加载时,创建 WebSocket 客户端
2. 建立连接(自动发送请求到后端)
3. 收到消息时,自动更新 React 状态(`setMessages`
4. React 自动重新渲染,显示新消息
5. 页面关闭时,断开连接
## 五、完整的消息流程(从发送到接收)
### 场景:访客发送消息,客服立即看到
```
步骤 1:访客发送消息
前端 → [HTTP POST /messages] → 后端
{
conversation_id: 123,
content: "你好",
sender_is_agent: false
}
步骤 2:后端处理
后端 → 保存到数据库 → 调用 hub.BroadcastMessage()
步骤 3Hub 广播消息
Hub → 找到对话 123 的所有客户端(访客和客服)
→ 通过 WebSocket 发送给每个客户端
步骤 4:前端接收消息
访客端:收到消息 → 自动显示(自己发的)
客服端:收到消息 → 自动显示(访客发的)
步骤 5:完成
访客看到自己的消息
客服立即看到访客的消息(无需刷新!)
```
## 六、WebSocket vs HTTP
### HTTP(传统方式)
```
访客发送消息:
前端 → [HTTP POST] → 后端 → 保存 → 返回成功
客服查看消息:
前端 → [HTTP GET /messages] → 后端 → 返回所有消息
(需要手动刷新或定时轮询)
```
**问题**
- 客服不知道什么时候有新消息
- 必须定时刷新(浪费资源)
- 不是实时的
### WebSocket(实时方式)
```
访客发送消息:
前端 → [HTTP POST] → 后端 → 保存 → [WebSocket 推送] → 客服端
客服查看消息:
客服端 → [WebSocket 连接] → 后端
后端 → [有新消息时自动推送] → 客服端 → 自动显示
```
**优势**
- 实时推送,无需刷新
- 节省资源(不需要定时轮询)
- 双向通信(可以前后端互相发送)
## 七、技术细节
### 1. 连接升级(Upgrade
**HTTP 请求头**
```
GET /ws?conversation_id=123 HTTP/1.1
Host: 127.0.0.1:8080
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
```
**HTTP 响应头**
```
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
```
**101 状态码**:表示协议切换成功(从 HTTP 切换到 WebSocket
### 2. 心跳检测(Ping/Pong
**为什么需要心跳**
- 保持连接活跃(防止被防火墙关闭)
- 检测连接是否断开
**工作原理**
```
后端 → [每 54 秒发送 Ping] → 前端
前端 → [收到 Ping,回复 Pong] → 后端
```
如果前端没有回复 Pong,后端就知道连接断开了。
### 3. 自动重连
```typescript
// frontend/lib/websocket.ts
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
return; // 超过最大重试次数,停止重连
}
this.reconnectAttempts++;
setTimeout(() => {
this.connect(); // 重新连接
}, 3000); // 等待 3 秒后重连
}
```
**重连策略**
- 连接断开时自动尝试重连
- 最多重试 5 次
- 每次等待 3 秒
## 八、记忆口诀
1. **WebSocket = 对讲机**:一直保持连接,随时可以说话
2. **HTTP = 打电话**:每次都要重新拨号
3. **Hub = 会议室管理员**:管理所有连接的人
4. **Client = 会议室里的人**:每个人都有自己的连接
5. **Upgrade = 开门**:把 HTTP 连接"升级"成 WebSocket
6. **Ping/Pong = 心跳**:保持连接活跃
7. **Broadcast = 广播**:把消息发送给所有人
## 九、常见问题
### 1. WebSocket 和 HTTP 可以同时使用吗?
**可以!** 就像:
- HTTP:用来发送消息、获取列表等
- WebSocket:用来接收实时推送
### 2. 为什么需要心跳检测?
**防止连接被关闭**
- 有些防火墙/代理会关闭长时间不活动的连接
- 心跳告诉它们"我还活着"
### 3. 如果 WebSocket 连接失败怎么办?
**自动降级**
- 可以继续使用 HTTP 轮询(定时刷新)
- 或者显示错误提示,让用户手动刷新
### 4. WebSocket 比 HTTP 快吗?
**不一定**
- WebSocket 的优势是"实时推送",不是速度
- 第一次建立连接需要时间
- 但之后推送消息非常快(不需要重新建立连接)
## 十、代码示例总结
### 后端关键代码
```go
// 1. 创建 Hub
wsHub := websocket.NewHub()
go wsHub.Run() // 启动 Hub
// 2. 注册 WebSocket 路由
r.GET("/ws", websocket.HandleWebSocket(wsHub))
// 3. 推送消息
hub.BroadcastMessage(conversationID, "new_message", msg)
```
### 前端关键代码
```typescript
// 1. 创建客户端
const wsClient = new WSClient({
conversationId: conversationId,
onMessage: (message) => {
// 处理收到的消息
},
});
// 2. 建立连接
wsClient.connect();
// 3. 断开连接(组件卸载时)
return () => {
wsClient.disconnect();
};
```
---
## 总结
**WebSocket 就像"对讲机"**
- 建立连接后,一直保持
- 后端可以随时"喊话"给前端
- 前端可以随时"回复"给后端
- 真正的实时双向通信!
**我们项目的实现**
- 后端用 Hub 管理所有连接
- 消息创建后自动推送
- 前端自动接收并显示
- 无需手动刷新,真正的实时体验!
**记住**WebSocket 不是用来替代 HTTP 的,而是用来补充 HTTP 的实时推送能力!
---
**学习建议**
1. 先理解整体流程(连接 → 发送 → 接收)
2. 再看代码实现(Hub、Client、Handler
3. 运行项目,观察日志和浏览器控制台
4. 尝试修改代码,看看效果
**调试技巧**
- 打开浏览器控制台(F12),查看 WebSocket 连接状态
- 查看后端日志,看连接和消息推送情况
- 使用 `ws.onmessage` 打印收到的消息
祝你学习愉快!🚀
+337
View File
@@ -0,0 +1,337 @@
# 前端聊天页面 - 学习笔记(通俗版)
## 一、核心概念理解
### 1. useState(数据盒子)
**类比**:就像一个小盒子,可以存东西,也可以拿出来
- `const [数据, 改数据的函数] = useState(初始值)`
- **例子**`const [input, setInput] = useState("")`
- `input` = 盒子里现在装的是什么(当前值)
- `setInput` = 改变盒子里东西的函数
- `""` = 盒子一开始是空的
**为什么需要?**
- 数据变了,页面自动更新(不用手动刷新)
### 2. useEffect(监听器)
**类比**:就像"当...的时候,执行..."
- `useEffect(() => { 做什么 }, [监听谁])`
- `[]` = 空数组,意思是"只在页面第一次加载时执行一次"
- `[某个数据]` = 当这个数据改变时执行
**为什么需要?**
- 页面加载时自动执行某些操作(比如拉取数据)
- 当数据改变时,自动执行某些操作(比如自动滚动)
### 3. useRef(指针/书签)
**类比**:就像书签,可以指向页面上某个元素
- `const ref = useRef(null)`
- `ref.current` = 指向的那个元素
**为什么需要?**
- 实现自动滚动(找到消息列表底部,滚动到那里)
### 4. async/await(等待)
**类比**:就像"等外卖"
- `async` = 这是一个需要等待的函数
- `await` = 在这里等,等到了才继续
- `fetch` = 发送网络请求(就像点外卖)
**为什么需要?**
- 网络请求需要时间,不能立即返回,所以要等
### 5. useRouter(路由导航)
**类比**:就像地图导航,告诉浏览器"我要去哪里"
- `const router = useRouter()` = 获取路由对象
- `router.push("/路径")` = 跳转到指定页面
- `router.replace("/路径")` = 跳转并替换历史记录(不能返回)
**为什么需要?**
- 登录成功后需要跳转到其他页面
- 未登录时需要跳转到登录页
### 6. WebSocket(实时通信)
**类比**:就像对讲机,可以实时双向通信
- `WSClient` = WebSocket 客户端类,封装连接逻辑
- `wsClient.connect()` = 建立连接
- `wsClient.disconnect()` = 断开连接
- `onMessage` = 收到消息时的回调函数
**为什么需要?**
- HTTP 只能客户端主动请求,不能服务器主动推送
- WebSocket 可以服务器主动推送消息,实现实时通信
- 新消息自动显示,无需手动刷新
### 7. 多状态管理(复杂组件)
**类比**:就像管理多个盒子,每个盒子存不同的数据
- 对话列表状态:`const [conversations, setConversations] = useState([])`
- 选中对话状态:`const [selectedConversationId, setSelectedConversationId] = useState(null)`
- 消息列表状态:`const [messages, setMessages] = useState([])`
- 输入框状态:`const [input, setInput] = useState("")`
**为什么需要?**
- 复杂页面需要管理多个数据
- 每个数据独立管理,互不干扰
- 数据改变时,只更新相关的 UI 部分
## 二、常用英文单词记忆
| 英文 | 中文意思 | 记忆技巧 |
|------|---------|---------|
| **state** | 状态 | 记住:state = 状态(盒子里的数据状态) |
| **effect** | 效果/影响 | 记住:effect = 当...的时候产生的效果 |
| **ref** | 引用/指针 | 记住:ref = reference(引用),指向某个东西 |
| **async** | 异步的 | 记住:async = asynchronized(异步),需要等待 |
| **await** | 等待 | 记住:await = wait(等待) |
| **fetch** | 获取 | 记住:fetch = 去拿(去后端拿数据) |
| **try** | 尝试 | 记住:try = 尝试(试试看能不能成功) |
| **catch** | 抓住 | 记住:catch = 抓住(如果出错了,抓住错误) |
| **finally** | 最终 | 记住:finally = 最终(最后一定要做的事) |
| **preventDefault** | 阻止默认 | 记住:prevent(阻止)+ default(默认)= 阻止默认行为 |
| **router** | 路由 | 记住:router = 路由器(导航到不同页面) |
| **push** | 推送/跳转 | 记住:push = 推(推送到新页面) |
| **replace** | 替换 | 记住:replace = 替换(替换当前页面) |
| **params** | 参数 | 记住:params = parameters(参数),URL 中的参数 |
| **websocket** | WebSocket | 记住:websocket = 实时通信协议(双向通信) |
| **client** | 客户端 | 记住:client = 客户端(WebSocket 客户端) |
| **connect** | 连接 | 记住:connect = 连接(建立 WebSocket 连接) |
| **disconnect** | 断开 | 记住:disconnect = 断开(断开 WebSocket 连接) |
| **callback** | 回调 | 记住:callback = 回调(收到消息时执行的函数) |
| **layout** | 布局 | 记住:layout = 布局(页面布局结构) |
| **dashboard** | 仪表盘 | 记住:dashboard = 仪表盘(工作台页面) |
| **hook** | 钩子 | 记住:hook = React 自定义逻辑的钩子函数 |
| **service** | 服务 | 记住:service = 统一封装接口请求的模块 |
| **module** | 模块 | 记住:module = 一组功能组成的模块化单元 |
## 三、代码执行流程(就像讲故事)
### 访客端流程:
1. **页面加载**
- 检查浏览器里有没有访客ID
- 如果没有,生成一个新的ID存起来
2. **有了访客ID后**
- 打电话给后端:"给我一个对话ID"
- 后端回复:"你的对话ID是123"
3. **有了对话ID后**
- 自动拉取这个对话的所有消息
- 显示在页面上
4. **用户发送消息**
- 用户点击"发送"按钮
- 把消息内容发送给后端
- 后端保存成功
- 重新拉取消息(能看到刚发的)
- 自动滚动到底部
5. **每次消息更新**
- 自动滚动到底部(让用户看到最新消息)
### 客服端流程(旧版,已废弃):
1. **登录页面加载**
- 用户输入用户名和密码
- 点击"登录"按钮
2. **登录请求**
- 发送登录请求到后端
- 后端验证用户名和密码
- 返回用户信息(user_id、username、role
3. **登录成功**
- 保存用户信息到 localStorage
- 跳转到对话列表页面
4. **对话列表页面**
- 检查是否已登录(检查 localStorage
- 未登录则跳转到登录页
- 已登录则拉取所有未关闭的对话
- 显示对话列表
5. **进入聊天页面**
- 点击对话,跳转到 `/agent/chat/[conversationId]`
- 从 URL 参数获取对话ID
- 拉取该对话的所有消息
- 客服消息显示在右侧,访客消息显示在左侧
6. **发送消息**
- 客服输入消息,点击"发送"
- 发送时设置 `sender_is_agent: true`
- 消息显示在右侧(蓝色气泡)
### 客服端流程(新版,四栏布局):
1. **登录页面加载**
- 用户输入用户名和密码
- 点击"登录"按钮
2. **登录请求**
- 发送登录请求到后端
- 后端验证用户名和密码
- 返回用户信息(user_id、username、role
3. **登录成功**
- 保存用户信息到 localStorage
- 跳转到 `/agent/dashboard`(四栏布局工作台)
4. **工作台页面加载**
- 检查是否已登录(检查 localStorage
- 未登录则跳转到登录页
- 已登录则拉取所有未关闭的对话
- 显示在左侧对话列表栏
5. **选择对话**
- 点击左侧对话列表中的某个对话
- 更新 `selectedConversationId` 状态
- 自动拉取该对话的所有消息
- 建立 WebSocket 连接,接收实时消息
- 中间栏显示聊天内容
- 右侧栏显示访客详情
6. **发送消息**
- 在中间栏输入框输入消息
- 点击"发送"按钮
- 发送时设置 `sender_is_agent: true`
- 消息通过 WebSocket 实时显示在右侧(蓝色气泡)
7. **实时接收消息**
- WebSocket 接收到新消息
- 自动更新消息列表
- 自动滚动到底部
- 无需手动刷新
## 四、模块化拆分(2025-11
> 拆分后的代码更像乐高积木,每一块负责自己的事情,组合起来就是完整的客服工作台。
- **页面层(app/agent/dashboard/page.tsx**
- 只做一件事:渲染 `<DashboardShell />`
- 没有业务逻辑,后续做 SSR / Route Handlers 时更轻松
- **组件层(components/dashboard/**
- `DashboardShell`:整合左中右三栏 + 顶部导航
- `NavigationSidebar / ConversationSidebar / MessageList / VisitorDetailPanel`:界面分块清晰,可复用
- `VisitorDetailPanel` 内置联系人信息编辑弹窗,点击“+ Add / 编辑”即可修改邮箱、电话、备注
- 样式问题在各自组件内部解决,互不影响
- `MessageList` 通过 `currentUserIsAgent` 参数兼容客服/访客视角
- **Hook 层(features/agent/hooks/**
- `useAuth`:登录信息获取 + 退出登录
- `useConversations`:对话列表、防抖搜索、未读数更新
- `useMessages`:消息拉取、已读状态、WebSocket 回调,新增 `updateContactInfo` 用于保存邮箱/电话/备注
- `useWebSocket`:封装连接/断开/错误处理
- **Service 层(features/agent/services/**
- 所有 `fetch` 请求集中在这里,例如 `conversationApi.ts``messageApi.ts``authApi.ts`
- 新增 `updateConversationContact` 方法,调用 `PUT /conversations/:id/contact` 更新访客联系信息
- 后续接入 React Query / SWR 时,只需要在这里改
- **工具层(utils/**
- `format.ts`:统一时间和消息预览格式
- `highlight.tsx`:关键词高亮组件化
- `storage.ts`localStorage 读写统一封装
> 小结:页面调用 HookHook 使用 ServiceService 请求后端;UI 部分由组件层独立负责。以后要换样式或替换数据源,都有明确位置可以下手。
### 目录与关键文件(2025-11
| 层级 | 目录 / 文件 | 作用说明 |
|------|-------------|----------|
| 页面入口 | `app/page.tsx` | 客服登录页,登录成功后跳转工作台 |
| 页面入口 | `app/agent/dashboard/page.tsx` | 工作台入口,渲染 `DashboardShell` |
| 页面入口 | `app/agent/chat/[conversationId]/page.tsx` | 旧单聊页面,复用新版组件与 Hook |
| 页面入口 | `app/chat/page.tsx` | 访客端聊天页面,复用统一组件/服务 |
| 组件层 | `components/dashboard/*` | `DashboardShell`、导航栏、会话列表、消息列表、访客详情、输入框等 UI 组件 |
| Hook 层 | `features/agent/hooks/useAuth` | 处理登录态:读取/清理 localStorage,提供退出方法 |
| Hook 层 | `features/agent/hooks/useConversations` | 统一管理会话列表、搜索、防抖、排序、选中会话 |
| Hook 层 | `features/agent/hooks/useMessages` | 统一管理消息、已读状态、详情数据、WebSocket 回调 |
| Hook 层 | `features/agent/hooks/useWebSocket` | 对 `WSClient` 的通用封装,负责连接/断开 |
| Service | `features/agent/services/conversationApi` | 会话相关接口封装:列表、搜索、详情、更新访客联系信息 |
| Service | `features/agent/services/messageApi` | 消息接口封装:拉取、发送、已读 |
| Service | `features/agent/services/authApi` | 登录态接口(目前仅登出) |
| Service | `features/visitor/services/conversationApi` | 访客端对话初始化接口(收集 UA、语言等) |
| 类型定义 | `features/agent/types.ts` | 会话、消息、用户、WebSocket 负载等公共类型 |
| 工具 | `utils/format.ts` | 时间格式化、消息预览截断 |
| 工具 | `utils/highlight.tsx` | 关键词高亮渲染(返回 `<mark>` |
| 工具 | `utils/storage.ts` | localStorage 读写封装(获取/设置/清理客服账号) |
| 工具 | `lib/websocket.ts` | `WSClient`:负责连接、自动重连、消息广播 |
> 访客与客服共用同一套消息组件/服务:通过 `currentUserIsAgent``senderIsAgent` 等参数切换左右气泡与已读对勾。
## 五、为什么这样写?
### 为什么用 useState
- 如果不用:数据变了,页面不会自动更新,要手动刷新
- 用了:数据一变,页面自动刷新
### 为什么用 useEffect
- 如果不用:需要手动点击按钮才能拉取数据
- 用了:页面加载时自动拉取数据
### 为什么用 async/await
- 如果不用:代码不会等网络请求完成,可能会出错
- 用了:等网络请求完成再继续,保证顺序
### 为什么用 try/catch
- 如果不用:网络断了,程序会崩溃
- 用了:即使出错,也能显示错误信息,程序不崩溃
## 五、记忆口诀
1. **useState**:数据盒子,存和改
2. **useEffect**:当...的时候,做...
3. **useRef**:书签指针,找元素
4. **async/await**:等待外卖,别着急
5. **try/catch**:试试看,错了别慌
6. **useRouter**:地图导航,跳页面
7. **localStorage**:浏览器存储,存数据
8. **URL参数**:从路径拿数据,用 useParams
9. **WebSocket**:对讲机,实时通信
10. **多状态**:多个盒子,各管各的
11. **布局**:四栏布局,各司其职
## 六、常见错误和解决
1. **忘记写 await**
- 错误:`fetch(...)` 后面直接用 `.json()`
- 正确:`await fetch(...)` 然后 `await res.json()`
2. **useEffect 依赖写错**
- 错误:`[]` 写成了 `[data]`,导致无限循环
- 正确:想清楚"当谁改变时才执行"
3. **忘记检查数据是否存在**
- 错误:直接用 `data.id`,如果 data 是 null 就报错
- 正确:先检查 `if (data)` 再使用
4. **忘记检查登录状态**
- 错误:直接访问需要登录的页面,没有检查是否已登录
- 正确:在页面加载时检查 localStorage 中是否有登录信息
5. **路由跳转问题**
- 错误:使用 `window.location.href` 跳转(会刷新页面)
- 正确:使用 `router.push()` 跳转(不会刷新,更流畅)
6. **WebSocket 连接问题**
- 错误:在组件每次渲染时都创建新连接(导致连接泄漏)
- 正确:在 `useEffect` 中创建连接,在清理函数中断开连接
- 注意:对话切换时,需要断开旧连接,建立新连接
7. **状态管理问题**
- 错误:把所有数据放在一个状态里(难以管理)
- 正确:按功能拆分状态(对话列表、选中对话、消息列表等)
- 注意:状态更新时,只更新相关的 UI 部分
8. **对话切换问题**
- 错误:切换对话时不清空消息列表(可能显示错误的消息)
- 正确:切换对话时先清空消息列表,再加载新对话的消息
## 七、练习建议
1. **多写注释**:每行代码都写注释,解释"为什么要这样写"
2. **画流程图**:把代码执行流程画出来,理清思路
3. **小步调试**:每次只改一小部分,测试看看效果
4. **多看报错**:出错时看报错信息,学会查问题
---
**记住**:编程就像学开车,一开始慢,多练习就熟了!🚀
+458
View File
@@ -0,0 +1,458 @@
# 后端学习笔记(Go + Gin + GORM
## 一、核心概念理解
### 1. Gin 框架(Web框架)
**类比**:就像餐厅的服务员,接收客人的请求,然后告诉厨房(处理函数)做什么菜
- `gin.Default()` = 创建一个 Gin 服务器(就像开一家餐厅)
- `r.POST("/路径", 处理函数)` = 注册一个路由(就像在菜单上写:客人点这个菜,叫这个厨师做)
- `r.Run(":8080")` = 启动服务器,监听 8080 端口(就像开门营业)
**为什么需要?**
- 没有框架:需要自己处理 HTTP 请求、解析 JSON、路由等(很麻烦)
- 有了框架:只需要写业务逻辑,框架帮你处理其他事情(简单)
### 2. GORMORM框架)
**类比**:就像翻译官,帮你把 Go 代码翻译成 SQL 语句
- `db.Create(&user)` = 插入数据(翻译成 `INSERT INTO users ...`
- `db.Where("username=?", name).First(&user)` = 查询数据(翻译成 `SELECT * FROM users WHERE username=?`
- `db.AutoMigrate(&User{})` = 自动创建表(根据结构体自动建表)
**为什么需要?**
- 不用 GORM:需要手写 SQL 语句(容易出错,不安全)
- 用了 GORM:用 Go 代码操作数据库,自动生成 SQL(安全、简单)
### 3. 环境变量(.env 文件)
**类比**:就像餐厅的配置表,记录"数据库地址"、"密码"等敏感信息
- `godotenv.Load()` = 从 `.env` 文件读取配置
- `os.Getenv("DB_HOST")` = 获取环境变量的值
**为什么需要?**
- 不写在代码里:密码等信息不会泄露,不同环境可以不同配置
- 写在代码里:密码硬编码,不安全,换环境要改代码
### 4. 中间件(Middleware
**类比**:就像餐厅的"检查站",每个请求都要经过这些检查
- `r.Use(middleware.Logger())` = 记录每个请求的日志(就像记录每个客人点了什么)
- `r.Use(middleware.CORS())` = 允许跨域请求(就像允许其他网站访问我们的 API)
**为什么需要?**
- 每个路由都要写日志、跨域处理等代码(重复)
- 用中间件:统一处理,代码简洁
### 5. Handler(处理函数)
**类比**:就像餐厅的厨师,负责处理具体的业务逻辑
- `func Register(db *gorm.DB) gin.HandlerFunc` = 注册用户的处理函数
- `c.ShouldBindJSON(&u)` = 把请求的 JSON 数据解析成结构体
- `c.JSON(200, gin.H{"message": "成功"})` = 返回 JSON 响应给前端
**为什么需要?**
- 接收前端请求 → 处理业务逻辑 → 返回结果
### 6. 初始化默认数据
**类比**:就像餐厅开业时,自动准备一些基础食材
- `initDefaultAdmin(db)` = 在首次启动时自动创建默认管理员账号
- 检查数据库是否已有管理员,如果没有则创建
**为什么需要?**
- 系统首次启动时,需要有一个管理员账号才能登录
- 避免手动创建管理员账号的麻烦
## 二、常用英文单词记忆
| 英文 | 中文意思 | 记忆技巧 |
|------|---------|---------|
| **handler** | 处理函数 | 记住:handler = 处理者(处理请求的人) |
| **middleware** | 中间件 | 记住:middle(中间)+ ware(件)= 中间件 |
| **router** | 路由 | 记住:router = 路由器(把请求路由到对应的处理函数) |
| **context** | 上下文 | 记住:context = 上下文(包含请求和响应的信息) |
| **migrate** | 迁移 | 记住:migrate = 迁移(把结构体迁移成数据库表) |
| **query** | 查询 | 记住:query = 查询(从数据库查数据) |
| **bind** | 绑定 | 记住:bind = 绑定(把 JSON 绑定到结构体) |
| **hash** | 哈希 | 记住:hash = 哈希(密码加密) |
| **bcrypt** | 加密算法 | 记住:bcrypt = 密码加密算法(B + crypt = 加密) |
| **env** | 环境变量 | 记住:env = environment(环境)的缩写 |
| **error** | 错误 | 记住:error = 错误(Go 语言中常用) |
| **return** | 返回 | 记住:return = 返回(返回结果或退出函数) |
## 三、代码执行流程(就像讲故事)
### 1. 服务器启动(main.go
```
1. 加载 .env 文件(读取数据库配置)
2. 连接数据库(NewDB
3. 自动创建表(AutoMigrate
4. 初始化默认管理员(initDefaultAdmin
5. 创建 Gin 服务器(gin.Default
6. 注册中间件(Logger、CORS
7. 注册路由(POST /login, POST /conversation/init, GET /conversations, PUT /conversations/:id/contact 等)
8. 启动服务器(监听 8080 端口)
```
### 2. 处理一个请求(比如登录)
```
1. 前端发送 POST /login 请求
2. Gin 框架接收请求
3. 经过中间件(记录日志、允许跨域)
4. 找到对应的处理函数(Login
5. 解析 JSON 数据(ShouldBindJSON
6. 查询用户是否存在(db.Where)
7. 验证密码(bcrypt.CompareHashAndPassword
8. 返回 JSON 响应(包含 user_id、username、role
9. 前端收到响应,保存到 localStorage
```
### 3. 处理一个请求(比如获取对话列表)
```
1. 前端发送 GET /conversations 请求
2. Gin 框架接收请求
3. 经过中间件(记录日志、允许跨域)
4. 找到对应的处理函数(ListConversations
5. 查询数据库(db.Where("status != ?", "closed")
6. 按更新时间倒序排列(Order("updated_at desc")
7. 返回 JSON 响应(对话列表)
8. 前端收到响应,显示对话列表
```
### 4. 服务器首次启动流程
```
1. 加载 .env 文件
2. 连接数据库(NewDB
3. 自动创建表(AutoMigrate
4. 检查是否有管理员账号(initDefaultAdmin
5. 如果没有,创建默认管理员(admin/admin123
6. 创建 Gin 服务器
7. 注册路由和中间件
8. 启动服务器
```
### 5. 数据库操作流程
```
1. 定义结构体(models.User
2. GORM 自动创建表(AutoMigrate
3. 查询:db.Where("条件").First(&结果)
4. 插入:db.Create(&数据)
5. 更新:db.Save(&数据)
6. 删除:db.Delete(&数据)
```
## 四、为什么这样写?
### 为什么用 Gin 框架?
- 如果不用:需要自己处理 HTTP 协议、解析请求、路由等(很复杂)
- 用了:只需要写业务逻辑,框架帮你处理其他事情(简单高效)
### 为什么用 GORM
- 如果不用:需要手写 SQL,容易出错,有 SQL 注入风险
- 用了:用 Go 代码操作数据库,自动生成安全的 SQL(安全、简单)
### 为什么用环境变量?
- 如果不用:密码硬编码在代码里,不安全,换环境要改代码
- 用了:配置和代码分离,安全,灵活
### 为什么用中间件?
- 如果不用:每个路由都要写日志、跨域等代码(重复)
- 用了:统一处理,代码简洁,易维护
### 为什么用 bcrypt 加密密码?
- 如果不用:密码明文存储,泄露了别人就能登录
- 用了:密码加密存储,即使泄露也无法还原(只能验证)
## 五、代码结构说明
### 项目结构
```
backend/
├── main.go # 入口:环境加载、依赖初始化、路由注册
├── controller/ # HTTP 层:接收请求、参数校验、调用业务服务
├── service/ # 业务层:会话/消息/认证逻辑,使用仓储与 WebSocket
├── service/types.go # 业务层通用请求/返回结构体
├── repository/ # 仓储层:封装 GORM 操作(User/Conversation/Message
├── router/ # 路由注册集中处(RegisterRoutes
├── utils/ # 工具函数(请求信息解析、UA 解析等)
├── websocket/ # Hub/Client/Handler:管理长连接与广播
├── infra/ # 基础设施(数据库连接 NewDB 等)
├── middleware/ # 中间件(日志、CORS)
├── models/ # 数据模型(User、Conversation、Message
└── .env # 环境变量配置
```
### 分层架构
```
main.go
↓ 初始化仓储 / 服务 / 控制器 / WebSocket Hub
router.RegisterRoutes
↓ controller 层(解析请求 → 调用 service)
↓ service 层(编排业务 → 调用 repository、websocket
↓ repository 层(GORM 操作数据库)
↓ models + 数据库
```
**关键职责拆分:**
- **controller**:只做输入输出转换,避免直接访问数据库
- **service**:编排业务流程(例如发送消息时刷新会话更新时间、广播 WebSocket)
- **repository**:封装 `FindOpenByVisitorID``ListActive``MarkMessagesRead` 等数据库操作
- **websocket**`hub.go` 管理连接字典,`client.go` 负责读写协程,`handler.go` 升级 HTTP连接
- **utils/request.go**`GetClientIP``ParseUserAgent` 等通用方法
- **service/types.go**`ConversationSummary``MessageItem` 等跨服务共享结构体
> 重构之后:新增服务时,只需新增仓储方法 + service + controller,不再在 `main.go` 里堆业务代码。
### 关键结构体与函数速查
| 名称 | 所在文件 | 作用与要点 |
|------|----------|------------|
| `controller.AuthController` | `controller/auth_controller.go` | 处理登录/退出,依赖 `AuthService`,统一 JSON 响应格式 |
| `controller.ConversationController` | `controller/conversation_controller.go` | 初始化会话、列表、搜索、状态更新、详情查询、更新访客联系信息 |
| `controller.MessageController` | `controller/message_controller.go` | 创建消息、拉取消息、批量已读 |
| `service.AuthService` | `service/auth_service.go` | 登录校验、账号创建(管理员) |
| `service.ConversationService` | `service/conversation_service.go` | 会话编排:初始化系统消息、列表拼装、搜索、状态/联系信息更新 |
| `service.MessageService` | `service/message_service.go` | 发送消息、拉取消息、已读逻辑 + WebSocket 广播 |
| `service.AdminService` | `service/admin_service.go` | 创建客服/管理员账号 |
| `repository.ConversationRepository` | `repository/conversation_repository.go` | 会话数据库操作:按访客查找、批量查询、更新状态/字段等 |
| `repository.MessageRepository` | `repository/message_repository.go` | 消息数据库操作:保存、按会话拉取、统计未读、标记已读 |
| `repository.UserRepository` | `repository/user_repository.go` | 用户查询/创建 |
| `router.RegisterRoutes` | `router/router.go` | 集中注册所有 REST + WebSocket 路由(含 `/conversations/:id/contact` |
| `websocket.Hub` / `Client` | `websocket/hub.go``client.go` | 维护对话房间 → 客户端列表,负责广播消息/已读事件 |
| `utils.GetClientIP` / `ParseUserAgent` | `utils/request.go` | 请求上下文工具函数,供访客信息采集、日志使用 |
| `service/types.go` | - | 定义 `ConversationSummary``CreateMessageInput` 等跨层 DTO,保持类型统一 |
## 六、常见代码模式
### 1. Handler 函数模式
```go
func Register(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 解析请求数据
var u models.User
if err := c.ShouldBindJSON(&u); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 2. 业务逻辑处理
// ...
// 3. 返回响应
c.JSON(200, gin.H{"message": "成功"})
}
}
```
**为什么这样写?**
- 返回 `gin.HandlerFunc`:可以传入数据库连接等参数
- `c *gin.Context`:包含请求和响应的信息
- 先检查错误,再处理:提前返回,代码清晰
### 2. 错误处理模式
```go
if err != nil {
// 处理错误
return
}
// 继续执行
```
**为什么这样写?**
- Go 语言的习惯:错误优先处理
- 提前返回:避免嵌套,代码清晰
### 3. 数据库查询模式
```go
var user models.User
if err := db.Where("username=?", name).First(&user).Error; err != nil {
// 没找到
return
}
// 找到了,使用 user
```
**为什么这样写?**
- `First`:找到第一条记录,没找到返回错误
- `&user`:传入指针,结果存到 user 里
- 检查错误:确保数据存在才继续
## 七、记忆口诀
1. **Gin**Web框架,处理请求
2. **GORM**ORM框架,操作数据库
3. **Handler**:处理函数,处理业务逻辑
4. **Middleware**:中间件,统一处理
5. **Context**:上下文,包含请求响应
6. **Error**:错误优先,提前返回
## 八、常见错误和解决
### 1. 忘记处理错误
**错误**`db.Create(&user)` 不检查错误
**正确**
```go
if err := db.Create(&user).Error; err != nil {
// 处理错误
return
}
```
### 2. 环境变量没加载
**错误**`os.Getenv("DB_HOST")` 返回空字符串
**解决**
- 检查 `.env` 文件是否存在
- 检查文件编码(不能有 BOM
- 检查 `godotenv.Load()` 是否执行
### 3. 数据库连接失败
**错误**`数据库连接失败`
**解决**
- 检查 MySQL 是否启动
- 检查 `.env` 配置是否正确
- 检查数据库是否存在
### 4. 跨域问题
**错误**:前端请求被阻止
**解决**
- 检查是否添加了 CORS 中间件
- 检查中间件配置是否正确
### 5. 密码加密问题
**错误**:密码明文存储或验证失败
**解决**
- 注册时用 `bcrypt.GenerateFromPassword` 加密
- 登录时用 `bcrypt.CompareHashAndPassword` 验证
### 6. 默认管理员创建问题
**错误**:首次启动时没有管理员账号,无法登录
**解决**
- 使用 `initDefaultAdmin` 函数在首次启动时自动创建
- 检查是否已存在,避免重复创建
## 九、调试技巧
### 1. 查看日志
- 后端启动时看日志输出
- 请求处理时看日志记录
- 错误时看错误信息
### 2. 使用 Postman 测试
- 不用写前端,直接测试 API
- 可以测试各种情况(成功、失败等)
### 3. 检查数据库
- 用 MySQL 客户端查看数据
- 确认数据是否正确保存
### 4. 打印调试信息
```go
log.Printf("调试信息: %v", 变量)
```
## 十、学习建议
1. **先理解概念**:理解框架的作用,不要死记硬背
2. **多看代码**:看别人的代码,理解写法
3. **多写注释**:每行代码都写注释,理解为什么这样写
4. **小步调试**:每次只改一小部分,测试看看效果
5. **多看错误**:出错时看错误信息,学会查问题
6. **画流程图**:把代码执行流程画出来,理清思路
## 十一、WebSocket 实时通信(已实现)
### 1. WebSocket 的作用
**类比**:就像对讲机,可以实时双向通信
- HTTP:只能客户端主动请求,服务器被动响应
- WebSocket:客户端和服务器可以随时互相发送消息
**为什么需要?**
- 新消息需要实时推送给客服,无需手动刷新
- 对话状态更新需要实时同步
- 提升用户体验,感觉更流畅
### 2. WebSocket 架构
```
Hub(中心管理器)
├── conversations(对话ID → 客户端列表)
├── register(注册新客户端)
├── unregister(注销客户端)
└── broadcast(广播消息)
Client(客户端连接)
├── hub(所属的 Hub
├── connWebSocket 连接)
├── send(发送消息的通道)
└── conversationID(所属的对话ID
```
### 3. 消息流程
```
1. 客户端建立 WebSocket 连接
2. 客户端发送对话ID,注册到 Hub
3. Hub 将客户端加入对应对话的客户端列表
4. 当有新消息时,Hub 广播给该对话的所有客户端
5. 客户端收到消息,更新 UI
```
### 4. 心跳机制
- 客户端定期发送 ping 消息
- 服务器回复 pong 消息
- 如果长时间没有收到 ping,断开连接
**为什么需要?**
- 检测连接是否断开
- 保持连接活跃
- 自动清理断开的连接
## 十二、后端分层架构(2025-11 重构版)
### 1. 目录结构(核心部分)
```
backend/
├─ main.go // 程序入口,只负责依赖注入和启动
├─ router/ // 路由注册(把 URL 映射到控制器)
├─ controller/ // 控制器层,处理 HTTP 请求/响应
├─ service/ // 业务层,编排业务逻辑
├─ repository/ // 数据访问层,封装 GORM 操作
├─ utils/ // 工具函数(IP、User-Agent 等)
└─ websocket/ // WebSocket Hub、Client 等
```
### 2. 各层职责
- **router**:统一注册所有路由,中间件分组时也只在这里改。
- **controller**:只关心“请求 → 调用服务 → 返回响应”,不会出现 SQL 或业务细节。
- **service**:聚焦业务规则,可以组合多个 repository、触发 WebSocket 推送等。
- **repository**:集中管理所有数据库读写,便于修改查询逻辑或引入缓存。
- **utils**:存放通用工具,避免重复代码。
### 3. 调用顺序
```
HTTP 请求
router → controller → service → repository
MySQL
```
(若需要 WebSocket 推送,service 会在写库后调用 `hub.BroadcastMessage`。)
### 4. 为什么这样做?
- 代码职责更清晰:想改接口,看 controller;想改业务,看 service;想改 SQL 看 repository。
- 更容易写单元测试:service 不依赖 ginrepository 可以替换成 mock。
- 方便扩展:以后要接入 gRPC/消息队列,只需在 service 层复用已有逻辑。
### 5. 迁移提示
- 新增接口时先写 service & repository,再写 controller 和 router。
- 记得运行 `gofmt`PowerShell 举例):
```powershell
cd backend
Get-ChildItem main.go, router, controller, service, repository, utils -Recurse -Filter *.go -File |
ForEach-Object { gofmt -w $_.FullName }
```
- main.go 只负责依赖注入(NewRepository → NewService → NewController)。
---
**记住**:后端开发就像搭建一个服务系统,框架帮你处理底层细节,你只需要关注业务逻辑!🚀
+404
View File
@@ -0,0 +1,404 @@
# 📋 待实现需求清单
## 第一阶段:核心功能优化(优先级:高)
### 1. UI 布局改进(客服端四栏布局)✅ 部分完成
**完成状态**
- ✅ **四栏布局基础框架**(已实现)
- ✅ 最左侧导航菜单栏(固定宽度 64px)
- ✅ 左侧对话列表栏(固定宽度 320px)
- ✅ 中间聊天内容栏(自适应宽度)
- ✅ 右侧访客详情栏(固定宽度 320px)
- ✅ 统一顶部栏高度(h-16),三栏对齐
- ✅ **左侧栏对话列表**(基础功能已实现)
- ✅ 显示所有对话
- ✅ 对话头像(圆形,基于 visitor_id 生成颜色)
- ✅ 对话ID、访客ID、状态显示
- ✅ 最后更新时间显示
- ✅ 对话选择高亮
- ✅ 搜索框("Q Search"- **已完成**(支持搜索对话内容、对话ID、访客ID)
- ✅ 最后一条消息预览 - **已完成**(显示最后一条消息的前50个字符)
- ✅ 搜索后点击对话自动定位到匹配消息 - **已完成**(居中显示历史消息,最后一条滚动到底部)
- ✅ 关键词高亮显示 - **已完成**(黄色背景高亮)
- ✅ 已读/未读状态图标 - **基础完成**(单/双对勾 + 未读徽标,未读数量统计将继续迭代)
- ✅ **中间栏聊天内容**(核心功能已实现)
- ✅ 对话标题显示
- ✅ 消息区域(滚动显示)
- ✅ 访客消息(左侧,白色气泡)
- ✅ 客服消息(右侧,蓝色气泡)
- ✅ 时间戳显示(智能格式化)
- ✅ 输入框和发送按钮
- ✅ 自动滚动到底部
- ✅ WebSocket 实时消息推送
- ❌ 系统消息显示 - **待实现**
- ❌ "last seen" 信息 - **待实现**(已显示但为静态数据)
- ✅ **右侧栏访客详情**UI 已实现,数据待完善)
- ✅ 访客头像(基于 visitor_id 生成颜色)
- ✅ 在线/离线状态显示(基于对话状态)
- ✅ 联系信息区域(邮箱、电话、备注)
- ✅ 技术信息区域(网站、来源、位置等)
- ✅ 刷新按钮和更多选项图标
- ❌ 联系信息手动添加功能 - **待实现**(只有按钮占位)
- ❌ 技术信息自动收集和显示 - **待实现**(只有占位文本)
- ✅ **无需跳转切换对话**(已实现)
- ✅ 点击对话列表切换,中间栏和右侧栏自动更新
- ✅ 对话切换时自动加载消息
- ✅ WebSocket 连接自动切换
**需求描述**(剩余部分):
- **左侧栏增强**
- 搜索框("Q Search"),支持搜索对话内容
- 最后一条消息预览
- 已读/未读状态图标(未读数量统计上限、批量操作待完善)
- **中间栏增强**
- 系统消息显示(如 "Visitor opened the page", "You joined the chat"
- 实时 "last seen" 信息
- **右侧栏数据完善**
- 联系信息手动添加/编辑功能
- 技术信息自动收集和显示
**技术要点**
- ✅ 路由已改为 `/agent/dashboard`(统一页面)
- ✅ 使用 React 状态管理当前选中的对话
- ❌ 响应式设计(移动端可能需要折叠侧栏)- **待实现**
- ✅ 左侧栏:对话列表组件
- ✅ 中间栏:聊天内容组件
- ✅ 右侧栏:访客详情组件
**预计剩余工作量**:0.5-1 周(完成搜索、系统消息、数据收集等功能)
---
### 1.1. 会话搜索功能 ✅ 已完成
**完成状态**
- ✅ 搜索框UI(已实现)
- ✅ 后端搜索接口(`GET /conversations/search?q=关键词`
- ✅ 搜索范围:消息内容、对话ID、访客ID
- ✅ 前端实时搜索(300ms防抖)
- ✅ 搜索结果高亮显示(关键词黄色背景高亮)
- ✅ 点击搜索结果自动定位到匹配消息(历史消息居中显示,最后一条滚动到底部)
- ✅ 搜索结果实时更新
**技术实现**
- 后端:`SearchConversations` 函数,支持搜索消息内容和ID
- 前端:防抖搜索、关键词高亮、消息定位
---
### 1.2. 客服个人资料管理 ✅ 已完成
**完成状态**
- ✅ **数据库**(已实现)
- ✅ User 模型增加字段:`avatar_url``nickname``email``created_at``updated_at`
- ✅ **后端**(已实现)
- ✅ 文件存储服务(`backend/infra/storage.go`):本地存储服务,可扩展为云存储
- ✅ 个人资料服务(`backend/service/profile_service.go`):提供获取、更新个人资料和上传头像功能
- ✅ 个人资料控制器(`backend/controller/profile_controller.go`):处理 HTTP 请求
- ✅ 新增接口:
- ✅ `GET /agent/profile/:user_id`:获取个人资料
- ✅ `PUT /agent/profile/:user_id`:更新个人资料(昵称、邮箱)
- ✅ `POST /agent/avatar/:user_id`:上传头像(支持 jpg、png、gif,最大 10MB
- ✅ 静态文件服务:`/uploads` 路径用于访问上传的头像等文件
- ✅ **前端**(已实现)
- ✅ 个人资料 API 服务(`frontend/features/agent/services/profileApi.ts`
- ✅ 个人资料 Hook`frontend/features/agent/hooks/useProfile.ts`):管理个人资料状态
- ✅ 个人资料弹窗组件(`frontend/components/dashboard/ProfileModal.tsx`):
- ✅ 显示和编辑昵称、邮箱
- ✅ 上传头像(支持预览、上传进度、错误提示)
- ✅ 实时更新个人资料
- ✅ DashboardHeader:显示头像和设置按钮,点击打开个人资料弹窗
- ✅ 头像工具函数(`frontend/utils/avatar.ts`):拼接完整的头像 URL、生成头像颜色、获取头像显示文本
**功能特性**
- ✅ 支持头像上传(jpg、png、gif,最大 10MB
- ✅ 支持修改昵称和邮箱
- ✅ 头像实时预览
- ✅ 如果没有上传头像,显示彩色圆形头像(基于用户ID生成颜色)
- ✅ 头像显示在 DashboardHeader 中
**技术要点**
- ✅ 文件存储采用可扩展设计,目前使用本地存储,后续可切换为云存储(OSS、S3 等)
- ✅ 头像 URL 拼接逻辑:如果后端返回相对路径,前端自动拼接 API_BASE_URL
- ✅ 头像上传支持文件类型和大小验证
- ✅ 个人资料更新实时刷新 UI
**预计工作量**:已完成
---
### 1.3. 访客信息收集和显示 ✅ 部分完成
**完成状态**
- ✅ **右侧栏 UI 显示**(已实现)
- ✅ 联系信息区域(邮箱、电话、备注)
- ✅ 技术信息区域(网站、来源、位置、语言、浏览器、操作系统、User Agent、IP地址)
- ✅ 添加按钮(邮箱、电话、备注)
- ✅ **自动收集技术信息**
- ✅ 网站(当前页面URL
- ✅ 来源(referrer,从哪个页面跳转过来)
- ✅ 浏览器信息(User Agent解析)
- ✅ 操作系统(User Agent解析)
- ✅ 语言(浏览器语言设置)
- ✅ IP地址(后端获取)
- ❌ 位置(通过IP地址定位,需要第三方服务)
- ✅ **手动添加联系信息**
- ✅ 邮箱添加/编辑功能(`PUT /conversations/:id/contact`
- ✅ 电话添加/编辑功能(`PUT /conversations/:id/contact`
- ✅ 备注添加/编辑功能(`PUT /conversations/:id/contact`
**需求描述**(剩余部分):
- **自动收集**:访客端自动收集技术信息
- 网站(当前页面URL
- 来源(referrer,从哪个页面跳转过来)
- 浏览器信息(User Agent解析)
- 操作系统(User Agent解析)
- 语言(浏览器语言设置)
- IP地址(后端获取)
- 位置(通过IP地址定位,需要第三方服务)
- **手动添加**:客服可以手动添加/编辑联系信息(已支持,后续可迭代优化校验/权限)
- **新增能力**:支持清空邮箱/电话/备注(输入空值即可)
**技术要点**
- 数据库:
- 对话表增加字段:`website`, `referrer`, `browser`, `os`, `language`, `ip_address`, `location`
- 访客表或对话表增加字段:`email`, `phone`, `notes`(客服手动添加)
- 前端(访客端):
- 页面加载时收集:`window.location.href`, `document.referrer`, `navigator.userAgent`, `navigator.language`
- 发送到后端保存
- 后端:
- 接收访客信息并保存
- 获取IP地址(从请求头)
- IP地址定位(可选,需要第三方API)
- ✅ 新增接口:`PUT /conversations/:id/contact`(更新访客邮箱/电话/备注)
- 前端(客服端):
- ✅ 右侧栏显示所有信息(UI 已实现)
- ✅ 编辑弹窗支持新增/修改邮箱、电话、备注
- ✅ 保存后实时刷新访客详情
- ❌ 更丰富的表单校验/批量编辑 - **待实现**
**预计工作量**0.5 周(完成 IP->地理位置、表单校验优化等收尾工作)
---
### 1.4. 系统消息记录 ✅ 初版完成
**完成状态**
- ✅ 消息表新增 `message_type` 字段(区分 `user_message` / `system_message`
- ✅ 新建对话时自动记录访客事件
- "Visitor opened the page [页面URL]"
- "Visitor came from [referrer URL]"(有来源时记录)
- ✅ 前端以灰色居中样式展示系统消息
- ❌ 客服行为事件(如 "You joined the chat"- 后续扩展
**后续迭代方向**
- 扩展更多系统事件(客服加入、对话状态变化等)
- 支持系统消息筛选/折叠
---
### 2. 对话状态管理(在线/离线)✅ 基础功能已完成,优化待实现
**完成状态**
- ✅ **右侧栏状态显示**(已实现,基于对话状态)
- ✅ 显示在线/离线状态(绿色圆点表示在线,灰色表示离线)
- ✅ 基于对话状态(`status === "open"` 显示在线)
- ✅ **实时状态更新**(基础功能已实现)
- ✅ 访客打开页面时自动标记为在线(WebSocket 连接建立)
- ✅ 访客关闭/离开页面时自动标记为离线(WebSocket 断开)
- ✅ 后端维护在线状态,通过 WebSocket 推送给客服端
- ✅ 实时同步状态到客服端
- ✅ 在对话列表中显示在线/离线图标(绿色圆点 = 在线)
- ✅ 记录最后活跃时间(`last_seen_at`- **已实现**(在 `ConversationDetail` 中)
**需求描述**(剩余部分):
- ❌ 通过 WebSocket 心跳机制检测在线状态(定期更新 `last_seen_at`- **待实现**
- ❌ 根据 `last_seen_at` 判断是否在线(例如,如果 `last_seen_at` 在最近 60 秒内,则认为在线)- **待实现**
- ❌ 在右侧栏显示"last seen"信息(如 "last seen today at 11:08"- **待实现**(已显示但为静态数据)
- ❌ 定期轮询对话列表,更新所有对话的状态 - **待实现**
**技术要点**
- ✅ 右侧栏状态显示(已实现,基于对话状态)
- ✅ 使用 WebSocket 检测访客在线状态 - **已实现**(连接/断开时更新状态)
- ❌ 心跳间隔:30 秒 - **待实现**
- ❌ 离线判定:断开后 60 秒标记为离线 - **待实现**(目前是断开后立即标记为离线)
- ✅ 后端维护在线状态,通过 WebSocket 推送给客服端 - **已实现**
- ✅ 记录最后活跃时间(`last_seen_at`- **已实现**
- ✅ 对话列表中显示在线/离线图标(绿色圆点 = 在线)- **已实现**
**已实现功能**
- 后端:`ConversationService.UpdateVisitorOnlineStatus``UpdateLastSeenAt` 方法
- 后端:`Hub` 回调机制,在客户端连接/断开时调用回调函数
- 后端:`Client` 添加 `isVisitor` 字段,区分访客和客服
- 后端:通过 WebSocket 广播 `visitor_status_update` 事件
- 前端:WebSocket 客户端添加 `isVisitor` 参数
- 前端:客服端接收 `visitor_status_update` 事件,刷新对话详情
- 前端:在对话列表中显示在线/离线图标
**预计工作量**:0.5 周(完成心跳机制和离线判定优化)
---
## 第二阶段:AI 功能(优先级:中高)
### 3. AI 客服功能 ❌ 待实现
**需求描述**
- 客户可以选择:人工客服 / AI 客服
- AI 客服通过 API 调用(如 OpenAI、文心一言等)
- 对话中可切换人工客服
**技术要点**
- 对话表增加字段:`service_type``human` / `ai`
- 前端:选择服务类型界面
- 后端:根据类型路由到人工或 AI 处理
- AI 处理:调用 AI API,返回回复
- 需要配置 AI API Key
**待确认**
- AI 服务商选择(OpenAI、文心一言、通义千问等)
- API Key 配置方式
- 成本控制(是否需要限制调用次数)
**预计工作量**2-3 周
---
## 第三阶段:完整功能(优先级:中)
### 4. 文件上传和图片发送 ❌ 待实现
**需求描述**
- 支持文件上传(文档、图片等)
- 支持粘贴图片发送
- 显示文件/图片预览
- 文件下载功能
**技术要点**
- 文件存储:**可扩展设计**(目前本地存储,后续可切换云存储)
- 文件大小限制:10MB
- 支持类型:图片(jpg, png, gif)、文档(pdf, doc, docx, txt
- 数据库:消息表增加 `file_url``file_type``file_name` 字段
- 后端:文件上传接口,文件存储抽象层(便于切换云存储)
- 前端:文件选择器、拖拽上传、粘贴图片监听
- 图片预览:缩略图显示
**预计工作量**1 周
---
### 5. 消息已读/未读状态 ✅ 部分完成
**完成状态**
- ✅ 数据库:消息表新增 `is_read``read_at` 字段
- ✅ 后端:提供批量标记已读接口 `PUT /messages/read`
- ✅ WebSocket:推送 `messages_read` 事件,同步状态
- ✅ 前端(客服端):聊天气泡与对话列表显示单/双对勾,系统消息灰色居中
- ✅ 前端(访客端):聊天气泡显示已读回执
- ✅ 自动标记逻辑:双方打开对话(滚动到底部)即触发已读
- ❌ 未读数量统计(对话列表数字提示)- 待实现
- ❌ 自定义已读触发条件(更精确的滚动检测)- 待优化
**后续计划**
- 统计对话未读数量并在列表展示
- 为客服提供“标记全部已读”操作
- 进一步区分系统消息、客服消息的回执逻辑
---
### 6. 事件管理页面(FAQ/知识库)❌ 待实现
**需求描述**
- 事件的增删查改
- 记录问题和答案
- 关键词组合搜索(用 `%` 分隔关键词)
- 搜索逻辑:包含所有关键词(AND 查询)
- 示例:`openai%api%调用` → 搜索包含 "openai" AND "api" AND "调用" 的记录
**技术要点**
- 数据库:事件表(`id`, `question`, `answer`, `keywords`, `created_at`, `updated_at` 等)
- 后端:CRUD 接口 + 关键词搜索接口
- 搜索范围:问题、答案、关键词字段
- 匹配方式:包含所有关键词(AND
- 前端:事件管理页面(列表、添加、编辑、删除、搜索)
- 搜索框:支持 `%` 分隔关键词
**待确认**
- 事件分类:暂时不需要
- 关联对话:是否关联到具体对话(待定)
**预计工作量**1-2 周
---
### 7. 知识库功能(AI 结合文档)❌ 待实现
**需求描述**
- 客服上传技术文档(PDF、Word、TXT、Markdown 等)
- AI 结合知识库内容回复
- 支持文档管理(上传、删除、查看)
**技术要点**
- 文档存储:本地文件系统或对象存储
- 文档处理:解析、向量化(用于检索)
- AI 回复:检索相关文档 → 结合文档内容生成回复
- 管理界面:文档上传、列表、删除
- 向量化方案:本地向量库或云服务
**待确认**
- 文档格式支持(PDF、Word、TXT、Markdown
- 向量化方案选择
- 存储方式(本地存储还是云存储)
**预计工作量**2 周
---
### 8. 用户管理功能 ❌ 待实现
**需求描述**
- 管理员对客服的增删查改
- 权限管理(角色、权限分配)
**技术要点**
- 用户列表页面
- CRUD 操作
- 权限系统(基于角色的访问控制)
- 角色:管理员、客服、只读(待定)
- 权限粒度:查看、编辑、删除、创建用户
**预计工作量**1 周
---
## 实施时间线
### 第一阶段(2-3 周)
- [ ] UI 布局改进(四栏布局)
- [x] 左侧栏:对话列表
- [x] 中间栏:聊天内容
- [x] 右侧栏:访客详情
- [ ] 会话搜索功能
- [ ] 客服个人资料管理(头像上传等)
- [ ] 访客信息收集和显示
- [ ] 自动收集技术信息
- [ ] 手动添加联系信息
- [ ] 系统消息记录
- [ ] 对话状态管理(在线/离线)
### 第二阶段(2-3 周)
- [ ] AI 客服功能
### 第三阶段(3-5 周)
- [ ] 文件上传和图片发送
- [ ] 消息已读/未读状态(未读计数待实现)
- [ ] 事件管理页面
- [ ] 知识库功能
- [ ] 用户管理功能
---
## 已确认的技术决策
1. **文件存储**:可扩展设计,目前本地存储,后续可切换云存储
2. **已读判定**:客服打开对话并滑动到底部
3. **事件搜索**:包含所有关键词(AND 查询)
4. **事件分类**:暂时不需要
5. **UI 布局**:四栏布局(最左侧导航、左侧对话列表、中间聊天内容、右侧访客详情)
6. **会话搜索**:支持搜索对话内容,定位到对应消息
7. **客服资料**:支持头像上传和个人信息修改
8. **访客信息**
- 自动收集:网站、来源、浏览器、OS、语言、IP地址、位置
- 手动添加:邮箱、电话、备注(客服可编辑)
9. **系统消息**:记录访客打开页面、来源页面、客服加入等事件
10. **头像**:访客使用默认头像(颜色区分),客服可上传头像
+982
View File
@@ -0,0 +1,982 @@
# AI-CS 智能客服系统 - 完整测试指南
> 📋 本文档用于全面测试已实现的功能和需求
>
> 最后更新:2025-11-12
## 📑 目录
- [一、准备工作](#一准备工作)
- [二、启动后端](#二启动后端)
- [三、启动前端](#三启动前端)
- [四、访客端测试流程](#四访客端测试流程)
- [测试 1:访问聊天页面](#测试-1访问聊天页面访客端)
- [测试 2:发送第一条消息](#测试-2发送第一条消息)
- [测试 3:发送多条消息](#测试-3发送多条消息)
- [测试 4:接收客服消息(实时推送)](#测试-4接收客服消息实时推送)
- [测试 5:已读状态同步](#测试-5已读状态同步)
- [测试 6:刷新页面](#测试-6刷新页面)
- [测试 7:打开新标签页](#测试-7打开新标签页验证-localstorage-共享)
- [测试 8:测试错误处理](#测试-8测试错误处理)
- [五、客服端测试流程](#五客服端测试流程)
- [测试 1:客服登录](#测试-1客服登录)
- [测试 2:客服工作台(四栏布局)](#测试-2客服工作台四栏布局)
- [测试 3:查看对话列表](#测试-3查看对话列表)
- [测试 4:选择对话](#测试-4选择对话)
- [测试 5:查看对话消息](#测试-5查看对话消息)
- [测试 6:客服发送消息](#测试-6客服发送消息)
- [测试 7:接收访客消息(实时推送)](#测试-7接收访客消息实时推送)
- [测试 8:已读状态同步](#测试-8已读状态同步)
- [测试 9:对话搜索功能](#测试-9对话搜索功能)
- [测试 10:查看访客详情](#测试-10查看访客详情)
- [测试 11:编辑联系信息](#测试-11编辑联系信息)
- [测试 12:在线/离线状态显示](#测试-12在线离线状态显示)
- [测试 13:刷新访客详情](#测试-13刷新访客详情)
- [测试 14:返回对话列表](#测试-14返回对话列表)
- [测试 15:退出登录](#测试-15退出登录)
- [测试 16:登录状态检查](#测试-16登录状态检查)
- [六、调试技巧](#六调试技巧)
- [七、常见问题](#七常见问题)
- [八、完整测试检查清单](#八完整测试检查清单)
- [九、快速测试流程(核心功能验证)](#九快速测试流程核心功能验证)
- [十、高级测试场景](#十高级测试场景)
- [场景 1:多个访客同时聊天](#场景-1多个访客同时聊天)
- [场景 2:并发消息测试](#场景-2并发消息测试)
- [场景 3:长时间连接测试](#场景-3长时间连接测试)
- [场景 4:网络中断恢复测试](#场景-4网络中断恢复测试)
- [场景 5:大量消息历史测试](#场景-5大量消息历史测试)
- [场景 6:搜索性能测试](#场景-6搜索性能测试)
- [场景 7:联系信息编辑并发测试](#场景-7联系信息编辑并发测试)
- [场景 8:在线状态多访客测试](#场景-8在线状态多访客测试)
- [十一、测试结果记录](#十一测试结果记录)
---
## 一、准备工作
### ⚡ 快速开始(5分钟快速验证)
如果你想快速验证系统是否正常工作,可以按照以下步骤:
1. **启动后端**1分钟)
```bash
cd backend
go run main.go
```
看到 `🚀 服务器启动成功,监听 :8080` 表示启动成功
2. **启动前端**1分钟)
```bash
cd frontend
npm run dev
```
看到 `Ready` 表示启动成功
3. **测试访客端**1分钟)
- 访问 `http://localhost:3000/chat`
- 发送消息:"你好,这是测试消息"
- 确认消息显示在右侧(蓝色气泡)
4. **测试客服端**2分钟)
- 访问 `http://localhost:3000/`,使用 admin/admin123 登录
- 确认进入客服工作台(四栏布局)
- 确认对话列表显示刚才的对话
- 点击对话,确认消息显示在中间栏
**如果以上步骤都正常,说明系统基本功能正常!** ✅
详细测试请参考 [九、快速测试流程(核心功能验证)](#九快速测试流程核心功能验证)
---
### 1. 检查数据库配置
确保 `backend/.env` 文件存在并配置正确:
```env
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=CS
```
**注意**
- `DB_PASSWORD` 改成你的 MySQL 密码
- `DB_NAME=CS` 是数据库名,如果不存在会自动创建(需要先手动创建数据库)
### 2. 创建数据库(如果还没有)
```sql
CREATE DATABASE CS CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
## 二、启动后端
### 步骤 1:进入后端目录
```bash
cd backend
```
### 步骤 2:安装依赖(如果还没安装)
```bash
go mod tidy
```
### 步骤 3:启动后端服务
```bash
go run main.go
```
**成功标志**
- 看到类似这样的输出:
```
✅ 找到 .env 文件: D:\tools\AI-CS\backend\.env
✅ .env 文件加载成功
✅ 管理员账号已存在
[GIN-debug] POST /login --> ...
[GIN-debug] POST /logout --> ...
[GIN-debug] POST /conversation/init --> ...
[GIN-debug] GET /conversations --> ...
[GIN-debug] GET /conversations/:id --> ...
[GIN-debug] PUT /conversations/:id/contact --> ...
[GIN-debug] GET /conversations/search --> ...
[GIN-debug] POST /messages --> ...
[GIN-debug] GET /messages --> ...
[GIN-debug] PUT /messages/read --> ...
[GIN-debug] GET /ws --> ...
🚀 服务器启动成功,监听 :8080
📡 WebSocket 服务已启动,路径: /ws?conversation_id=<对话ID>
```
**如果出错**
- 数据库连接失败:检查 `.env` 配置和 MySQL 是否启动
- 端口被占用:检查 8080 端口是否被其他程序占用
## 三、启动前端
### 步骤 1:打开新的终端窗口
**重要**:后端和前端要在不同的终端窗口运行!
### 步骤 2:进入前端目录
```bash
cd frontend
```
### 步骤 3:安装依赖(如果还没安装)
```bash
npm install
```
### 步骤 4:启动前端开发服务器
```bash
npm run dev
```
**成功标志**
- 看到类似这样的输出:
```
▲ Next.js 15.5.3
- Local: http://localhost:3000
- Ready in 2.3s
```
### 步骤 5:打开浏览器
访问:`http://localhost:3000`
## 四、测试流程
### ⚠️ 重要说明
**系统包含两个端**
- **访客端** (`/chat`):不需要登录,直接访问即可使用
- **客服端** (`/``/agent/dashboard`):需要登录,使用管理员/客服账号
**测试时你需要分别测试两个端**
**已实现的核心功能**
- ✅ 客服工作台(四栏布局:导航栏、对话列表、聊天内容、访客详情)
- ✅ WebSocket 实时通信(消息实时推送、已读状态同步、在线状态更新)
- ✅ 对话搜索功能(支持搜索消息内容、对话ID、访客ID)
- ✅ 访客信息收集和显示(网站、来源、浏览器、操作系统、语言、IP地址)
- ✅ 联系信息编辑(邮箱、电话、备注)
- ✅ 在线/离线状态显示(实时更新)
- ✅ 已读状态同步(单对勾/双对勾)
- ✅ 系统消息显示(访客打开页面、来源信息等)
- ✅ 关键词高亮(搜索时高亮显示)
- ✅ 消息预览(对话列表显示最后一条消息)
## 四、访客端测试流程
### 测试 1:访问聊天页面(访客端)
1. 在浏览器打开 `http://localhost:3000/chat`
2. **预期结果**
- 看到顶部标题栏(渐变背景)
- 显示"访客聊天"和会话ID、访客ID(自动生成)
- 消息列表区域显示系统消息(如 "Visitor opened the page [URL]"
- 系统消息居中显示(灰色背景,圆角边框)
- 显示当前页面URL(如果有)
- 如果有来源页面,显示 "Visitor came from [referrer URL]"
- 系统消息居中显示(灰色背景,圆角边框)
- 显示来源页面URL
- 底部有输入框和"发送"按钮
- **不需要登录,直接可以使用!**
- WebSocket 连接自动建立(查看浏览器控制台,应该看到 "✅ WebSocket 连接成功: 对话ID=X"
- 后端日志显示:`✅ WebSocket 连接已建立: 对话ID=X, 是访客=true`
- 后端日志显示:`✅ 客户端已连接: 对话ID=X, 总连接数=1, 访客连接数=1`
### 测试 2:发送第一条消息
1. 在输入框输入:"你好,这是第一条消息"
2. 点击"发送"按钮
3. **预期结果**
- 输入框清空
- 消息立即出现在右侧(蓝色气泡,显示"我")
- 显示时间(如 "14:30"
- 显示已读状态(单对勾,表示已发送但未读)
- 页面自动滚动到底部
- 消息通过 WebSocket 实时推送到客服端
### 测试 3:发送多条消息
1. 连续发送 3-5 条消息
2. **预期结果**
- 每条消息都显示在右侧
- 消息按时间顺序排列
- 每次发送后自动滚动到底部
- 每条消息显示已读状态(单对勾)
### 测试 4:接收客服消息(实时推送)
1. **打开两个浏览器标签页**
- 标签页1:访客端 (`http://localhost:3000/chat`)
- 标签页2:客服端 (`http://localhost:3000/agent/dashboard`),需要先登录
2. **在客服端发送消息**:"您好,我是客服,有什么可以帮您的吗?"
3. **预期结果**(访客端):
- **无需刷新页面**,消息自动出现在左侧(白色气泡)
- 消息显示时间
- 自动滚动到底部(如果当前在底部)
- 显示已读状态(单对勾,表示客服已读)
- 这是通过 WebSocket 实时推送的!
### 测试 5:已读状态同步
1. **在客服端查看访客消息**(客服端会自动标记已读)
2. **预期结果**(访客端):
- **无需刷新页面**,已读状态从单对勾变为双对勾
- 对勾颜色变为蓝色(表示已读)
- 这是通过 WebSocket 实时推送 `messages_read` 事件实现的!
### 测试 6:刷新页面
1. 按 `F5` 刷新页面
2. **预期结果**
- 之前发送的消息还在(因为存到数据库了)
- 自动拉取历史消息
- 访客ID不变(因为存在 localStorage
- 系统消息显示(如 "Visitor opened the page"
- WebSocket 连接重新建立
### 测试 7:打开新标签页(验证 localStorage 共享)
1. 打开新的浏览器标签页,访问 `http://localhost:3000/chat`
2. **预期结果**
- **使用的是同一个访客ID**(因为 localStorage 在同一浏览器不同标签页之间共享)
- **显示同一个对话**(因为 visitor_id 相同)
- **能看到之前发送的消息**(因为是同一个对话)
- 系统消息显示(如 "Visitor opened the page"
**注意**
- 同一浏览器不同标签页 → 共享 localStorage → 同一个访客ID → 同一个对话
- 如果要测试新访客,需要:
- 使用不同的浏览器(Chrome、Edge、Firefox等)
- 或者清除浏览器缓存/localStorage
- 或者使用隐私/无痕模式
### 测试 8:测试错误处理
1. **停止后端**(在运行 `go run main.go` 的终端按 `Ctrl+C`
2. 尝试发送消息
3. **预期结果**
- 弹出错误提示:"发送消息失败,请稍后重试"
- 按钮恢复正常(不再是"发送中..."
- WebSocket 连接断开(查看浏览器控制台,应该看到 "❌ WebSocket 连接关闭"
---
## 五、客服端测试流程
### 测试 1:客服登录
1. 访问 `http://localhost:3000/`(根路径)
2. **预期结果**
- 看到客服登录页面
- 有用户名和密码输入框
- 有"登录"按钮
- 显示默认管理员账号提示(用户名: admin,密码: admin123
3. **输入登录信息**
- 用户名:`admin`
- 密码:`admin123`
4. 点击"登录"按钮
5. **预期结果**
- 登录成功,自动跳转到 `/agent/dashboard`(客服工作台)
- 浏览器 localStorage 中保存了 `agent_user_id``agent_username``agent_role`
### 测试 2:客服工作台(四栏布局)
1. 登录成功后,自动进入客服工作台
2. **预期结果**
- **最左侧导航栏**(固定宽度 64px):显示导航图标
- **左侧对话列表栏**(固定宽度 320px):显示所有对话列表
- **中间聊天内容栏**(自适应宽度):显示当前选中对话的消息
- **右侧访客详情栏**(固定宽度 320px):显示当前选中对话的访客详情
- 顶部显示当前登录的用户名和角色(如:"admin (管理员)"
- 显示"退出"按钮
### 测试 3:查看对话列表
1. 在工作台左侧查看对话列表
2. **预期结果**
- 显示所有未关闭的对话列表(如果有访客发送过消息)
- 每条对话显示:
- 对话头像(圆形,基于 visitor_id 生成颜色)
- 对话ID、访客ID
- 在线/离线状态图标(绿色圆点 = 在线)
- 状态标签("进行中"或"已关闭"
- 最后一条消息预览(显示最后一条消息的前50个字符)
- 未读消息数量(蓝色徽标,如果有未读消息)
- 已读/未读状态图标(单/双对勾)
- 最后更新时间(智能格式化:今天显示时间,更早显示日期+时间)
- 对话按更新时间倒序排列(最新的在前)
- 选中对话高亮显示(蓝色背景)
3. **如果没有对话**
- 需要先访问 `/chat` 页面(访客端)发送一些消息
- 然后刷新客服端,就能看到对话了
### 测试 4:选择对话
1. 在对话列表中,点击任意一条对话
2. **预期结果**
- 对话高亮显示(蓝色背景)
- **中间栏**自动加载该对话的消息
- **右侧栏**自动加载该对话的访客详情
- WebSocket 连接自动切换到该对话
- 无需跳转页面,所有内容在同一页面更新
### 测试 5:查看对话消息
1. 选择一条对话后,查看中间栏的消息列表
2. **预期结果**
- 显示该对话的所有消息(包括系统消息)
- **访客消息在左侧**(白色气泡)
- **客服消息在右侧**(蓝色气泡)
- 系统消息居中显示(灰色背景,如 "Visitor opened the page [URL]"
- 每条消息显示时间(智能格式化)
- 客服消息显示已读/未读状态(单/双对勾)
- 自动滚动到底部(如果是新对话)
- 消息按时间顺序排列
### 测试 6:客服发送消息
1. 在中间栏输入框输入消息:"您好,我是客服,有什么可以帮您的吗?"
2. 点击"发送"按钮
3. **预期结果**
- 输入框清空
- 消息立即出现在右侧(蓝色气泡,客服消息)
- 显示时间
- 显示已读状态(单对勾,表示已发送但未读)
- 页面自动滚动到底部
- 消息通过 WebSocket 实时推送到访客端
- 对话列表中的最后一条消息预览自动更新
### 测试 7:接收访客消息(实时推送)
1. **打开两个浏览器标签页**
- 标签页1:访客端 (`http://localhost:3000/chat`)
- 标签页2:客服端 (`http://localhost:3000/agent/dashboard`),需要先登录
2. **在访客端发送消息**"你好,我需要帮助"
3. **预期结果**(客服端):
- **无需刷新页面**,消息自动出现在左侧(白色气泡)
- 消息显示时间
- 自动滚动到底部(如果当前在底部)
- 对话列表中的最后一条消息预览自动更新
- 未读消息数量自动更新(蓝色徽标)
- 这是通过 WebSocket 实时推送的!
### 测试 8:已读状态同步
1. **在客服端查看访客消息**(客服端会自动标记已读)
2. **预期结果**(访客端):
- **无需刷新页面**,已读状态从单对勾变为双对勾
- 对勾颜色变为蓝色(表示已读)
- 这是通过 WebSocket 实时推送 `messages_read` 事件实现的!
3. **预期结果**(客服端):
- 未读消息数量自动减少
- 已读状态图标更新(双对勾)
### 测试 9:对话搜索功能
1. 在左侧对话列表顶部,找到搜索框(显示 "Q Search" 或搜索图标)
2. **输入搜索关键词**(例如:对话ID、访客ID、消息内容)
- 例如:搜索 "你好"(如果消息中包含"你好")
- 例如:搜索对话ID "10"(如果对话ID为10
- 例如:搜索访客ID "176"(如果访客ID为176
3. **预期结果**
- 实时搜索(300ms 防抖,输入后等待 300ms 才开始搜索)
- 显示匹配的对话列表(只显示包含关键词的对话)
- 搜索结果高亮显示(关键词黄色背景高亮,使用 `<mark>` 标签)
- 点击搜索结果,自动定位到匹配消息
- 如果匹配消息在历史记录中,自动滚动到匹配消息并居中显示
- 如果匹配消息是最后一条,自动滚动到底部
- 高亮显示持续 3 秒后自动清除
- 清除搜索后,显示所有对话
- 搜索框为空时,显示所有对话
### 测试 10:查看访客详情
1. 选择一条对话后,查看右侧栏的访客详情
2. **预期结果**
- 显示访客头像(圆形,基于 visitor_id 生成颜色)
- 显示访客ID(例如:"访客 #176"
- 显示在线/离线状态(绿色圆点 = 在线,灰色 = 离线)
- 显示刷新按钮(圆形箭头图标)和更多选项图标(三个点)
- **联系信息区域**
- 邮箱(可以编辑,显示"+ Add"或"编辑"按钮)
- 电话(可以编辑,显示"+ Add"或"编辑"按钮)
- 备注(可以编辑,显示"+ Add"或"编辑"按钮)
- **技术信息区域**
- 网站(当前页面URL,如果有,显示为链接)
- 来源(referrer,从哪个页面跳转过来,如果有,显示为链接)
- 语言(浏览器语言设置,例如:"zh-CN"
- 浏览器(浏览器信息,例如:"Edge"、"Chrome"
- 操作系统(操作系统信息,例如:"Windows"、"macOS"
- IP地址(后端获取,例如:"127.0.0.1"
- 位置(暂未实现,显示"暂未收集")
- 最后活跃时间(智能格式化,例如:"刚刚"、"5分钟前"、"今天 14:30"
### 测试 11:编辑联系信息
1. 在右侧栏访客详情中,找到"联系信息"区域
2. **编辑邮箱**
- 点击"邮箱"右侧的"+ Add"或"编辑"按钮
- 弹出编辑弹窗(输入框 + "保存"和"取消"按钮)
- 输入邮箱地址(例如:`visitor@example.com`
- 点击"保存"按钮
- **预期结果**
- 编辑弹窗关闭
- 邮箱立即更新显示
- 数据保存到后端(查看后端日志,应该看到 `PUT /conversations/:id/contact` 请求)
- 刷新后数据还在
- 如果保存失败,显示错误提示
3. **编辑电话**
- 点击"电话"右侧的"+ Add"或"编辑"按钮
- 弹出编辑弹窗
- 输入电话号码(例如:`13800138000`
- 点击"保存"按钮
- **预期结果**
- 编辑弹窗关闭
- 电话立即更新显示
- 数据保存到后端
- 刷新后数据还在
4. **编辑备注**
- 点击"备注"右侧的"+ Add"或"编辑"按钮
- 弹出编辑弹窗(支持多行文本)
- 输入备注内容(例如:`重要客户,需要重点关注`
- 点击"保存"按钮
- **预期结果**
- 编辑弹窗关闭
- 备注立即更新显示(支持换行显示)
- 数据保存到后端
- 刷新后数据还在
5. **取消编辑**
- 点击编辑按钮,弹出编辑弹窗
- 修改内容(或不修改)
- 点击"取消"按钮
- **预期结果**
- 编辑弹窗关闭
- 内容不更新(保持原值)
6. **清空联系信息**
- 点击编辑按钮,弹出编辑弹窗
- 清空输入框内容
- 点击"保存"按钮
- **预期结果**
- 编辑弹窗关闭
- 联系信息清空,显示"暂未填写"
- 数据保存到后端(空值)
- 刷新后数据还在(显示"暂未填写")
### 测试 12:在线/离线状态显示
1. **访客在线时**
- 在访客端打开 `/chat` 页面
- **预期结果**(客服端):
- 对话列表中显示绿色圆点(在线)
- 右侧栏显示"● 在线"
- 这是通过 WebSocket 实时更新的!
2. **访客离线时**
- 关闭访客端页面(或关闭浏览器标签页)
- **预期结果**(客服端):
- 对话列表中绿色圆点消失(或变为灰色)
- 右侧栏显示"● 离线"
- 最后活跃时间更新
- 这是通过 WebSocket 实时更新的!
### 测试 13:刷新访客详情
1. 在右侧栏访客详情顶部,找到刷新按钮(圆形箭头图标)
2. 点击刷新按钮
3. **预期结果**
- 访客详情重新加载
- 显示最新的访客信息(包括联系信息、技术信息、最后活跃时间等)
### 测试 14:返回对话列表
1. 在工作台左侧对话列表中,点击其他对话
2. **预期结果**
- 对话切换,中间栏和右侧栏自动更新
- WebSocket 连接自动切换
- 无需跳转页面,所有内容在同一页面更新
### 测试 15:退出登录
1. 在工作台顶部,找到"退出"按钮
2. 点击"退出"按钮
3. **预期结果**
- localStorage 中的 `agent_user_id``agent_username``agent_role` 被清除
- 自动跳转到登录页面 (`/`)
4. **验证登录状态**
- 尝试直接访问 `/agent/dashboard`
- **预期结果**:自动跳转到登录页面(因为未登录)
### 测试 16:登录状态检查
1. **清除 localStorage**(在浏览器控制台执行):
```javascript
localStorage.clear()
```
2. 尝试直接访问 `/agent/dashboard`
3. **预期结果**
- 自动跳转到登录页面(因为未登录)
---
## 六、调试技巧
### 1. 打开浏览器开发者工具
`F12` 打开开发者工具
### 2. 查看控制台(Console
- 如果有红色错误,说明代码有问题
- 如果有 `console.error` 输出,说明后端请求失败
- 查看 WebSocket 连接状态:
- `✅ WebSocket 连接成功: 对话ID=X` → 连接成功
- `❌ WebSocket 连接关闭: 对话ID=X` → 连接断开
- `WebSocket 连接错误: {}` → 连接失败(可能是后端未启动)
### 3. 查看网络请求(Network
- 点击"发送"后,查看是否有 `POST /messages` 请求
- 查看 WebSocket 连接:找到 `ws``wss` 类型的请求
- 查看请求状态:
- `200` = 成功
- `400` = 请求参数错误
- `500` = 服务器错误
- 查看 WebSocket 消息:
- 点击 WebSocket 连接,查看 "Messages" 标签
- 应该看到 `new_message``messages_read``visitor_status_update` 等事件
### 4. 查看后端日志
在后端运行的终端窗口,查看日志输出:
- 如果看到 `✅ 客户端已连接: 对话ID=X, 总连接数=Y, 访客连接数=Z`,说明 WebSocket 连接成功
- 如果看到 `❌ 客户端已断开: 对话ID=X, 剩余访客连接数=Y`,说明 WebSocket 断开
- 如果看到 `✅ WebSocket 连接已建立: 对话ID=X, 是访客=true/false`,说明连接建立
- 如果看到 `更新访客在线状态失败: ...`,说明状态更新失败
- 如果有错误信息,说明后端处理有问题
## 七、常见问题
### 问题 1:后端启动失败
**错误**`数据库连接失败`
**解决**
- 检查 MySQL 是否启动
- 检查 `.env` 配置是否正确
- 检查数据库是否存在
### 问题 2:前端启动失败
**错误**`npm install` 失败
**解决**
- 检查 Node.js 版本(需要 18+
- 删除 `node_modules` 文件夹,重新 `npm install`
### 问题 3:页面空白
**错误**:浏览器显示空白页
**解决**
- 打开开发者工具(F12)查看错误
- 检查后端是否启动
- 检查前端是否启动在 3000 端口
### 问题 4:发送消息失败
**错误**:点击发送没反应
**解决**
- 打开浏览器控制台(F12)查看错误
- 检查后端是否启动在 8080 端口
- 检查 `frontend/lib/config.ts` 中的 API 地址是否正确
### 问题 5WebSocket 连接失败
**错误**`WebSocket 连接错误: {}`
**解决**
- 检查后端是否启动
- 检查后端是否监听 8080 端口
- 检查浏览器控制台错误信息
- 查看后端日志,确认 WebSocket 服务是否启动
### 问题 6:消息不实时推送
**错误**:发送消息后,对方看不到
**解决**
- 检查 WebSocket 连接状态(查看浏览器控制台)
- 检查后端日志,确认 WebSocket 是否正常广播
- 检查后端是否正常处理 WebSocket 消息
- 确认双方都建立了 WebSocket 连接
### 问题 7:已读状态不同步
**错误**:已读状态不更新
**解决**
- 检查 WebSocket 连接状态
- 检查后端是否正常推送 `messages_read` 事件
- 检查前端是否正常处理 `messages_read` 事件
- 查看浏览器控制台和后端日志
### 问题 8:在线状态不更新
**错误**:在线状态不实时更新
**解决**
- 检查 WebSocket 连接状态
- 检查后端是否正常推送 `visitor_status_update` 事件
- 检查前端是否正常处理 `visitor_status_update` 事件
- 查看浏览器控制台和后端日志
- 确认访客端 WebSocket 连接是否正常(`is_visitor=true`
### 问题 9:对话列表不显示
**错误**:对话列表为空
**解决**
- 确认是否有访客发送过消息
- 检查后端是否正常返回对话列表
- 查看浏览器控制台错误信息
- 检查后端日志,确认数据库查询是否正常
### 问题 10:搜索功能不工作
**错误**:搜索无结果
**解决**
- 检查搜索关键词是否正确
- 检查后端搜索接口是否正常
- 查看浏览器控制台错误信息
- 检查后端日志,确认搜索查询是否正常
## 八、完整测试检查清单
完成以下测试后,打勾:
### 一、基础功能测试
#### 访客端基础功能:
- [ ] 后端启动成功(显示监听 :8080 和 WebSocket 服务)
- [ ] 前端启动成功(显示 Ready)
- [ ] 访问 `/chat` 页面正常显示
- [ ] 自动生成/获取访客ID
- [ ] 自动初始化对话(获取对话ID)
- [ ] 显示系统消息(如 "Visitor opened the page [URL]"
- [ ] WebSocket 连接自动建立(查看浏览器控制台)
- [ ] 发送第一条消息成功
- [ ] 消息显示在右侧(蓝色气泡)
- [ ] 显示已读状态(单对勾)
- [ ] 发送后自动滚动到底部
- [ ] 输入框清空,可以继续输入
- [ ] 时间显示正确(今天的只显示时间,更早的显示日期+时间)
- [ ] 可以连续发送多条消息
- [ ] 输入框为空时,发送按钮禁用
- [ ] 刷新页面后消息还在
- [ ] 访客信息自动收集(网站、来源、浏览器、操作系统、语言、IP地址)
#### 客服端基础功能:
- [ ] 访问 `/` 显示登录页面
- [ ] 使用 admin/admin123 可以成功登录
- [ ] 登录后跳转到 `/agent/dashboard`(客服工作台)
- [ ] 四栏布局正常显示(导航栏、对话列表、聊天内容、访客详情)
- [ ] 对话列表显示所有未关闭的对话
- [ ] 对话列表显示在线/离线状态图标(绿色圆点 = 在线)
- [ ] 对话列表显示最后一条消息预览
- [ ] 对话列表显示未读消息数量(蓝色徽标)
- [ ] 对话列表显示已读/未读状态图标(单/双对勾)
- [ ] 选择对话后,中间栏和右侧栏自动更新
- [ ] WebSocket 连接自动切换
- [ ] 客服消息显示在右侧(蓝色气泡)
- [ ] 访客消息显示在左侧(白色气泡)
- [ ] 系统消息居中显示(灰色背景)
- [ ] 客服可以发送消息
- [ ] 退出登录功能正常
- [ ] 未登录访问客服页面会跳转到登录页
### 二、实时通信测试
#### WebSocket 实时推送:
- [ ] 访客发送消息后,客服端实时收到(无需刷新)
- [ ] 客服发送消息后,访客端实时收到(无需刷新)
- [ ] 消息通过 WebSocket 实时推送
- [ ] WebSocket 连接自动重连(断开后自动重连)
#### 已读状态同步:
- [ ] 客服查看访客消息后,访客端实时更新已读状态(单对勾 → 双对勾)
- [ ] 访客查看客服消息后,客服端实时更新已读状态(单对勾 → 双对勾)
- [ ] 已读状态通过 WebSocket 实时推送
- [ ] 对勾颜色正确(已读 = 蓝色,未读 = 灰色)
#### 在线状态更新:
- [ ] 访客打开页面后,客服端实时显示在线状态(绿色圆点)
- [ ] 访客关闭页面后,客服端实时显示离线状态(灰色圆点或消失)
- [ ] 在线状态通过 WebSocket 实时推送
- [ ] 最后活跃时间实时更新
### 三、搜索功能测试
#### 对话搜索:
- [ ] 搜索框UI正常显示
- [ ] 输入搜索关键词后,实时搜索(300ms 防抖)
- [ ] 搜索结果正确显示(匹配的对话列表)
- [ ] 搜索结果高亮显示(关键词黄色背景高亮)
- [ ] 点击搜索结果,自动定位到匹配消息
- [ ] 历史消息居中显示,最后一条滚动到底部
- [ ] 清除搜索后,显示所有对话
- [ ] 支持搜索消息内容、对话ID、访客ID
### 四、访客信息测试
#### 访客信息收集:
- [ ] 访客端自动收集网站(当前页面URL)
- [ ] 访客端自动收集来源(referrer)
- [ ] 访客端自动收集浏览器信息
- [ ] 访客端自动收集操作系统信息
- [ ] 访客端自动收集语言信息
- [ ] 后端自动获取IP地址
- [ ] 客服端显示所有访客信息
#### 联系信息编辑:
- [ ] 编辑邮箱功能正常(新增、修改、清空)
- [ ] 编辑电话功能正常(新增、修改、清空)
- [ ] 编辑备注功能正常(新增、修改、清空)
- [ ] 联系信息保存到后端
- [ ] 刷新后联系信息还在
- [ ] 编辑弹窗正常显示和关闭
### 五、界面交互测试
#### 滚动行为:
- [ ] 消息列表内部滚动正常(不会滚动整个页面)
- [ ] 对话列表内部滚动正常(不会滚动整个页面)
- [ ] 访客详情内部滚动正常(不会滚动整个页面)
- [ ] 发送新消息后自动滚动到底部
- [ ] 长消息历史正常滚动
#### 响应式布局:
- [ ] 四栏布局正常显示
- [ ] 各栏宽度正确(导航栏 64px,对话列表 320px,访客详情 320px,聊天内容自适应)
- [ ] 各栏高度正确(顶部栏 h-16,内容区域自适应)
- [ ] 选中对话高亮显示(蓝色背景)
### 六、错误处理测试
#### 网络错误:
- [ ] 后端停止后,发送消息显示错误提示
- [ ] WebSocket 连接断开后,显示连接关闭提示
- [ ] 网络恢复后,WebSocket 自动重连
- [ ] 错误提示清晰明确
#### 数据验证:
- [ ] 输入框为空时,发送按钮禁用
- [ ] 输入框有内容时,发送按钮启用
- [ ] 编辑联系信息时,输入验证正常
- [ ] 清空联系信息时,数据正确保存
### 七、性能测试
#### 加载性能:
- [ ] 页面加载速度正常(< 2秒)
- [ ] 对话列表加载速度正常(< 1秒)
- [ ] 消息列表加载速度正常(< 1秒)
- [ ] 访客详情加载速度正常(< 1秒)
#### 实时性能:
- [ ] 消息实时推送延迟 < 100ms
- [ ] 已读状态同步延迟 < 100ms
- [ ] 在线状态更新延迟 < 100ms
- [ ] WebSocket 连接稳定(不断开)
### 八、兼容性测试
#### 浏览器兼容:
- [ ] Chrome 浏览器正常
- [ ] Edge 浏览器正常
- [ ] Firefox 浏览器正常
- [ ] Safari 浏览器正常(如果可用)
#### 数据持久化:
- [ ] 刷新页面后,消息还在
- [ ] 刷新页面后,访客ID不变
- [ ] 刷新页面后,登录状态保持
- [ ] 联系信息编辑后,刷新后数据还在
---
## 九、快速测试流程(核心功能验证)
> 💡 如果你时间有限,可以按照以下流程快速验证核心功能是否正常
### 快速测试步骤:
1. **启动系统**5分钟)
- [ ] 启动后端:`cd backend && go run main.go`
- [ ] 启动前端:`cd frontend && npm run dev`
- [ ] 访问 `http://localhost:3000`
2. **访客端测试**5分钟)
- [ ] 访问 `http://localhost:3000/chat`
- [ ] 发送消息:"你好,这是测试消息"
- [ ] 确认消息显示在右侧(蓝色气泡)
- [ ] 确认 WebSocket 连接建立(查看浏览器控制台)
3. **客服端测试**5分钟)
- [ ] 访问 `http://localhost:3000/`,使用 admin/admin123 登录
- [ ] 确认进入客服工作台(四栏布局)
- [ ] 确认对话列表显示刚才的对话
- [ ] 点击对话,确认消息显示在中间栏
- [ ] 发送消息:"您好,我是客服"
- [ ] 确认消息显示在右侧(蓝色气泡)
4. **实时通信测试**5分钟)
- [ ] 打开两个浏览器标签页(访客端和客服端)
- [ ] 在访客端发送消息:"测试实时推送"
- [ ] 确认客服端实时收到消息(无需刷新)
- [ ] 在客服端发送消息:"测试实时推送回复"
- [ ] 确认访客端实时收到消息(无需刷新)
5. **已读状态测试**3分钟)
- [ ] 在客服端查看访客消息
- [ ] 确认访客端已读状态从单对勾变为双对勾(无需刷新)
6. **在线状态测试**3分钟)
- [ ] 确认客服端对话列表显示绿色圆点(在线)
- [ ] 关闭访客端页面
- [ ] 确认客服端绿色圆点消失(或变为灰色)
7. **搜索功能测试**2分钟)
- [ ] 在客服端搜索框输入关键词
- [ ] 确认搜索结果高亮显示
- [ ] 点击搜索结果,确认自动定位到匹配消息
8. **联系信息编辑测试**3分钟)
- [ ] 在客服端右侧栏,点击"邮箱"右侧的"编辑"按钮
- [ ] 输入邮箱地址,点击"保存"
- [ ] 确认邮箱立即更新显示
**如果以上测试都通过,说明核心功能正常!** ✅
---
## 十、高级测试场景
### 场景 1:多个访客同时聊天
1. **打开多个浏览器**(或使用隐私/无痕模式)
- 浏览器1:访客A (`http://localhost:3000/chat`)
- 浏览器2:访客B (`http://localhost:3000/chat`)
- 浏览器3:客服端 (`http://localhost:3000/agent/dashboard`)
2. **测试流程**
- 访客A 发送消息:"我是访客A"
- 访客B 发送消息:"我是访客B"
- 客服端应该看到两个不同的对话
- 客服可以选择不同的对话进行回复
- 每个访客只能看到自己的对话
### 场景 2:并发消息测试
1. **打开两个浏览器标签页**(访客端和客服端)
2. **快速发送多条消息**
- 访客端连续发送 10 条消息
- 客服端连续发送 10 条消息
3. **预期结果**
- 所有消息都实时推送
- 消息顺序正确
- 已读状态正确同步
- 无消息丢失
- 无重复消息
### 场景 3:长时间连接测试
1. **打开访客端和客服端**
2. **保持连接 30 分钟**
3. **预期结果**
- WebSocket 连接保持稳定(不断开)
- 消息正常推送
- 已读状态正常同步
- 在线状态正常更新
- 无内存泄漏
### 场景 4:网络中断恢复测试
1. **打开访客端和客服端**
2. **断开网络**(关闭 WiFi 或拔掉网线)
3. **等待 10 秒**
4. **恢复网络**
5. **预期结果**
- WebSocket 连接自动重连
- 消息正常推送
- 已读状态正常同步
- 在线状态正常更新
### 场景 5:大量消息历史测试
1. **创建一个对话**
2. **发送 100+ 条消息**
3. **预期结果**
- 消息列表正常加载
- 滚动性能正常(不卡顿)
- 消息显示正确
- 搜索功能正常(可以搜索历史消息)
- 关键词高亮正常
### 场景 6:搜索性能测试
1. **创建多个对话**10+ 个对话)
2. **每个对话发送多条消息**10+ 条消息)
3. **测试搜索功能**
- 搜索消息内容
- 搜索对话ID
- 搜索访客ID
4. **预期结果**
- 搜索速度正常(< 500ms
- 搜索结果正确
- 关键词高亮正常
- 点击搜索结果自动定位正常
### 场景 7:联系信息编辑并发测试
1. **打开多个客服端**(使用不同的浏览器)
2. **同时编辑同一个访客的联系信息**
- 客服A 编辑邮箱
- 客服B 编辑电话
3. **预期结果**
- 数据正确保存
- 无数据冲突
- 刷新后数据正确
### 场景 8:在线状态多访客测试
1. **打开多个访客端**3+ 个访客)
2. **测试在线状态**
- 访客A 在线
- 访客B 在线
- 访客C 离线
3. **预期结果**(客服端):
- 对话列表正确显示在线/离线状态
- 在线状态实时更新
- 最后活跃时间正确更新
---
## 十一、测试结果记录
### 测试日期:__________
### 测试人员:__________
### 测试环境:
- 后端版本:__________
- 前端版本:__________
- 数据库版本:__________
- 浏览器版本:__________
### 测试结果:
- [ ] 所有测试通过
- [ ] 部分测试通过(请列出未通过的测试)
- [ ] 测试失败(请列出失败的测试)
### 问题记录:
1. __________
2. __________
3. __________
### 备注:
__________
---
**测试完成后,你就知道整个系统是否正常工作了!** 🎉
**如果所有测试都通过,恭喜你!系统已经可以正常使用了!** ✅
+130
View File
@@ -0,0 +1,130 @@
# 系统角色说明
## 当前系统状态
### ✅ 已实现:访客端(`/chat` 页面)
**访客的特点**
- 不需要登录注册
- 访问 `/chat` 页面时自动生成访客ID
- 访客ID存储在浏览器的 localStorage 中
- 发送消息时,`sender_is_agent: false`, `sender_id: 0`
- **可以直接测试,无需任何配置!**
**访客能做什么**
- ✅ 自动创建对话
- ✅ 发送消息
- ✅ 查看历史消息
- ✅ 刷新页面后消息还在
**测试访客功能**
```bash
# 1. 启动后端和前端
# 2. 浏览器打开 http://localhost:3000/chat
# 3. 直接发送消息测试,无需登录!
```
---
### ❌ 未实现:客服端(需要单独开发)
**客服的特点**(未来功能):
- 需要登录(使用 `/register``/login` 接口)
- 登录后获取客服ID
- 发送消息时,`sender_is_agent: true`, `sender_id: 客服ID`
- 需要创建一个新的页面,比如 `/agent/chat`
**客服需要能做什么**(未来功能):
- 登录系统
- 查看所有对话列表
- 选择某个对话进行回复
- 发送消息时标识为客服
**后端已支持**
- ✅ 后端代码已经支持客服发送消息(`sender_is_agent: true`
- ✅ 后端会检查:如果是客服,`sender_id` 必须提供且不能为0
---
## 代码逻辑说明
### 后端检查逻辑(`backend/service/service.go`
```go
// 第122-125行
if req.SenderIsAgent && req.SenderID == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "客服id不能为空"})
return
}
```
**含义**
- 如果 `sender_is_agent == true`(是客服),那么 `sender_id` 不能为0
- 如果 `sender_is_agent == false`(是访客),`sender_id` 可以为0
**所以访客可以直接发送消息,不需要ID!**
### 前端发送逻辑(`frontend/app/chat/page.tsx`
```typescript
// 第187-188行
sender_is_agent: false, // false = 访客发的
sender_id: 0, // 访客ID(0表示不需要)
```
**这是访客端页面,所以固定为 `false``0`**
---
## 未来开发计划
### 客服端页面开发(待实现)
需要创建:
1. **客服登录页面**`/agent/login`
- 使用现有的 `/login` 接口
- 登录后保存客服ID和token
2. **客服对话列表页面**`/agent/conversations`
- 显示所有未关闭的对话
- 显示每个对话的访客ID、最后消息时间等
3. **客服聊天页面**`/agent/chat/[conversationId]`
- 类似访客聊天页面
- 发送消息时:`sender_is_agent: true`, `sender_id: 客服ID`
- 显示对话双方的消息(访客在左,客服在右)
---
## 测试建议
### 当前可以测试的(访客端)
1. ✅ 访问 `/chat` 页面
2. ✅ 发送消息(以访客身份)
3. ✅ 查看历史消息
4. ✅ 刷新页面后消息还在
### 暂时无法测试的(客服端)
1. ❌ 客服登录(页面还没创建)
2. ❌ 客服查看对话列表(页面还没创建)
3. ❌ 客服回复消息(页面还没创建)
**但你可以用两个浏览器窗口测试**
- 窗口1:访客A,发送消息
- 窗口2:访客B,查看对话(看到访客A的消息)
---
## 总结
**当前状态**
- ✅ 访客端完全可用,可以直接测试
- ❌ 客服端还未开发,需要单独实现
**测试时**
- 你就是在模拟访客角色
- 不需要任何登录或配置
- 直接访问 `/chat` 就可以发送消息!
**后端已经准备好了**,支持访客和客服两种角色,只是前端客服页面还没做而已。
Binary file not shown.
+1
View File
@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env*.example
# vercel
.vercel
@@ -0,0 +1,401 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { MessageList } from "@/components/dashboard/MessageList";
import { MessageInput } from "@/components/dashboard/MessageInput";
import { ChatHeader } from "@/components/dashboard/ChatHeader";
import { useAuth } from "@/features/agent/hooks/useAuth";
import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
import {
fetchMessages,
sendMessage as sendMessageApi,
markMessagesRead,
} from "@/features/agent/services/messageApi";
import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
import {
ConversationDetail,
ConversationSummary,
MessageItem,
MessagesReadPayload,
ChatWebSocketPayload,
} from "@/features/agent/types";
import type { WSMessage } from "@/lib/websocket";
export default function AgentChatPage() {
const params = useParams();
const router = useRouter();
const { agent, loading: authLoading } = useAuth();
const conversationId = useMemo(() => {
const id = params?.conversationId;
if (!id) {
return null;
}
const parsed = Number.parseInt(String(id), 10);
return Number.isNaN(parsed) ? null : parsed;
}, [params]);
const [messages, setMessages] = useState<MessageItem[]>([]);
const [conversationDetail, setConversationDetail] =
useState<ConversationDetail | null>(null);
const [conversationSummary, setConversationSummary] =
useState<ConversationSummary | null>(null);
const [messageInput, setMessageInput] = useState("");
const [loadingMessages, setLoadingMessages] = useState(true);
const [sending, setSending] = useState(false);
const [highlightKeyword, setHighlightKeyword] = useState("");
const handleMarkMessagesRead = useCallback(
async (
conversationIdParam?: number,
readerIsAgentParam?: boolean
) => {
const targetConversationId = conversationIdParam ?? conversationId;
const targetReaderIsAgent = readerIsAgentParam ?? true;
if (!targetConversationId) {
return;
}
const result = await markMessagesRead(
targetConversationId,
targetReaderIsAgent
);
if (!result || result.message_ids.length === 0) {
return;
}
const idSet = new Set(result.message_ids);
setMessages((prev) =>
prev.map((msg) =>
idSet.has(msg.id)
? {
...msg,
is_read: true,
read_at: result.read_at ?? msg.read_at ?? null,
}
: msg
)
);
if (targetReaderIsAgent) {
setConversationDetail((prev) =>
prev ? { ...prev, unread_count: result.unread_count } : prev
);
setConversationSummary((prev) =>
prev ? { ...prev, unread_count: result.unread_count } : prev
);
} else {
setConversationDetail((prev) =>
prev
? {
...prev,
last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
}
: prev
);
}
},
[conversationId]
);
const loadConversationDetail = useCallback(async () => {
if (!conversationId) {
return;
}
const detail = await fetchConversationDetail(conversationId);
if (detail) {
setConversationDetail(detail);
setConversationSummary({
id: detail.id,
visitor_id: detail.visitor_id,
agent_id: detail.agent_id,
status: detail.status,
created_at: detail.created_at,
updated_at: detail.updated_at,
last_message: detail.last_message,
unread_count: detail.unread_count ?? 0,
});
}
}, [conversationId]);
const loadMessages = useCallback(async () => {
if (!conversationId) {
return;
}
setLoadingMessages(true);
try {
const data = await fetchMessages(conversationId);
setMessages(data);
// 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
} catch (error) {
console.error("拉取消息失败:", error);
} finally {
setLoadingMessages(false);
}
}, [conversationId]);
useEffect(() => {
if (!conversationId) {
router.push("/agent/dashboard");
}
}, [conversationId, router]);
useEffect(() => {
if (!conversationId || !agent) {
return;
}
loadConversationDetail();
loadMessages();
}, [conversationId, agent, loadConversationDetail, loadMessages]);
const handleSendMessage = useCallback(async () => {
if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
return;
}
setSending(true);
try {
await sendMessageApi({
conversationId,
content: messageInput,
senderId: agent.id,
});
setMessageInput("");
} catch (error) {
console.error(error);
alert((error as Error).message);
} finally {
setSending(false);
}
}, [agent?.id, conversationId, messageInput, sending]);
const handleNewMessage = useCallback(
(message: MessageItem) => {
setMessages((prev) => {
const exists = prev.some((item) => item.id === message.id);
if (exists) {
return prev;
}
return [...prev, message];
});
setConversationDetail((prev) => {
if (!prev) {
return prev;
}
const nextUnread =
!message.sender_is_agent && message.message_type !== "system_message"
? (prev.unread_count ?? 0) + 1
: prev.unread_count ?? 0;
return {
...prev,
updated_at: message.created_at,
unread_count:
message.sender_is_agent || message.message_type === "system_message"
? prev.unread_count ?? 0
: nextUnread,
last_message: {
id: message.id,
content: message.content,
sender_is_agent: message.sender_is_agent,
message_type: message.message_type ?? "user_message",
is_read: Boolean(message.is_read),
read_at: message.read_at ?? null,
created_at: message.created_at,
},
};
});
setConversationSummary((prev) => {
if (!prev) {
return prev;
}
const nextUnread =
!message.sender_is_agent && message.message_type !== "system_message"
? (prev.unread_count ?? 0) + 1
: prev.unread_count ?? 0;
return {
...prev,
updated_at: message.created_at,
unread_count:
message.sender_is_agent || message.message_type === "system_message"
? prev.unread_count ?? 0
: nextUnread,
last_message: {
id: message.id,
content: message.content,
sender_is_agent: message.sender_is_agent,
message_type: message.message_type ?? "user_message",
is_read: Boolean(message.is_read),
read_at: message.read_at ?? null,
created_at: message.created_at,
},
};
});
// 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
if (message.conversation_id === conversationId) {
loadConversationDetail();
}
},
[conversationId, loadConversationDetail]
);
const handleMessagesReadEvent = useCallback(
(payload: MessagesReadPayload) => {
if (!conversationId) {
return;
}
// 检查对话ID是否匹配
const payloadConversationId = payload?.conversation_id;
if (payloadConversationId && payloadConversationId !== conversationId) {
return;
}
const ids = Array.isArray(payload?.message_ids)
? payload.message_ids
: [];
if (ids.length === 0) {
return;
}
const readAt = payload?.read_at;
const readerIsAgent = Boolean(payload?.reader_is_agent);
const unreadCount =
typeof payload?.unread_count === "number"
? payload.unread_count
: undefined;
// 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
// 才更新客服消息(sender_is_agent === true)的已读状态
if (readerIsAgent) {
// 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
if (unreadCount !== undefined) {
setConversationDetail((prev) =>
prev ? { ...prev, unread_count: unreadCount } : prev
);
setConversationSummary((prev) =>
prev ? { ...prev, unread_count: unreadCount } : prev
);
}
return;
}
// 访客读取了客服的消息,更新客服消息的已读状态
const messageIdSet = new Set(ids);
setMessages((prev) =>
prev.map((msg) =>
// 只更新客服自己的消息(sender_is_agent === true)的已读状态
messageIdSet.has(msg.id) && msg.sender_is_agent
? {
...msg,
is_read: true,
read_at: readAt ?? msg.read_at ?? null,
}
: msg
)
);
// 更新访客的最后活跃时间
setConversationDetail((prev) =>
prev
? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
: prev
);
},
[conversationId]
);
const handleWebSocketMessage = useCallback(
(event: WSMessage<ChatWebSocketPayload>) => {
if (!event) {
return;
}
if (event.type === "new_message" && event.data) {
const data = event.data as MessageItem;
if (typeof data.conversation_id === "number") {
handleNewMessage(data);
}
} else if (event.type === "messages_read") {
// 确保处理已读事件,并传入对话ID
const payload = event.data as MessagesReadPayload;
if (!payload.conversation_id && event.conversation_id) {
payload.conversation_id = event.conversation_id;
}
handleMessagesReadEvent(payload);
}
},
[handleMessagesReadEvent, handleNewMessage]
);
useWebSocket<ChatWebSocketPayload>({
conversationId,
enabled: Boolean(conversationId),
onMessage: handleWebSocketMessage,
onError: (error) => console.error("WebSocket 连接错误:", error),
onClose: () => console.log("WebSocket 连接已关闭"),
});
const handleBack = useCallback(() => {
router.push("/agent/dashboard");
}, [router]);
const unreadCount =
conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
if (authLoading || !agent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
...
</div>
);
}
if (!conversationId) {
return null;
}
return (
<div className="flex flex-col h-screen bg-gray-50">
<div className="bg-white border-b border-gray-200">
<div className="flex items-center gap-4 px-4 h-16">
<button
onClick={handleBack}
className="px-3 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
>
</button>
<div className="flex-1">
<ChatHeader
conversationId={conversationId}
lastSeenAt={conversationDetail?.last_seen_at}
unreadCount={unreadCount}
onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
onRefresh={() => {
loadMessages();
loadConversationDetail();
}}
/>
</div>
</div>
</div>
<div className="flex-1 flex flex-col bg-white">
<MessageList
messages={messages}
loading={loadingMessages}
highlightKeyword={highlightKeyword}
onHighlightClear={() => setHighlightKeyword("")}
currentUserIsAgent={true}
conversationId={conversationId}
onMarkMessagesRead={handleMarkMessagesRead}
/>
<MessageInput
value={messageInput}
onChange={setMessageInput}
onSubmit={handleSendMessage}
sending={sending}
/>
</div>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { API_BASE_URL } from "@/lib/config";
// 对话类型定义
interface Conversation {
id: number;
visitor_id: number;
agent_id: number;
status: string;
created_at: string;
updated_at: string;
}
export default function ConversationsPage() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [loading, setLoading] = useState(true);
const [username, setUsername] = useState<string>("");
const [role, setRole] = useState<string>("");
const router = useRouter();
// 检查是否已登录
useEffect(() => {
const userId = localStorage.getItem("agent_user_id");
const savedUsername = localStorage.getItem("agent_username");
const savedRole = localStorage.getItem("agent_role");
if (!userId || !savedUsername) {
// 未登录,跳转到登录页面
router.push("/");
return;
}
setUsername(savedUsername);
setRole(savedRole || "");
}, [router]);
// 拉取对话列表
const fetchConversations = async () => {
try {
const res = await fetch(`${API_BASE_URL}/conversations`);
if (res.ok) {
const data = await res.json();
if (Array.isArray(data)) {
setConversations(data);
}
} else {
console.error("获取对话列表失败");
}
} catch (error) {
console.error("获取对话列表失败:", error);
} finally {
setLoading(false);
}
};
// 页面加载时拉取对话列表
useEffect(() => {
const userId = localStorage.getItem("agent_user_id");
if (userId) {
fetchConversations();
}
}, []);
// 退出登录
const handleLogout = async () => {
try {
await fetch(`${API_BASE_URL}/logout`, {
method: "POST",
});
} catch (error) {
console.error("退出登录失败:", error);
} finally {
// 清空本地存储
localStorage.removeItem("agent_user_id");
localStorage.removeItem("agent_username");
localStorage.removeItem("agent_role");
// 跳转到登录页面
router.push("/");
}
};
// 格式化时间显示
const formatTime = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
// 今天:只显示时间
if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
// 更早:显示日期+时间
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
};
// 点击对话,跳转到聊天页面
const handleConversationClick = (conversationId: number) => {
router.push(`/agent/chat/${conversationId}`);
};
if (loading) {
return (
<div className="flex justify-center items-center min-h-screen">
<div className="text-lg">...</div>
</div>
);
}
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* 顶部标题栏 */}
<div className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-white p-4 shadow-md">
<div className="flex justify-between items-center">
<div>
<h1 className="text-xl font-bold"></h1>
<div className="text-sm opacity-90 mt-1">
{username} ({role === "admin" ? "管理员" : "客服"})
</div>
</div>
<button
onClick={handleLogout}
className="px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-lg transition-colors text-sm"
>
退
</button>
</div>
</div>
{/* 对话列表区域 */}
<div className="flex-1 overflow-y-auto p-4">
{conversations.length === 0 ? (
<div className="text-center text-gray-400 mt-8">
</div>
) : (
<div className="space-y-2">
{conversations.map((conv) => (
<div
key={conv.id}
onClick={() => handleConversationClick(conv.id)}
className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 hover:shadow-md cursor-pointer transition-shadow"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-800">
#{conv.id}
</span>
<span
className={`px-2 py-1 rounded text-xs ${
conv.status === "open"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-700"
}`}
>
{conv.status === "open" ? "进行中" : conv.status}
</span>
</div>
<div className="text-sm text-gray-600">
访ID: {conv.visitor_id}
</div>
<div className="text-xs text-gray-400 mt-1">
: {formatTime(conv.created_at)}
</div>
</div>
<div className="text-xs text-gray-400">
: {formatTime(conv.updated_at)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { DashboardShell } from "@/components/dashboard/DashboardShell";
export default function AgentDashboardPage() {
// 页面采用纯客户端渲染,所有业务逻辑由 DashboardShell 承担
return <DashboardShell />;
}
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { MessageList } from "@/components/dashboard/MessageList";
import { MessageInput } from "@/components/dashboard/MessageInput";
import {
ChatWebSocketPayload,
MessageItem,
MessagesReadPayload,
} from "@/features/agent/types";
import {
fetchMessages,
markMessagesRead,
sendMessage,
} from "@/features/agent/services/messageApi";
import { initVisitorConversation } from "@/features/visitor/services/conversationApi";
import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
import type { WSMessage } from "@/lib/websocket";
function parseUserAgent(userAgent: string) {
const ua = userAgent.toLowerCase();
let browser = "Unknown";
let os = "Unknown";
if (ua.includes("edg/")) {
browser = "Edge";
} else if (ua.includes("chrome/")) {
browser = "Chrome";
} else if (ua.includes("firefox/")) {
browser = "Firefox";
} else if (ua.includes("safari/")) {
browser = "Safari";
}
if (ua.includes("windows nt")) {
os = "Windows";
} else if (ua.includes("mac os x") || ua.includes("macintosh")) {
os = "macOS";
} else if (ua.includes("android")) {
os = "Android";
} else if (ua.includes("iphone") || ua.includes("ipad")) {
os = "iOS";
} else if (ua.includes("linux")) {
os = "Linux";
}
return { browser, os };
}
export default function ChatPage() {
// ===== 访客与会话状态 =====
const [visitorId, setVisitorId] = useState<number | null>(null);
const [conversationId, setConversationId] = useState<number | null>(null);
const [conversationStatus, setConversationStatus] = useState<string>("open");
const [messages, setMessages] = useState<MessageItem[]>([]);
const [loadingMessages, setLoadingMessages] = useState(true);
const [sending, setSending] = useState(false);
const [input, setInput] = useState("");
const noopHighlight = useCallback(() => {}, []);
// 初始化访客 ID(使用 localStorage 保持连续性)
useEffect(() => {
let stored = window.localStorage.getItem("visitor_id");
if (!stored) {
stored = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
window.localStorage.setItem("visitor_id", stored);
}
const parsed = Number.parseInt(stored, 10);
setVisitorId(Number.isNaN(parsed) ? null : parsed);
}, []);
// 创建或恢复访客会话
const initializeConversation = useCallback(async (id: number) => {
const { browser, os } = parseUserAgent(navigator.userAgent);
const language =
navigator.language || (navigator.languages && navigator.languages[0]) || "";
const result = await initVisitorConversation({
visitorId: id,
website: window.location.href,
referrer: document.referrer || "",
browser,
os,
language,
});
if (result.conversation_id) {
setConversationId(result.conversation_id);
setConversationStatus(result.status);
}
}, []);
useEffect(() => {
if (visitorId === null) {
return;
}
initializeConversation(visitorId).catch((error) =>
console.error("初始化对话失败:", error)
);
}, [initializeConversation, visitorId]);
// 标记客服消息已读(readerIsAgent = false 表示访客读取了客服的消息)
const handleMarkAgentMessagesRead = useCallback(
async (conversationIdParam?: number, readerIsAgentParam?: boolean) => {
const targetConversationId = conversationIdParam ?? conversationId;
const targetReaderIsAgent = readerIsAgentParam ?? false;
if (!targetConversationId) {
return;
}
const result = await markMessagesRead(targetConversationId, targetReaderIsAgent);
if (!result || result.message_ids.length === 0) {
return;
}
const idSet = new Set(result.message_ids);
setMessages((prev) =>
prev.map((msg) =>
idSet.has(msg.id)
? {
...msg,
is_read: true,
read_at: result.read_at ?? msg.read_at ?? null,
}
: msg
)
);
},
[conversationId]
);
// 拉取历史消息
const loadMessages = useCallback(async () => {
if (!conversationId) {
return;
}
setLoadingMessages(true);
try {
const data = await fetchMessages(conversationId);
setMessages(data);
// 注意:不再自动标记客服消息为已读,而是通过滚动检测来处理
} catch (error) {
console.error("拉取消息失败:", error);
} finally {
setLoadingMessages(false);
}
}, [conversationId]);
useEffect(() => {
loadMessages();
}, [loadMessages]);
// 收到新消息时更新状态(包括访客消息和客服消息)
const handleNewMessage = useCallback(
(message: MessageItem) => {
if (!conversationId || message.conversation_id !== conversationId) {
return;
}
setMessages((prev) => {
const exists = prev.some((item) => item.id === message.id);
if (exists) {
return prev;
}
return [...prev, message];
});
// 注意:不再自动标记客服消息为已读,而是通过滚动检测来处理
},
[conversationId]
);
// 处理 WebSocket 的已读事件
// 只有当客服读取了访客的消息时(reader_is_agent === true),才更新访客消息的已读状态
const handleMessagesReadEvent = useCallback(
(payload: MessagesReadPayload) => {
if (!conversationId) {
return;
}
// 检查对话ID是否匹配
const payloadConversationId = payload?.conversation_id;
if (payloadConversationId && payloadConversationId !== conversationId) {
return;
}
// 只有当 reader_is_agent === true 时,才表示客服读取了访客的消息
// 此时应该更新访客消息(sender_is_agent === false)的已读状态
if (payload?.reader_is_agent !== true) {
return;
}
const ids = Array.isArray(payload?.message_ids)
? payload.message_ids
: [];
if (ids.length === 0) {
return;
}
const idSet = new Set(ids);
const readAt = payload?.read_at;
setMessages((prev) =>
prev.map((msg) =>
// 只更新访客自己的消息(sender_is_agent === false)的已读状态
idSet.has(msg.id) && !msg.sender_is_agent
? {
...msg,
is_read: true,
read_at: readAt ?? msg.read_at ?? null,
}
: msg
)
);
},
[conversationId]
);
const handleWebSocketMessage = useCallback(
(event: WSMessage<ChatWebSocketPayload>) => {
if (!event) {
return;
}
if (event.type === "new_message" && event.data) {
handleNewMessage(event.data as MessageItem);
} else if (event.type === "messages_read") {
// 确保处理已读事件,并传入对话ID
const payload = event.data as MessagesReadPayload;
if (!payload.conversation_id && event.conversation_id) {
payload.conversation_id = event.conversation_id;
}
handleMessagesReadEvent(payload);
}
},
[handleMessagesReadEvent, handleNewMessage]
);
useWebSocket<ChatWebSocketPayload>({
conversationId,
enabled: Boolean(conversationId),
isVisitor: true, // 访客端明确设置为 true
onMessage: handleWebSocketMessage,
onError: (error) => {
console.error("WebSocket 连接错误(访客端):", error);
},
});
const handleSendMessage = useCallback(async () => {
if (!conversationId || !input.trim() || sending) {
return;
}
const messageContent = input.trim();
setSending(true);
try {
await sendMessage({
conversationId,
content: messageContent,
senderIsAgent: false,
});
setInput("");
} catch (error) {
console.error("❌ 发送消息失败:", error);
alert((error as Error).message || "发送消息失败,请稍后重试");
} finally {
setSending(false);
}
}, [conversationId, input, sending]);
const headerStatus = useMemo(() => {
if (!conversationId) {
return "正在建立会话...";
}
return `会话 #${conversationId} · ${
conversationStatus === "open" ? "进行中" : "已关闭"
}`;
}, [conversationId, conversationStatus]);
if (visitorId === null) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 text-gray-500">
...
</div>
);
}
return (
<div className="flex flex-col h-screen bg-gray-50">
<div className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-white p-4 shadow-md">
<h1 className="text-xl font-bold">访</h1>
<div className="text-sm opacity-90 mt-1">{headerStatus}</div>
</div>
<MessageList
messages={messages}
loading={loadingMessages}
highlightKeyword=""
onHighlightClear={noopHighlight}
currentUserIsAgent={false}
disableAutoScroll
conversationId={conversationId}
onMarkMessagesRead={handleMarkAgentMessagesRead}
/>
<MessageInput
value={input}
onChange={setInput}
onSubmit={handleSendMessage}
sending={sending}
/>
</div>
);
}
+96 -96
View File
@@ -1,103 +1,103 @@
import Image from "next/image";
"use client";
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { API_BASE_URL } from "@/lib/config";
export default function AgentLoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
// 客服登录
async function handleLogin(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); // 阻止默认行为
if (!username || !password) {
setError("用户名和密码不能为空");
return;
}
setLoading(true);
setError("");
try {
const res = await fetch(`${API_BASE_URL}/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await res.json();
if (res.ok) {
// 登录成功,保存用户信息到 localStorage
localStorage.setItem("agent_user_id", String(data.user_id));
localStorage.setItem("agent_username", data.username);
localStorage.setItem("agent_role", data.role);
// 跳转到客服工作台(三栏布局)
router.push("/agent/dashboard");
} else {
// 登录失败,显示错误信息
setError(data.error || data.message || "登录失败");
}
} catch (error) {
console.error("登录失败:", error);
setError("登录失败,请检查网络连接");
} finally {
setLoading(false);
}
}
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex justify-center items-center min-h-screen bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500">
<div className="bg-white p-6 rounded-lg shadow-lg w-full sm:w-96">
<h1 className="text-center text-2xl font-bold mb-2 text-gray-800">
</h1>
<p className="text-center text-sm text-gray-500 mb-6">
</p>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-3 mb-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400"
disabled={loading}
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 mb-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400"
disabled={loading}
/>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-500 text-white py-3 rounded-md hover:bg-blue-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
{loading ? "登录中..." : "登录"}
</button>
</form>
<div className="mt-4 text-center text-xs text-gray-400">
<p>admin / admin123</p>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
</div>
);
}
}
@@ -0,0 +1,96 @@
"use client";
import { formatConversationTime } from "@/utils/format";
interface ChatHeaderProps {
conversationId: number;
lastSeenAt?: string | null;
unreadCount: number;
onMarkAllRead: () => void;
onRefresh: () => void;
}
export function ChatHeader({
conversationId,
lastSeenAt,
unreadCount,
onMarkAllRead,
onRefresh,
}: ChatHeaderProps) {
return (
<div className="h-16 border-b border-gray-200 flex items-center justify-between px-4 flex-shrink-0">
<div>
<div className="font-semibold text-gray-800"> #{conversationId}</div>
<div className="text-xs text-gray-500 mt-0.5">
{lastSeenAt
? `last seen ${formatConversationTime(lastSeenAt)}`
: "last seen 未知"}
</div>
</div>
<div className="flex items-center gap-2">
<button
className={`w-8 h-8 flex items-center justify-center rounded transition-colors ${
unreadCount > 0
? "hover:bg-gray-100 text-gray-600"
: "text-gray-300 cursor-not-allowed"
}`}
title="标记全部已读"
onClick={onMarkAllRead}
disabled={unreadCount === 0}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</button>
<button
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
title="刷新"
onClick={onRefresh}
>
<svg
className="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
title="更多选项"
>
<svg
className="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
export function ConversationHeader() {
return (
<div className="h-16 border-b border-gray-200 flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-800">All chats</span>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">All</span>
<svg
className="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
);
}
@@ -0,0 +1,42 @@
"use client";
import { ConversationSummary } from "@/features/agent/types";
import { ConversationListItem } from "./ConversationListItem";
interface ConversationListProps {
conversations: ConversationSummary[];
selectedConversationId: number | null;
onSelect: (id: number) => void;
searchQuery: string;
}
export function ConversationList({
conversations,
selectedConversationId,
onSelect,
searchQuery,
}: ConversationListProps) {
if (conversations.length === 0) {
return (
<div className="flex-1 overflow-y-auto">
<div className="text-center text-gray-400 mt-8 text-sm">
{searchQuery ? "未找到匹配的对话" : "暂无对话"}
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
{conversations.map((conversation) => (
<ConversationListItem
key={conversation.id}
conversation={conversation}
selected={selectedConversationId === conversation.id}
onSelect={onSelect}
/>
))}
</div>
);
}
@@ -0,0 +1,102 @@
"use client";
import { ConversationSummary } from "@/features/agent/types";
import {
buildMessagePreview,
formatConversationTime,
isVisitorOnline,
} from "@/utils/format";
interface ConversationListItemProps {
conversation: ConversationSummary;
selected: boolean;
onSelect: (id: number) => void;
}
export function ConversationListItem({
conversation,
selected,
onSelect,
}: ConversationListItemProps) {
const avatarColor = `hsl(${(conversation.id * 137.5) % 360}, 70%, 50%)`;
const unreadCount = conversation.unread_count ?? 0;
const lastMessage = conversation.last_message;
const lastMessagePreview = lastMessage
? buildMessagePreview(lastMessage.content)
: "暂无消息";
// 根据 last_seen_at 判断是否在线(最近 10 秒内认为在线)
const isOnline = isVisitorOnline(conversation.last_seen_at);
return (
<div
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onSelect(conversation.id);
}}
onMouseDown={(event) => {
if (event.button === 0) {
event.preventDefault();
}
}}
className={`p-4 border-b border-gray-100 cursor-pointer transition-colors select-none ${
selected ? "bg-blue-50 border-l-4 border-l-blue-500" : "hover:bg-gray-50"
}`}
>
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0"
style={{ backgroundColor: avatarColor }}
>
{conversation.visitor_id.toString().slice(-2)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-800 text-sm truncate">
#{conversation.id}
</span>
{/* 在线/离线状态图标 */}
{isOnline && (
<span
className="w-2 h-2 rounded-full flex-shrink-0"
title="在线"
style={{ backgroundColor: "#10b981" }}
/>
)}
{unreadCount > 0 && (
<span className="px-1.5 py-0.5 rounded-full text-[10px] bg-blue-500 text-white flex-shrink-0">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
<span
className={`px-2 py-0.5 rounded text-xs flex-shrink-0 ${
conversation.status === "open"
? "bg-green-100 text-green-700"
: "bg-gray-100 text-gray-700"
}`}
>
{conversation.status === "open" ? "进行中" : "已关闭"}
</span>
</div>
<div className="text-xs text-gray-600 mb-1 flex items-center gap-1">
{lastMessage?.sender_is_agent && (
<span
className={`text-[10px] ${
lastMessage.is_read ? "text-blue-400" : "text-gray-400"
}`}
>
{lastMessage.is_read ? "✓✓" : "✓"}
</span>
)}
<span className="truncate">{lastMessagePreview}</span>
</div>
<div className="flex items-center justify-between text-xs text-gray-400">
<span>访 #{conversation.visitor_id}</span>
<span>{formatConversationTime(conversation.updated_at)}</span>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,39 @@
"use client";
interface ConversationSearchProps {
value: string;
onChange: (value: string) => void;
}
export function ConversationSearch({
value,
onChange,
}: ConversationSearchProps) {
return (
<div className="p-4 border-b border-gray-200">
<div className="relative">
<input
type="text"
placeholder="Q Search"
value={value}
onChange={(event) => onChange(event.target.value)}
className="w-full px-3 py-2 pl-9 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
/>
<svg
className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
"use client";
import { ConversationSummary } from "@/features/agent/types";
import { ConversationHeader } from "./ConversationHeader";
import { ConversationSearch } from "./ConversationSearch";
import { ConversationList } from "./ConversationList";
interface ConversationSidebarProps {
conversations: ConversationSummary[];
selectedConversationId: number | null;
searchQuery: string;
onSearchChange: (value: string) => void;
onSelectConversation: (id: number) => void;
}
export function ConversationSidebar({
conversations,
selectedConversationId,
searchQuery,
onSearchChange,
onSelectConversation,
}: ConversationSidebarProps) {
return (
<div className="w-80 bg-white border-r border-gray-200 flex flex-col min-h-0">
<ConversationHeader />
<ConversationSearch value={searchQuery} onChange={onSearchChange} />
<ConversationList
conversations={conversations}
selectedConversationId={selectedConversationId}
onSelect={onSelectConversation}
searchQuery={searchQuery}
/>
</div>
);
}
@@ -0,0 +1,75 @@
"use client";
import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
interface DashboardHeaderProps {
username: string;
role: string;
avatarUrl?: string | null;
onLogout: () => void;
onProfileClick: () => void;
}
export function DashboardHeader({
username,
role,
avatarUrl,
onLogout,
onProfileClick,
}: DashboardHeaderProps) {
// 根据用户名生成头像颜色(如果没有上传头像)
const avatarColor = getAvatarColor(username);
const displayInitial = getAvatarInitial(username);
const fullAvatarUrl = getAvatarUrl(avatarUrl);
return (
<div className="h-16 flex items-center justify-between px-6 border-b border-gray-200 bg-white flex-shrink-0">
<div className="flex items-center gap-3">
{/* 头像 */}
<button
onClick={onProfileClick}
className="flex-shrink-0 hover:opacity-80 transition-opacity"
title="点击查看个人资料"
>
{fullAvatarUrl ? (
<img
src={fullAvatarUrl}
alt={username}
className="w-10 h-10 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-semibold border-2 border-gray-200"
style={{ backgroundColor: avatarColor }}
>
{displayInitial}
</div>
)}
</button>
<div>
<div className="text-sm text-gray-500"></div>
<div className="text-base font-semibold text-gray-800">
{username || "客服"}
{role ? `${role}` : ""}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={onProfileClick}
className="px-3 py-1.5 text-sm rounded-lg bg-blue-100 text-blue-700 hover:bg-blue-200 transition-colors"
title="个人资料"
>
</button>
<button
onClick={onLogout}
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 transition-colors"
>
退
</button>
</div>
</div>
);
}
@@ -0,0 +1,229 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useAuth } from "@/features/agent/hooks/useAuth";
import { useConversations } from "@/features/agent/hooks/useConversations";
import { useMessages } from "@/features/agent/hooks/useMessages";
import { useProfile } from "@/features/agent/hooks/useProfile";
import { Profile } from "@/features/agent/types";
import { ChatHeader } from "./ChatHeader";
import { ConversationSidebar } from "./ConversationSidebar";
import { DashboardHeader } from "./DashboardHeader";
import { MessageInput } from "./MessageInput";
import { MessageList } from "./MessageList";
import { NavigationSidebar } from "./NavigationSidebar";
import { ProfileModal } from "./ProfileModal";
import { VisitorDetailPanel } from "./VisitorDetailPanel";
export function DashboardShell() {
// 登录状态:负责从本地存储读取客服信息,并提供登出方法
const { agent, loading: authLoading, logout } = useAuth();
// 个人资料状态
const [profileModalOpen, setProfileModalOpen] = useState(false);
const {
profile,
loading: profileLoading,
refresh: refreshProfile,
update: updateProfile,
upload: uploadAvatar,
} = useProfile({
userId: agent?.id ?? null,
enabled: Boolean(agent?.id),
});
// 会话状态:包含会话列表、搜索关键字、选中的会话等
const {
conversations,
filteredConversations,
selectedConversationId,
searchQuery,
loading,
isInitialLoad,
setSearchQuery,
selectConversation,
updateConversation,
} = useConversations();
// 输入框内容与搜索高亮关键字
const [messageInput, setMessageInput] = useState("");
const [highlightKeyword, setHighlightKeyword] = useState("");
// 当前选中的会话信息,供右侧访客详情展示
const selectedConversation = useMemo(
() =>
conversations.find(
(conversation) => conversation.id === selectedConversationId
) ?? null,
[conversations, selectedConversationId]
);
// 消息层:负责消息列表、未读状态、访客详情以及 WebSocket
const {
messages,
loadingMessages,
sending,
conversationDetail,
refreshConversationDetail,
refreshMessages,
sendMessage,
markMessagesAsRead,
updateContactInfo,
} = useMessages({
conversationId: selectedConversationId,
agentId: agent?.id ?? null,
updateConversation,
});
// 左侧选择会话时,记录关键字用于消息高亮
const handleConversationSelect = useCallback(
(conversationId: number) => {
if (searchQuery.trim()) {
setHighlightKeyword(searchQuery.trim());
} else {
setHighlightKeyword("");
}
selectConversation(conversationId);
},
[searchQuery, selectConversation]
);
// 发送消息:调用 service 后清空输入框
const handleSendMessage = useCallback(async () => {
const content = messageInput.trim();
if (!content) {
return;
}
try {
await sendMessage(content);
setMessageInput("");
} catch (error) {
alert((error as Error).message);
}
}, [messageInput, sendMessage]);
// 标记当前会话全部消息为已读
const handleMarkAllRead = useCallback(() => {
if (selectedConversationId) {
markMessagesAsRead(selectedConversationId, true);
}
}, [markMessagesAsRead, selectedConversationId]);
// 手动刷新消息与访客详情
const handleRefreshChat = useCallback(() => {
if (!selectedConversationId) return;
refreshMessages(selectedConversationId);
refreshConversationDetail(selectedConversationId);
}, [refreshConversationDetail, refreshMessages, selectedConversationId]);
// 单独刷新访客详情
const handleRefreshVisitor = useCallback(() => {
if (!selectedConversationId) return;
refreshConversationDetail(selectedConversationId);
}, [refreshConversationDetail, selectedConversationId]);
// 当前会话未读数(优先使用详情返回的数据)
const selectedUnreadCount =
conversationDetail?.unread_count ??
selectedConversation?.unread_count ??
0;
// 3 秒后清除搜索高亮
const clearHighlight = useCallback(() => {
setHighlightKeyword("");
}, []);
// 处理个人资料更新
const handleProfileUpdate = useCallback(
(updated: Profile) => {
// 个人资料更新后,刷新缓存(这里可以通过更新 agent 状态来触发UI更新)
refreshProfile();
},
[refreshProfile]
);
if (authLoading || (loading && isInitialLoad)) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-lg text-gray-600">...</div>
</div>
);
}
if (!agent) {
return null;
}
return (
<div className="flex h-screen bg-gray-50 overflow-hidden">
<NavigationSidebar />
<div className="flex-1 flex flex-col min-h-0">
<DashboardHeader
username={agent.username}
role={agent.role}
avatarUrl={profile?.avatar_url}
onLogout={logout}
onProfileClick={() => setProfileModalOpen(true)}
/>
<div className="flex flex-1 min-h-0 overflow-hidden">
<ConversationSidebar
conversations={filteredConversations}
selectedConversationId={selectedConversationId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onSelectConversation={handleConversationSelect}
/>
<div className="flex-1 flex flex-col bg-white min-h-0">
{selectedConversationId ? (
<>
<ChatHeader
conversationId={selectedConversationId}
lastSeenAt={conversationDetail?.last_seen_at}
unreadCount={selectedUnreadCount}
onMarkAllRead={handleMarkAllRead}
onRefresh={handleRefreshChat}
/>
<MessageList
messages={messages}
loading={loadingMessages}
highlightKeyword={highlightKeyword}
onHighlightClear={clearHighlight}
currentUserIsAgent={true}
conversationId={selectedConversationId ?? null}
onMarkMessagesRead={markMessagesAsRead}
/>
<MessageInput
value={messageInput}
onChange={setMessageInput}
onSubmit={handleSendMessage}
sending={sending}
/>
</>
) : (
<div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
</div>
)}
</div>
<VisitorDetailPanel
conversation={selectedConversation}
detail={conversationDetail}
onRefresh={handleRefreshVisitor}
onUpdateContact={updateContactInfo}
/>
</div>
</div>
{/* 个人资料弹窗 */}
<ProfileModal
profile={profile}
open={profileModalOpen}
onClose={() => setProfileModalOpen(false)}
onUpdate={handleProfileUpdate}
/>
</div>
);
}
@@ -0,0 +1,70 @@
"use client";
import { FormEvent, useEffect, useRef } from "react";
interface MessageInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: () => Promise<void> | void;
sending: boolean;
}
export function MessageInput({
value,
onChange,
onSubmit,
sending,
}: MessageInputProps) {
// 输入框引用,用于发送消息后自动聚焦
const inputRef = useRef<HTMLInputElement>(null);
// 记录上一次的 sending 状态,用于判断是否刚刚完成发送
const prevSendingRef = useRef<boolean>(false);
// 当发送状态从 true 变为 false 时(发送完成),自动聚焦到输入框
useEffect(() => {
// 如果上一次是发送中(true),现在是发送完成(false),说明刚刚发送完成
if (prevSendingRef.current && !sending && inputRef.current) {
// 使用 setTimeout 确保 DOM 更新完成后再聚焦
// 这样可以避免在某些情况下聚焦失败
setTimeout(() => {
inputRef.current?.focus();
}, 0);
}
// 更新上一次的 sending 状态
prevSendingRef.current = sending;
}, [sending]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
if (sending) {
return;
}
await onSubmit();
// 注意:聚焦逻辑由 useEffect 处理,当 sending 从 true 变为 false 时会自动聚焦
};
return (
<form
onSubmit={handleSubmit}
className="border-t border-gray-200 px-4 py-3 flex items-center gap-2 bg-white flex-shrink-0"
>
<input
ref={inputRef}
type="text"
placeholder="输入消息..."
value={value}
onChange={(event) => onChange(event.target.value)}
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-400 text-sm"
disabled={sending}
/>
<button
type="submit"
disabled={sending || !value.trim()}
className="px-4 py-2 bg-blue-500 text-white text-sm rounded-lg hover:bg-blue-600 transition-colors disabled:bg-blue-200 disabled:cursor-not-allowed"
>
{sending ? "发送中..." : "发送"}
</button>
</form>
);
}
@@ -0,0 +1,354 @@
"use client";
import { useEffect, useRef } from "react";
import { MessageItem } from "@/features/agent/types";
import { formatMessageTime } from "@/utils/format";
import { highlightText } from "@/utils/highlight";
interface MessageListProps {
messages: MessageItem[];
loading: boolean;
highlightKeyword: string;
onHighlightClear: () => void;
currentUserIsAgent?: boolean;
disableAutoScroll?: boolean;
conversationId?: number | null;
onMarkMessagesRead?: (conversationId: number, readerIsAgent: boolean) => void;
}
export function MessageList({
messages,
loading,
highlightKeyword,
onHighlightClear,
currentUserIsAgent = true,
disableAutoScroll = false,
conversationId = null,
onMarkMessagesRead,
}: MessageListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const messageRefs = useRef<Record<number, HTMLDivElement | null>>({});
const shouldStickToBottomRef = useRef(true);
const lastConversationIdRef = useRef<number | null>(null);
const markReadTimerRef = useRef<NodeJS.Timeout | null>(null);
const lastMarkedReadRef = useRef<number>(0);
const lastMessageIdRef = useRef<number | null>(null);
const lastMessageCountRef = useRef<number>(0);
useEffect(() => {
if (conversationId !== lastConversationIdRef.current) {
lastConversationIdRef.current = conversationId;
shouldStickToBottomRef.current = true;
lastMessageIdRef.current = null;
lastMessageCountRef.current = 0;
}
}, [conversationId]);
// 监听滚动事件,当滚动到底部附近时标记消息为已读
// 注意:即使 disableAutoScroll 为 true,也应该允许通过滚动来标记消息为已读
useEffect(() => {
const container = containerRef.current;
if (!container || !conversationId || !onMarkMessagesRead) {
return;
}
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceToBottom < 100;
shouldStickToBottomRef.current = isNearBottom;
// 当滚动到底部附近时,检查是否有未读消息需要标记为已读
if (isNearBottom) {
// 防抖:延迟 500ms 后标记为已读,避免频繁调用
if (markReadTimerRef.current) {
clearTimeout(markReadTimerRef.current);
}
markReadTimerRef.current = setTimeout(() => {
// 检查是否有未读的消息(对方发送的消息)
const unreadMessages = messages.filter((msg) => {
// 对于客服端:检查访客发送的未读消息
// 对于访客端:检查客服发送的未读消息
const isFromOther = currentUserIsAgent
? !msg.sender_is_agent
: msg.sender_is_agent;
return isFromOther && !msg.is_read;
});
if (unreadMessages.length > 0) {
// 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
const now = Date.now();
if (now - lastMarkedReadRef.current < 2000) {
return;
}
// 标记为已读
onMarkMessagesRead(conversationId, currentUserIsAgent);
lastMarkedReadRef.current = now;
}
}, 500);
}
};
handleScroll();
container.addEventListener("scroll", handleScroll);
return () => {
container.removeEventListener("scroll", handleScroll);
if (markReadTimerRef.current) {
clearTimeout(markReadTimerRef.current);
}
};
}, [conversationId, onMarkMessagesRead, messages, currentUserIsAgent]);
useEffect(() => {
if (messages.length === 0) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const keyword = highlightKeyword.trim();
const lastMessage = messages[messages.length - 1];
const isLastMessageFromCurrentUser = lastMessage
? currentUserIsAgent
? lastMessage.sender_is_agent
: !lastMessage.sender_is_agent
: false;
// 检查是否有新消息(通过比较消息ID或消息数量)
const hasNewMessage =
lastMessage.id !== lastMessageIdRef.current ||
messages.length !== lastMessageCountRef.current;
// 更新记录
lastMessageIdRef.current = lastMessage.id;
lastMessageCountRef.current = messages.length;
// 使用 requestAnimationFrame 确保 DOM 已更新后再检查位置
requestAnimationFrame(() => {
// 重新获取容器引用,确保使用最新的 DOM 元素
const currentContainer = containerRef.current;
if (!currentContainer) {
return;
}
// 在 DOM 更新后检查当前位置
const { scrollTop, scrollHeight, clientHeight } = currentContainer;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceToBottom < 100;
// 更新 shouldStickToBottomRef,确保使用最新的位置信息
shouldStickToBottomRef.current = isNearBottom;
// 滚动逻辑:
// 1. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true
// 2. 如果最后一条消息是对方发送的:
// - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
// - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
// - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
// 3. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
// 这样确保访客端和客服端的行为一致:当用户在底部附近时,收到新消息会自动滚动到底部
const shouldAutoScroll =
hasNewMessage &&
(isLastMessageFromCurrentUser || isNearBottom);
if (keyword) {
const keywordLower = keyword.toLowerCase();
const matchingMessage = messages.find((message) =>
message.content.toLowerCase().includes(keywordLower)
);
if (matchingMessage) {
const scroll = () => {
const target = messageRefs.current[matchingMessage.id];
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
}
setTimeout(onHighlightClear, 3000);
};
setTimeout(scroll, 200);
} else {
if (!shouldAutoScroll) {
return;
}
const scrollBottom = () => {
const container = containerRef.current;
if (!container) {
return;
}
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
};
setTimeout(scrollBottom, 100);
onHighlightClear();
}
} else {
if (!shouldAutoScroll) {
return;
}
const scrollBottom = () => {
const container = containerRef.current;
if (!container) {
return;
}
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
};
setTimeout(scrollBottom, 100);
}
// 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
// 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
if (conversationId && onMarkMessagesRead && messages.length > 0) {
// 延迟标记为已读,确保滚动动画完成
if (markReadTimerRef.current) {
clearTimeout(markReadTimerRef.current);
}
markReadTimerRef.current = setTimeout(() => {
// 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
const shouldMarkRead = shouldAutoScroll || isNearBottom;
if (!shouldMarkRead) {
return;
}
const unreadMessages = messages.filter((msg) => {
const isFromOther = currentUserIsAgent
? !msg.sender_is_agent
: msg.sender_is_agent;
return isFromOther && !msg.is_read;
});
if (unreadMessages.length > 0) {
// 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
const now = Date.now();
if (now - lastMarkedReadRef.current < 2000) {
return;
}
onMarkMessagesRead(conversationId, currentUserIsAgent);
lastMarkedReadRef.current = now;
}
}, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
}
});
}, [
messages,
highlightKeyword,
onHighlightClear,
disableAutoScroll,
currentUserIsAgent,
conversationId,
onMarkMessagesRead,
]);
if (loading) {
return (
<div className="flex-1 flex items-center justify-center bg-gray-50">
<span className="text-sm text-gray-500">...</span>
</div>
);
}
if (messages.length === 0) {
return (
<div ref={containerRef} className="flex-1 overflow-y-auto p-4 bg-gray-50">
<div className="text-center text-gray-400 mt-8 text-sm"></div>
</div>
);
}
return (
<div
ref={containerRef}
className="flex-1 overflow-y-auto p-4 bg-gray-50"
>
<div className="space-y-4">
{messages.map((message) => {
const keyword = highlightKeyword.trim();
const isMatching =
keyword !== "" &&
message.content.toLowerCase().includes(keyword.toLowerCase());
const bubbleContent =
keyword !== "" && isMatching
? highlightText(message.content, keyword)
: message.content;
if (message.message_type === "system_message") {
return (
<div
key={message.id}
ref={(element) => {
messageRefs.current[message.id] = element;
}}
className={`text-center text-xs text-gray-500`}
>
<span className="inline-block px-3 py-1 rounded-full bg-gray-200 text-gray-700">
{message.content}
</span>
</div>
);
}
const isSenderAgent = message.sender_is_agent;
const isCurrentUser = currentUserIsAgent
? isSenderAgent
: !isSenderAgent;
const alignment = isCurrentUser ? "justify-end" : "justify-start";
const bubbleColor = isCurrentUser
? "bg-blue-500 text-white"
: "bg-white text-gray-800 border border-gray-200";
const cornerClass = isCurrentUser ? "rounded-br-none" : "rounded-bl-none";
const receiptClass = isCurrentUser
? message.is_read
? currentUserIsAgent
? "text-blue-400"
: "text-blue-200"
: currentUserIsAgent
? ""
: "text-blue-200"
: "";
return (
<div
key={message.id}
ref={(element) => {
messageRefs.current[message.id] = element;
}}
className={`flex ${alignment}`}
>
<div className="max-w-[70%]">
<div
className={`px-4 py-2 rounded-2xl shadow-sm ${
cornerClass
} ${bubbleColor}`}
>
<div className="whitespace-pre-wrap break-words text-sm">
{bubbleContent}
</div>
</div>
<div className="flex items-center gap-1 mt-1 text-[10px] text-gray-400">
{isCurrentUser && (
<span className={receiptClass}>
{message.is_read ? "✓✓" : "✓"}
</span>
)}
<span>{formatMessageTime(message.created_at)}</span>
</div>
</div>
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
export function NavigationSidebar() {
return (
<div className="w-16 bg-gray-50 flex flex-col items-center py-4 border-r border-gray-200">
<button
className="w-10 h-10 rounded-lg bg-green-600 flex items-center justify-center mb-4 hover:bg-green-700 transition-colors"
title="对话"
>
<svg
className="w-6 h-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</button>
<button
className="w-10 h-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center mb-4 hover:bg-gray-100 transition-colors"
title="知识库"
disabled
>
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5s3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18s-3.332.477-4.5 1.253"
/>
</svg>
</button>
<button
className="w-10 h-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center mb-4 hover:bg-gray-100 transition-colors"
title="事件管理"
disabled
>
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</button>
<button
className="w-10 h-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center mb-4 hover:bg-gray-100 transition-colors"
title="用户管理"
disabled
>
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
</button>
<button
className="w-10 h-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center mt-auto hover:bg-gray-100 transition-colors"
title="设置"
disabled
>
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
);
}
@@ -0,0 +1,344 @@
"use client";
import { useCallback, useState, useEffect, useRef } from "react";
import { Profile } from "@/features/agent/types";
import {
updateProfile as updateProfileApi,
uploadAvatar as uploadAvatarApi,
UpdateProfilePayload,
} from "@/features/agent/services/profileApi";
import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
interface ProfileModalProps {
profile: Profile | null;
open: boolean;
onClose: () => void;
onUpdate: (profile: Profile) => void;
}
export function ProfileModal({
profile,
open,
onClose,
onUpdate,
}: ProfileModalProps) {
const [editingNickname, setEditingNickname] = useState(false);
const [editingEmail, setEditingEmail] = useState(false);
const [nickname, setNickname] = useState("");
const [email, setEmail] = useState("");
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
// 当弹窗打开或 profile 变化时,初始化表单
useEffect(() => {
if (open && profile) {
setNickname(profile.nickname || "");
setEmail(profile.email || "");
setAvatarPreview(profile.avatar_url || null);
setEditingNickname(false);
setEditingEmail(false);
setErrorMessage("");
}
}, [open, profile]);
// 选择头像文件
const handleAvatarSelect = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !profile) {
return;
}
// 验证文件类型
const allowedTypes = ["image/jpeg", "image/jpg", "image/png", "image/gif"];
if (!allowedTypes.includes(file.type)) {
setErrorMessage("只支持上传图片文件(jpg、png、gif");
return;
}
// 验证文件大小(10MB
if (file.size > 10 * 1024 * 1024) {
setErrorMessage("头像文件大小不能超过10MB");
return;
}
// 预览头像
const reader = new FileReader();
reader.onload = (e) => {
setAvatarPreview(e.target?.result as string);
};
reader.readAsDataURL(file);
// 上传头像
setUploading(true);
setErrorMessage("");
try {
const updated = await uploadAvatarApi(profile.id, file);
onUpdate(updated);
setAvatarPreview(updated.avatar_url);
} catch (error) {
setErrorMessage((error as Error).message || "上传头像失败,请稍后重试");
// 恢复原头像
setAvatarPreview(profile.avatar_url || null);
} finally {
setUploading(false);
}
},
[profile, onUpdate]
);
// 保存昵称
const handleSaveNickname = useCallback(async () => {
if (!profile || !nickname.trim()) {
return;
}
setSaving(true);
setErrorMessage("");
try {
const payload: UpdateProfilePayload = {
nickname: nickname.trim() || undefined,
};
const updated = await updateProfileApi(profile.id, payload);
onUpdate(updated);
setEditingNickname(false);
} catch (error) {
setErrorMessage((error as Error).message || "保存失败,请稍后重试");
} finally {
setSaving(false);
}
}, [profile, nickname, onUpdate]);
// 保存邮箱
const handleSaveEmail = useCallback(async () => {
if (!profile) {
return;
}
setSaving(true);
setErrorMessage("");
try {
const payload: UpdateProfilePayload = {
email: email.trim() || undefined,
};
const updated = await updateProfileApi(profile.id, payload);
onUpdate(updated);
setEditingEmail(false);
} catch (error) {
setErrorMessage((error as Error).message || "保存失败,请稍后重试");
} finally {
setSaving(false);
}
}, [profile, email, onUpdate]);
if (!open || !profile) {
return null;
}
const displayName = profile.nickname || profile.username;
const avatarColor = getAvatarColor(profile.id);
const displayInitial = getAvatarInitial(profile.username, profile.nickname);
const fullAvatarUrl = getAvatarUrl(profile.avatar_url);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6 max-h-[90vh] overflow-y-auto">
{/* 标题 */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button
onClick={onClose}
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
disabled={saving || uploading}
>
<svg
className="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* 错误提示 */}
{errorMessage && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{errorMessage}
</div>
)}
{/* 头像区域 */}
<div className="flex flex-col items-center mb-6">
<div className="relative">
{avatarPreview || fullAvatarUrl ? (
<img
src={avatarPreview || fullAvatarUrl || ""}
alt={displayName}
className="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
/>
) : (
<div
className="w-24 h-24 rounded-full flex items-center justify-center text-white text-2xl font-semibold border-4 border-gray-200"
style={{ backgroundColor: avatarColor }}
>
{displayInitial}
</div>
)}
{uploading && (
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
<div className="w-6 h-6 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif"
className="hidden"
onChange={handleAvatarSelect}
disabled={uploading}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="mt-3 px-4 py-2 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
{uploading ? "上传中..." : "更换头像"}
</button>
</div>
{/* 用户名(只读) */}
<div className="mb-4">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-base text-gray-800">{profile.username}</div>
</div>
{/* 角色(只读) */}
<div className="mb-4">
<div className="text-sm text-gray-500 mb-1"></div>
<div className="text-base text-gray-800">{profile.role}</div>
</div>
{/* 昵称(可编辑) */}
<div className="mb-4">
<div className="text-sm text-gray-500 mb-1 flex items-center justify-between">
<span></span>
{!editingNickname ? (
<button
onClick={() => setEditingNickname(true)}
className="text-blue-500 text-xs hover:text-blue-600"
disabled={saving}
>
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => {
setEditingNickname(false);
setNickname(profile.nickname || "");
}}
className="text-gray-500 text-xs hover:text-gray-600"
disabled={saving}
>
</button>
<button
onClick={handleSaveNickname}
className="text-blue-500 text-xs hover:text-blue-600"
disabled={saving}
>
</button>
</div>
)}
</div>
{editingNickname ? (
<input
type="text"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder="请输入昵称"
disabled={saving}
/>
) : (
<div className="text-base text-gray-800">
{profile.nickname || "未设置"}
</div>
)}
</div>
{/* 邮箱(可编辑) */}
<div className="mb-6">
<div className="text-sm text-gray-500 mb-1 flex items-center justify-between">
<span></span>
{!editingEmail ? (
<button
onClick={() => setEditingEmail(true)}
className="text-blue-500 text-xs hover:text-blue-600"
disabled={saving}
>
</button>
) : (
<div className="flex gap-2">
<button
onClick={() => {
setEditingEmail(false);
setEmail(profile.email || "");
}}
className="text-gray-500 text-xs hover:text-gray-600"
disabled={saving}
>
</button>
<button
onClick={handleSaveEmail}
className="text-blue-500 text-xs hover:text-blue-600"
disabled={saving}
>
</button>
</div>
)}
</div>
{editingEmail ? (
<input
type="email"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="请输入邮箱"
disabled={saving}
/>
) : (
<div className="text-base text-gray-800">
{profile.email || "未设置"}
</div>
)}
</div>
{/* 关闭按钮 */}
<div className="flex justify-end">
<button
onClick={onClose}
disabled={saving || uploading}
className="px-4 py-2 text-sm rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:bg-gray-50 disabled:cursor-not-allowed transition-colors"
>
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,362 @@
"use client";
import { useMemo, useState } from "react";
import { ConversationDetail, ConversationSummary } from "@/features/agent/types";
import {
formatConversationTime,
isVisitorOnline,
} from "@/utils/format";
type ContactField = "email" | "phone" | "notes";
type ContactUpdatePayload = Partial<Record<ContactField, string>>;
interface VisitorDetailPanelProps {
conversation: ConversationSummary | null;
detail: ConversationDetail | null;
onRefresh: () => void;
onUpdateContact: (payload: ContactUpdatePayload) => Promise<unknown>;
}
const displayValue = (value?: string | null, placeholder = "暂未填写") => {
if (!value) {
return placeholder;
}
const trimmed = value.trim();
return trimmed || placeholder;
};
export function VisitorDetailPanel({
conversation,
detail,
onRefresh,
onUpdateContact,
}: VisitorDetailPanelProps) {
const [editingField, setEditingField] = useState<ContactField | null>(null);
const [editingValue, setEditingValue] = useState("");
const [saving, setSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const fieldLabels = useMemo<Record<ContactField, string>>(
() => ({
email: "邮箱",
phone: "电话",
notes: "备注",
}),
[]
);
if (!conversation) {
return (
<div className="w-80 bg-white border-l border-gray-200 flex flex-col min-h-0">
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-gray-400 text-sm">
</div>
</div>
</div>
);
}
const avatarColor = `hsl(${(conversation.visitor_id * 137.5) % 360}, 70%, 50%)`;
// 根据 last_seen_at 判断是否在线(优先使用 detail,因为它是最新的)
// 如果 detail 不存在,使用 conversation.last_seen_at
const isOnline = isVisitorOnline(
detail?.last_seen_at ?? conversation.last_seen_at ?? null
);
const getFieldValue = (field: ContactField) => {
if (!detail) {
return "";
}
switch (field) {
case "email":
return detail.email ?? "";
case "phone":
return detail.phone ?? "";
case "notes":
return detail.notes ?? "";
default:
return "";
}
};
const handleOpenEditor = (field: ContactField) => {
setEditingField(field);
setEditingValue(getFieldValue(field));
setErrorMessage("");
};
const handleCloseEditor = () => {
if (saving) {
return;
}
setEditingField(null);
setEditingValue("");
setErrorMessage("");
};
const handleSubmit = async () => {
if (!editingField) {
return;
}
setSaving(true);
try {
const payload: ContactUpdatePayload = {
[editingField]: editingValue,
};
await onUpdateContact(payload);
setEditingField(null);
setEditingValue("");
setErrorMessage("");
} catch (error) {
setErrorMessage((error as Error).message || "保存失败,请稍后重试");
} finally {
setSaving(false);
}
};
const actionLabel = (field: ContactField) => {
const current = getFieldValue(field).trim();
return current ? "编辑" : "+ Add";
};
return (
<div className="w-80 bg-white border-l border-gray-200 flex flex-col min-h-0">
<div className="h-16 border-b border-gray-200 flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0"
style={{ backgroundColor: avatarColor }}
>
{conversation.visitor_id.toString().slice(-2)}
</div>
<div>
<div className="font-semibold text-gray-800 text-sm">
访 #{conversation.visitor_id}
</div>
<div className="text-xs text-gray-500">
{isOnline ? (
<span className="text-green-600"> 线</span>
) : (
<span className="text-gray-400"> 线</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
title="刷新"
onClick={onRefresh}
>
<svg
className="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<button
className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
title="更多选项"
>
<svg
className="w-5 h-5 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
/>
</svg>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
{/* 联系信息区域 */}
<div className="p-4 border-b border-gray-200">
<h3 className="text-sm font-semibold text-gray-700 mb-3"></h3>
<div className="space-y-3 text-sm">
<div>
<div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
<span></span>
<button
className="text-blue-500 text-xs hover:text-blue-600"
onClick={() => handleOpenEditor("email")}
>
{actionLabel("email")}
</button>
</div>
<div className="text-xs text-gray-700 break-all">
{displayValue(detail?.email, "暂未填写")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
<span></span>
<button
className="text-blue-500 text-xs hover:text-blue-600"
onClick={() => handleOpenEditor("phone")}
>
{actionLabel("phone")}
</button>
</div>
<div className="text-xs text-gray-700 break-all">
{displayValue(detail?.phone, "暂未填写")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
<span></span>
<button
className="text-blue-500 text-xs hover:text-blue-600"
onClick={() => handleOpenEditor("notes")}
>
{actionLabel("notes")}
</button>
</div>
<div className="text-xs text-gray-700 whitespace-pre-wrap break-words min-h-[1rem]">
{displayValue(detail?.notes, "暂无备注")}
</div>
</div>
</div>
</div>
{/* 技术信息区域 */}
<div className="p-4">
<h3 className="text-sm font-semibold text-gray-700 mb-3"></h3>
<div className="space-y-3 text-sm">
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
{detail?.website ? (
<a
href={detail.website}
target="_blank"
rel="noreferrer"
className="text-xs text-blue-600 break-all hover:underline"
>
{detail.website}
</a>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</div>
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
{detail?.referrer ? (
<a
href={detail.referrer}
target="_blank"
rel="noreferrer"
className="text-xs text-blue-600 break-all hover:underline"
>
{detail.referrer}
</a>
) : (
<div className="text-gray-400 text-xs"></div>
)}
</div>
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
<div className="text-gray-700 text-xs">
{displayValue(detail?.language, "暂未收集")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
<div className="text-gray-700 text-xs">
{displayValue(detail?.browser, "暂未收集")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
<div className="text-gray-700 text-xs">
{displayValue(detail?.os, "暂未收集")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs">IP </div>
<div className="text-gray-700 text-xs">
{displayValue(detail?.ip_address, "暂未收集")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
<div className="text-gray-700 text-xs">
{displayValue(detail?.location, "暂未收集")}
</div>
</div>
<div>
<div className="text-gray-500 mb-1 text-xs"></div>
<div className="text-gray-700 text-xs">
{detail?.last_seen_at
? formatConversationTime(detail.last_seen_at)
: "未知"}
</div>
</div>
</div>
</div>
</div>
{editingField && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm p-6">
<h3 className="text-base font-semibold text-gray-800 mb-3">
{fieldLabels[editingField]}
</h3>
{editingField === "notes" ? (
<textarea
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none h-32"
value={editingValue}
onChange={(event) => setEditingValue(event.target.value)}
placeholder={`请输入${fieldLabels[editingField]}`}
/>
) : (
<input
type="text"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
value={editingValue}
onChange={(event) => setEditingValue(event.target.value)}
placeholder={`请输入${fieldLabels[editingField]}`}
/>
)}
{errorMessage && (
<div className="text-xs text-red-500 mt-2">{errorMessage}</div>
)}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200"
onClick={handleCloseEditor}
disabled={saving}
>
</button>
<button
type="button"
className="px-3 py-1.5 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-300 disabled:cursor-not-allowed"
onClick={handleSubmit}
disabled={saving}
>
{saving ? "保存中..." : "保存"}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { AgentUser } from "../../agent/types";
import { logout } from "../../agent/services/authApi";
import { clearAgentUser, getAgentUser } from "@/utils/storage";
export function useAuth() {
const router = useRouter();
const [agent, setAgent] = useState<AgentUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const current = getAgentUser();
if (!current) {
setLoading(false);
router.push("/");
return;
}
setAgent(current);
setLoading(false);
}, [router]);
const handleLogout = useCallback(async () => {
try {
await logout();
} catch (error) {
console.error("退出登录失败:", error);
} finally {
clearAgentUser();
router.push("/");
}
}, [router]);
return {
agent,
loading,
isAuthenticated: Boolean(agent),
logout: handleLogout,
};
}
@@ -0,0 +1,162 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
fetchConversations,
searchConversations,
} from "../../agent/services/conversationApi";
import { ConversationSummary } from "../../agent/types";
const sortByUpdatedAtDesc = (list: ConversationSummary[]) =>
[...list].sort(
(a, b) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
export function useConversations() {
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [filteredConversations, setFilteredConversations] = useState<
ConversationSummary[]
>([]);
const [selectedConversationId, setSelectedConversationId] = useState<
number | null
>(null);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [isInitialLoad, setIsInitialLoad] = useState(true);
const searchRef = useRef("");
const loadConversations = useCallback(async () => {
setLoading(true);
try {
const data = await fetchConversations();
setConversations(data);
if (!searchRef.current.trim()) {
setFilteredConversations(data);
}
setSelectedConversationId((prev) => {
if (prev) {
return prev;
}
return data.length > 0 ? data[0].id : null;
});
} catch (error) {
console.error(error);
} finally {
setLoading(false);
setIsInitialLoad(false);
}
}, []);
useEffect(() => {
loadConversations();
}, [loadConversations]);
useEffect(() => {
if (isInitialLoad) {
return;
}
const handler = setTimeout(async () => {
const query = searchQuery.trim();
searchRef.current = query;
if (!query) {
setFilteredConversations(sortByUpdatedAtDesc(conversations));
return;
}
try {
setLoading(true);
const data = await searchConversations(query);
setFilteredConversations(sortByUpdatedAtDesc(data));
} catch (error) {
console.error(error);
setFilteredConversations([]);
} finally {
setLoading(false);
}
}, 300);
return () => clearTimeout(handler);
}, [searchQuery, conversations, isInitialLoad]);
const selectConversation = useCallback((conversationId: number) => {
setSelectedConversationId((prev) =>
prev === conversationId ? prev : conversationId
);
}, []);
const updateConversation = useCallback(
(
conversationId: number,
updater: (conversation: ConversationSummary) => ConversationSummary,
options?: { skipResort?: boolean }
) => {
const applyUpdate = (list: ConversationSummary[]) => {
let changed = false;
const next = list.map((conv) => {
if (conv.id === conversationId) {
changed = true;
return updater(conv);
}
return conv;
});
if (!changed) {
return list;
}
if (options?.skipResort) {
return next;
}
return sortByUpdatedAtDesc(next);
};
setConversations((prev) => applyUpdate(prev));
setFilteredConversations((prev) => {
if (searchRef.current && !prev.some((item) => item.id === conversationId)) {
return prev;
}
return applyUpdate(prev);
});
},
[]
);
const setAllConversations = useCallback((data: ConversationSummary[]) => {
setConversations(data);
if (!searchRef.current.trim()) {
setFilteredConversations(data);
}
}, []);
const contextValue = useMemo(
() => ({
conversations,
filteredConversations,
selectedConversationId,
searchQuery,
loading,
isInitialLoad,
setSearchQuery,
selectConversation,
refresh: loadConversations,
updateConversation,
setAllConversations,
}),
[
conversations,
filteredConversations,
selectedConversationId,
searchQuery,
loading,
isInitialLoad,
selectConversation,
loadConversations,
updateConversation,
setAllConversations,
setSearchQuery,
]
);
return contextValue;
}
@@ -0,0 +1,436 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
fetchConversationDetail,
updateConversationContact,
UpdateConversationContactPayload,
UpdateConversationContactResult,
} from "../../agent/services/conversationApi";
import {
fetchMessages,
markMessagesRead,
sendMessage,
} from "../../agent/services/messageApi";
import {
ConversationDetail,
ConversationSummary,
MessageItem,
MessagesReadPayload,
ChatWebSocketPayload,
VisitorStatusUpdatePayload,
} from "../../agent/types";
import { useWebSocket } from "./useWebSocket";
import { WSMessage } from "@/lib/websocket";
import { buildMessagePreview } from "@/utils/format";
interface UseMessagesOptions {
conversationId: number | null;
agentId: number | null;
updateConversation: (
conversationId: number,
updater: (conversation: ConversationSummary) => ConversationSummary,
options?: { skipResort?: boolean }
) => void;
}
export function useMessages({
conversationId,
agentId,
updateConversation,
}: UseMessagesOptions) {
// 消息列表、请求状态、访客详情等基础状态
const [messages, setMessages] = useState<MessageItem[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false);
const [sending, setSending] = useState(false);
const [conversationDetail, setConversationDetail] =
useState<ConversationDetail | null>(null);
const refreshConversationDetail = useCallback(
async (id: number) => {
const detail = await fetchConversationDetail(id);
setConversationDetail(detail);
// 同时更新对话列表中的 last_seen_at(用于判断在线状态)
if (detail) {
updateConversation(id, (conv) => ({
...conv,
last_seen_at: detail.last_seen_at ?? conv.last_seen_at ?? null,
}));
}
},
[updateConversation]
);
const updateContactInfo = useCallback(
async (
payload: UpdateConversationContactPayload
): Promise<UpdateConversationContactResult> => {
if (!conversationId) {
throw new Error("未选中会话,无法更新访客信息");
}
const result = await updateConversationContact(conversationId, payload);
setConversationDetail((prev) =>
prev
? {
...prev,
email: result.email,
phone: result.phone,
notes: result.notes,
}
: prev
);
if (!conversationDetail) {
refreshConversationDetail(conversationId);
}
return result;
},
[conversationDetail, conversationId, refreshConversationDetail]
);
const handleMarkMessagesRead = useCallback(
async (id: number, readerIsAgent: boolean) => {
const result = await markMessagesRead(id, readerIsAgent);
if (!result || result.message_ids.length === 0) {
return;
}
const messageIdSet = new Set(result.message_ids);
setMessages((prev) =>
prev.map((msg) =>
messageIdSet.has(msg.id)
? {
...msg,
is_read: true,
read_at: result.read_at ?? msg.read_at ?? null,
}
: msg
)
);
if (readerIsAgent) {
updateConversation(id, (conversation) => ({
...conversation,
unread_count: result.unread_count,
last_message:
conversation.last_message &&
messageIdSet.has(conversation.last_message.id)
? {
...conversation.last_message,
is_read: true,
read_at:
result.read_at ?? conversation.last_message.read_at ?? null,
}
: conversation.last_message,
}));
setConversationDetail((prev) =>
prev ? { ...prev, unread_count: result.unread_count } : prev
);
} else {
updateConversation(
id,
(conversation) => ({
...conversation,
last_message:
conversation.last_message &&
messageIdSet.has(conversation.last_message.id)
? {
...conversation.last_message,
is_read: true,
read_at:
result.read_at ??
conversation.last_message.read_at ??
null,
}
: conversation.last_message,
}),
{ skipResort: true }
);
setConversationDetail((prev) =>
prev ? { ...prev, last_seen_at: result.read_at ?? prev.last_seen_at ?? null } : prev
);
}
},
[updateConversation]
);
const loadMessages = useCallback(
async (id: number) => {
setLoadingMessages(true);
try {
const data = await fetchMessages(id);
setMessages(data);
// 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
} catch (error) {
console.error("拉取消息失败:", error);
} finally {
setLoadingMessages(false);
}
},
[]
);
useEffect(() => {
if (!conversationId || !agentId) {
setMessages([]);
setConversationDetail(null);
return;
}
loadMessages(conversationId);
refreshConversationDetail(conversationId);
}, [conversationId, agentId, loadMessages, refreshConversationDetail]);
const handleSendMessage = useCallback(
async (content: string) => {
if (!conversationId || !agentId || !content.trim() || sending) {
return;
}
setSending(true);
try {
await sendMessage({
conversationId,
content,
senderId: agentId,
});
} catch (error) {
console.error(error);
throw error;
} finally {
setSending(false);
}
},
[agentId, conversationId, sending]
);
const handleNewMessage = useCallback(
(message: MessageItem) => {
setMessages((prev) => {
const exists = prev.some((item) => item.id === message.id);
if (exists) {
// 消息已存在,更新消息内容(包括已读状态)
return prev.map((msg) =>
msg.id === message.id
? {
...msg,
...message,
// 如果消息已被标记为已读,保持已读状态
is_read: message.is_read ?? msg.is_read,
read_at: message.read_at ?? msg.read_at,
}
: msg
);
}
return [...prev, message];
});
updateConversation(message.conversation_id, (conversation) => {
const preview = buildMessagePreview(message.content);
const isSystemMessage =
(message.message_type ?? "user_message") === "system_message";
const isVisitorMessage = !message.sender_is_agent && !isSystemMessage;
const isCurrentConversation = message.conversation_id === conversationId;
const nextUnread = isVisitorMessage
? isCurrentConversation
? 0
: (conversation.unread_count ?? 0) + 1
: conversation.unread_count ?? 0;
return {
...conversation,
updated_at: message.created_at,
unread_count: nextUnread,
last_message: {
id: message.id,
content: preview,
sender_is_agent: message.sender_is_agent,
message_type: message.message_type ?? "user_message",
is_read: Boolean(message.is_read),
read_at: message.read_at ?? null,
created_at: message.created_at,
},
};
});
// 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
if (message.conversation_id === conversationId) {
refreshConversationDetail(message.conversation_id);
}
},
[conversationId, refreshConversationDetail, updateConversation]
);
const handleMessagesReadBroadcast = useCallback(
(payload: MessagesReadPayload, eventConversationId?: number) => {
const messageIds: number[] = Array.isArray(payload?.message_ids)
? payload.message_ids
: [];
if (!Array.isArray(messageIds) || messageIds.length === 0) {
return;
}
const readAt: string | undefined = payload?.read_at;
const readerIsAgent: boolean = Boolean(payload?.reader_is_agent);
const conversation_id: number | undefined =
payload?.conversation_id ?? eventConversationId;
if (!conversation_id) {
return;
}
// 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
// 才更新客服消息(sender_is_agent === true)的已读状态
if (readerIsAgent) {
return;
}
const idSet = new Set(messageIds);
// 更新消息列表中的已读状态(只更新当前对话中的消息,且只更新客服自己的消息)
if (conversation_id === conversationId) {
setMessages((prev) => {
// 检查是否有需要更新的消息
const hasUpdates = prev.some(
(msg) => idSet.has(msg.id) && msg.sender_is_agent && !msg.is_read
);
if (!hasUpdates) {
// 没有需要更新的消息,直接返回原列表
return prev;
}
// 更新消息列表
return prev.map((msg) =>
// 只更新客服自己的消息(sender_is_agent === true)的已读状态
idSet.has(msg.id) && msg.sender_is_agent
? {
...msg,
is_read: true,
read_at: readAt ?? msg.read_at ?? null,
}
: msg
);
});
}
const unreadCount =
typeof payload?.unread_count === "number"
? payload.unread_count
: undefined;
updateConversation(conversation_id, (conversation) => {
const lastMessage =
conversation.last_message &&
idSet.has(conversation.last_message.id)
? {
...conversation.last_message,
is_read: true,
read_at:
readAt ?? conversation.last_message.read_at ?? null,
}
: conversation.last_message;
return {
...conversation,
last_message: lastMessage,
unread_count:
readerIsAgent && unreadCount !== undefined
? unreadCount
: conversation.unread_count,
};
});
if (conversation_id === conversationId) {
setConversationDetail((prev) => {
if (!prev) {
return prev;
}
if (readerIsAgent && unreadCount !== undefined) {
return { ...prev, unread_count: unreadCount };
}
if (!readerIsAgent) {
return {
...prev,
last_seen_at: readAt ?? prev.last_seen_at ?? null,
};
}
return prev;
});
}
},
[conversationId, updateConversation]
);
const onWebSocketMessage = useCallback(
(event: WSMessage<ChatWebSocketPayload>) => {
if (!event) {
return;
}
if (event.type === "new_message" && event.data) {
const data = event.data as MessageItem;
if (typeof data.conversation_id === "number") {
handleNewMessage(data);
}
} else if (event.type === "messages_read") {
handleMessagesReadBroadcast(
event.data as MessagesReadPayload,
event.conversation_id
);
} else if (event.type === "visitor_status_update") {
// 处理访客状态更新事件
const payload = event.data as VisitorStatusUpdatePayload;
if (payload?.conversation_id) {
if (payload.is_online === true) {
// 在线:更新为当前时间(实时更新在线状态)
updateConversation(payload.conversation_id, (conv) => ({
...conv,
last_seen_at: new Date().toISOString(),
}));
}
// 刷新对话详情以获取最新的 last_seen_at(后端会在离线时更新 last_seen_at
// refreshConversationDetail 会自动更新对话列表的 last_seen_at
refreshConversationDetail(payload.conversation_id);
}
}
},
[
conversationId,
handleMessagesReadBroadcast,
handleNewMessage,
refreshConversationDetail,
updateConversation,
]
);
useWebSocket<ChatWebSocketPayload>({
conversationId,
enabled: Boolean(conversationId),
isVisitor: false, // 客服端设置为 false
onMessage: onWebSocketMessage,
onError: (error) => console.error("WebSocket 连接错误:", error),
onClose: () => console.log("WebSocket 连接已关闭"),
});
const controls = useMemo(
() => ({
messages,
loadingMessages,
sending,
conversationDetail,
refreshConversationDetail,
refreshMessages: loadMessages,
sendMessage: handleSendMessage,
markMessagesAsRead: handleMarkMessagesRead,
updateContactInfo,
}),
[
conversationDetail,
handleMarkMessagesRead,
handleSendMessage,
loadMessages,
loadingMessages,
messages,
refreshConversationDetail,
sending,
updateContactInfo,
]
);
return controls;
}
@@ -0,0 +1,95 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
fetchProfile,
updateProfile,
uploadAvatar,
UpdateProfilePayload,
} from "../../agent/services/profileApi";
import { Profile } from "../../agent/types";
interface UseProfileOptions {
userId: number | null;
enabled?: boolean;
}
export function useProfile({ userId, enabled = true }: UseProfileOptions) {
const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(false);
const [updating, setUpdating] = useState(false);
const [uploading, setUploading] = useState(false);
// 加载个人资料
const loadProfile = useCallback(async () => {
if (!userId || !enabled) {
return;
}
setLoading(true);
try {
const data = await fetchProfile(userId);
setProfile(data);
} catch (error) {
console.error("获取个人资料失败:", error);
} finally {
setLoading(false);
}
}, [userId, enabled]);
// 初始化时加载个人资料
useEffect(() => {
loadProfile();
}, [loadProfile]);
// 更新个人资料
const update = useCallback(
async (payload: UpdateProfilePayload) => {
if (!userId) {
throw new Error("用户ID不能为空");
}
setUpdating(true);
try {
const updated = await updateProfile(userId, payload);
setProfile(updated);
return updated;
} finally {
setUpdating(false);
}
},
[userId]
);
// 上传头像
const upload = useCallback(
async (file: File) => {
if (!userId) {
throw new Error("用户ID不能为空");
}
setUploading(true);
try {
const updated = await uploadAvatar(userId, file);
setProfile(updated);
return updated;
} finally {
setUploading(false);
}
},
[userId]
);
// 刷新个人资料
const refresh = useCallback(() => {
return loadProfile();
}, [loadProfile]);
return {
profile,
loading,
updating,
uploading,
update,
upload,
refresh,
};
}
@@ -0,0 +1,60 @@
"use client";
import { useEffect, useRef } from "react";
import { WSClient, WSMessage } from "@/lib/websocket";
interface UseWebSocketOptions<T> {
conversationId: number | null;
enabled?: boolean;
isVisitor?: boolean; // 是否是访客(默认为 true
onMessage: (payload: WSMessage<T>) => void;
onError?: (error: Event) => void;
onClose?: () => void;
}
export function useWebSocket<T>({
conversationId,
enabled = true,
isVisitor = true, // 默认是访客
onMessage,
onError,
onClose,
}: UseWebSocketOptions<T>) {
// 使用 useRef 存储最新的回调函数,避免因回调函数变化导致重新连接
const onMessageRef = useRef(onMessage);
const onErrorRef = useRef(onError);
const onCloseRef = useRef(onClose);
// 更新 ref 的值
useEffect(() => {
onMessageRef.current = onMessage;
onErrorRef.current = onError;
onCloseRef.current = onClose;
}, [onMessage, onError, onClose]);
useEffect(() => {
if (!conversationId || !enabled) {
return;
}
const client = new WSClient<T>({
conversationId,
isVisitor,
// 使用 ref 的 current 值,这样即使回调函数变化也不会导致重新连接
onMessage: (payload) => onMessageRef.current(payload),
onError: onErrorRef.current
? (error) => onErrorRef.current?.(error)
: undefined,
onClose: onCloseRef.current ? () => onCloseRef.current?.() : undefined,
});
client.connect();
return () => {
client.disconnect();
};
// 只依赖 conversationId、enabled 和 isVisitor,不依赖回调函数
// 回调函数通过 useRef 存储,不会导致重新连接
}, [conversationId, enabled, isVisitor]);
}
@@ -0,0 +1,8 @@
import { API_BASE_URL } from "@/lib/config";
export async function logout(): Promise<void> {
await fetch(`${API_BASE_URL}/logout`, {
method: "POST",
});
}
@@ -0,0 +1,98 @@
import { API_BASE_URL } from "@/lib/config";
import {
ConversationDetail,
ConversationSummary,
} from "../types";
export async function fetchConversations(): Promise<ConversationSummary[]> {
const res = await fetch(`${API_BASE_URL}/conversations`, {
cache: "no-store",
});
if (!res.ok) {
throw new Error("获取对话列表失败");
}
const data = await res.json();
if (!Array.isArray(data)) {
return [];
}
return data.map((item) => ({
...item,
unread_count: item.unread_count ?? 0,
}));
}
export async function searchConversations(
query: string
): Promise<ConversationSummary[]> {
const res = await fetch(
`${API_BASE_URL}/conversations/search?q=${encodeURIComponent(query)}`,
{
cache: "no-store",
}
);
if (!res.ok) {
throw new Error("搜索对话失败");
}
const data = await res.json();
if (!Array.isArray(data)) {
return [];
}
return data.map((item) => ({
...item,
unread_count: item.unread_count ?? 0,
}));
}
export async function fetchConversationDetail(
conversationId: number
): Promise<ConversationDetail | null> {
const res = await fetch(`${API_BASE_URL}/conversations/${conversationId}`, {
cache: "no-store",
});
if (!res.ok) {
return null;
}
const data = await res.json();
return {
...data,
unread_count: data.unread_count ?? 0,
};
}
export interface UpdateConversationContactPayload {
email?: string;
phone?: string;
notes?: string;
}
export interface UpdateConversationContactResult {
email: string;
phone: string;
notes: string;
}
export async function updateConversationContact(
conversationId: number,
payload: UpdateConversationContactPayload
): Promise<UpdateConversationContactResult> {
const res = await fetch(
`${API_BASE_URL}/conversations/${conversationId}/contact`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}
);
if (!res.ok) {
throw new Error("更新访客联系信息失败");
}
const data = await res.json();
return {
email: data.email ?? "",
phone: data.phone ?? "",
notes: data.notes ?? "",
};
}
@@ -0,0 +1,84 @@
import { API_BASE_URL } from "@/lib/config";
import { MessageItem } from "../types";
interface SendMessagePayload {
conversationId: number;
content: string;
senderId?: number;
senderIsAgent?: boolean;
}
export async function fetchMessages(
conversationId: number
): Promise<MessageItem[]> {
const res = await fetch(
`${API_BASE_URL}/messages?conversation_id=${conversationId}`,
{
cache: "no-store",
}
);
if (!res.ok) {
throw new Error("获取消息失败");
}
const data = await res.json();
if (!Array.isArray(data)) {
return [];
}
return data;
}
export async function sendMessage({
conversationId,
content,
senderId,
senderIsAgent = true,
}: SendMessagePayload): Promise<void> {
const res = await fetch(`${API_BASE_URL}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversationId,
content,
sender_is_agent: senderIsAgent,
sender_id: typeof senderId === "number" ? senderId : 0,
}),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
console.error(
`❌ 发送消息失败: 对话ID=${conversationId}, 状态=${res.status}, 错误=${JSON.stringify(error)}`
);
throw new Error(error.error || "发送消息失败");
}
}
export interface MarkMessagesReadResult {
message_ids: number[];
unread_count: number;
read_at?: string;
}
export async function markMessagesRead(
conversationId: number,
readerIsAgent: boolean
): Promise<MarkMessagesReadResult | null> {
const res = await fetch(`${API_BASE_URL}/messages/read`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversationId,
reader_is_agent: readerIsAgent,
}),
});
if (!res.ok) {
return null;
}
const data = await res.json();
return {
message_ids: Array.isArray(data.message_ids) ? data.message_ids : [],
unread_count:
typeof data.unread_count === "number" ? data.unread_count : 0,
read_at: typeof data.read_at === "string" ? data.read_at : undefined,
};
}
@@ -0,0 +1,87 @@
// 客服个人资料 API 服务
import { API_BASE_URL } from "@/lib/config";
import { Profile } from "../types";
// 获取个人资料
export async function fetchProfile(userId: number): Promise<Profile | null> {
const res = await fetch(`${API_BASE_URL}/agent/profile/${userId}`, {
cache: "no-store",
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(
error.error || error.message || `获取个人资料失败 (${res.status})`
);
}
const data = await res.json();
return {
id: data.id ?? 0,
username: data.username ?? "",
role: data.role ?? "",
avatar_url: data.avatar_url ?? "",
nickname: data.nickname ?? "",
email: data.email ?? "",
};
}
// 更新个人资料
export interface UpdateProfilePayload {
nickname?: string;
email?: string;
}
export async function updateProfile(
userId: number,
payload: UpdateProfilePayload
): Promise<Profile> {
const res = await fetch(`${API_BASE_URL}/agent/profile/${userId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(
error.error || error.message || `更新个人资料失败 (${res.status})`
);
}
const data = await res.json();
return {
id: data.id ?? 0,
username: data.username ?? "",
role: data.role ?? "",
avatar_url: data.avatar_url ?? "",
nickname: data.nickname ?? "",
email: data.email ?? "",
};
}
// 上传头像
export async function uploadAvatar(
userId: number,
file: File
): Promise<Profile> {
const formData = new FormData();
formData.append("avatar", file);
const res = await fetch(`${API_BASE_URL}/agent/avatar/${userId}`, {
method: "POST",
body: formData,
});
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new Error(
error.error || error.message || `上传头像失败 (${res.status})`
);
}
const data = await res.json();
return {
id: data.id ?? 0,
username: data.username ?? "",
role: data.role ?? "",
avatar_url: data.avatar_url ?? "",
nickname: data.nickname ?? "",
email: data.email ?? "",
};
}
+83
View File
@@ -0,0 +1,83 @@
export interface LastMessage {
id: number;
content: string;
sender_is_agent: boolean;
message_type: string;
is_read: boolean;
read_at?: string | null;
created_at: string;
}
export interface ConversationSummary {
id: number;
visitor_id: number;
agent_id: number;
status: string;
created_at: string;
updated_at: string;
last_message?: LastMessage;
unread_count?: number;
last_seen_at?: string | null; // 最后活跃时间,用于判断在线状态
}
export interface MessageItem {
id: number;
conversation_id: number;
sender_id: number;
sender_is_agent: boolean;
content: string;
created_at: string;
message_type?: string;
is_read?: boolean;
read_at?: string | null;
}
export interface ConversationDetail extends ConversationSummary {
website?: string;
referrer?: string;
browser?: string;
os?: string;
language?: string;
ip_address?: string;
location?: string;
email?: string;
phone?: string;
notes?: string;
last_seen_at?: string | null;
}
export interface AgentUser {
id: number;
username: string;
role: string;
}
// 个人资料信息
export interface Profile {
id: number;
username: string;
role: string;
avatar_url: string;
nickname: string;
email: string;
}
export interface MessagesReadPayload {
message_ids?: number[];
read_at?: string;
reader_is_agent?: boolean;
conversation_id?: number;
unread_count?: number;
}
export interface VisitorStatusUpdatePayload {
conversation_id?: number;
is_online?: boolean;
visitor_count?: number;
}
export type ChatWebSocketPayload =
| MessageItem
| MessagesReadPayload
| VisitorStatusUpdatePayload;
@@ -0,0 +1,45 @@
import { API_BASE_URL } from "@/lib/config";
export interface InitVisitorConversationPayload {
visitorId: number;
website?: string;
referrer?: string;
browser?: string;
os?: string;
language?: string;
ipAddress?: string;
}
export interface InitVisitorConversationResult {
conversation_id: number;
status: string;
}
export async function initVisitorConversation(
payload: InitVisitorConversationPayload
): Promise<InitVisitorConversationResult> {
const res = await fetch(`${API_BASE_URL}/conversation/init`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
visitor_id: payload.visitorId,
website: payload.website,
referrer: payload.referrer,
browser: payload.browser,
os: payload.os,
language: payload.language,
ip_address: payload.ipAddress,
}),
});
if (!res.ok) {
throw new Error("初始化对话失败");
}
const data = await res.json();
return {
conversation_id: data.conversation_id ?? 0,
status: data.status ?? "open",
};
}
+6
View File
@@ -0,0 +1,6 @@
// 统一的 API 配置
// 读取 NEXT_PUBLIC_ 开头的环境变量(Next.js 会自动暴露到浏览器)
// 使用默认值方便本地开发
export const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8080";
+162
View File
@@ -0,0 +1,162 @@
// WebSocket 客户端工具
// 用于连接后端 WebSocket 服务,接收实时消息
// WebSocket 消息类型
export interface WSMessage<T = unknown> {
type: string; // "new_message" | "conversation_update" 等
conversation_id: number;
data: T; // 消息内容(Message 对象)
}
// WebSocket 连接选项
export interface WSOptions<T = unknown> {
conversationId: number; // 对话ID
isVisitor?: boolean; // 是否是访客(默认为 true
onMessage?: (message: WSMessage<T>) => void; // 收到消息时的回调
onError?: (error: Event) => void; // 连接错误时的回调
onClose?: () => void; // 连接关闭时的回调
}
// WebSocket 客户端类
export class WSClient<T = unknown> {
private ws: WebSocket | null = null;
private conversationId: number;
private isVisitor: boolean;
private onMessage?: (message: WSMessage<T>) => void;
private onError?: (error: Event) => void;
private onClose?: () => void;
private reconnectTimer: NodeJS.Timeout | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private reconnectDelay = 3000; // 3秒
constructor(options: WSOptions<T>) {
this.conversationId = options.conversationId;
this.isVisitor = options.isVisitor !== undefined ? options.isVisitor : true;
this.onMessage = options.onMessage;
this.onError = options.onError;
this.onClose = options.onClose;
}
// 连接 WebSocket
connect() {
// 如果已经连接,先断开
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
this.ws.close();
this.ws = null;
}
// 获取 API 基地址
const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8080";
// 将 http:// 替换为 ws://,将 https:// 替换为 wss://
const wsUrl =
apiBaseUrl.replace(/^http/, "ws") +
`/ws?conversation_id=${this.conversationId}&is_visitor=${this.isVisitor}`;
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
this.reconnectAttempts = 0; // 重置重连次数
};
this.ws.onmessage = (event) => {
try {
const message: WSMessage<T> = JSON.parse(event.data);
if (this.onMessage) {
this.onMessage(message);
}
} catch (error) {
console.error(
`❌ 解析 WebSocket 消息失败: 对话ID=${this.conversationId}`,
error
);
}
};
this.ws.onerror = (error) => {
const state = this.ws?.readyState;
const stateText =
state === WebSocket.CONNECTING
? "连接中"
: state === WebSocket.OPEN
? "已连接"
: state === WebSocket.CLOSING
? "关闭中"
: state === WebSocket.CLOSED
? "已关闭"
: "未知";
const url = this.ws?.url || wsUrl;
console.error(
`❌ WebSocket 错误: 对话ID=${this.conversationId}, 状态=${stateText}, URL=${url}`,
error
);
if (this.onError) {
this.onError(error);
}
};
this.ws.onclose = (event) => {
this.ws = null;
if (this.onClose) {
this.onClose();
}
// 只有在非正常关闭时才尝试重连(避免在开发模式下频繁重连)
const code = event.code;
const wasClean = event.wasClean;
if (!wasClean && code !== 1000) {
this.attemptReconnect();
}
};
} catch (error) {
console.error(
`❌ 创建 WebSocket 连接失败: 对话ID=${this.conversationId}, URL=${wsUrl}`,
error
);
if (this.onError) {
// 创建一个错误事件对象
const errorEvent = new Event("error");
this.onError(errorEvent);
}
}
}
// 尝试重连
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`❌ WebSocket 重连次数已达上限,停止重连: 对话ID=${this.conversationId}`);
return;
}
this.reconnectAttempts++;
this.reconnectTimer = setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
// 断开连接
disconnect() {
// 取消重连
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// 关闭 WebSocket 连接
if (this.ws) {
// 设置标志,避免重连
this.reconnectAttempts = this.maxReconnectAttempts;
// 关闭连接
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
this.ws = null;
}
}
// 检查是否已连接
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
}
+38
View File
@@ -0,0 +1,38 @@
// 头像工具函数
import { API_BASE_URL } from "@/lib/config";
/**
* URL
* avatarUrl URL http:// 或 https:// 开头),直接返回
* API_BASE_URL
*/
export function getAvatarUrl(avatarUrl: string | null | undefined): string | null {
if (!avatarUrl) {
return null;
}
// 如果已经是完整 URL,直接返回
if (avatarUrl.startsWith("http://") || avatarUrl.startsWith("https://")) {
return avatarUrl;
}
// 如果是相对路径,拼接 API_BASE_URL
// 确保路径以 / 开头
const path = avatarUrl.startsWith("/") ? avatarUrl : `/${avatarUrl}`;
return `${API_BASE_URL}${path}`;
}
/**
*
*/
export function getAvatarColor(seed: string | number): string {
const value = typeof seed === "string" ? seed.length : seed;
return `hsl(${(value * 137.5) % 360}, 70%, 50%)`;
}
/**
*
*/
export function getAvatarInitial(username: string, nickname?: string): string {
const displayName = nickname || username || "?";
return displayName.charAt(0).toUpperCase();
}
+78
View File
@@ -0,0 +1,78 @@
export function formatConversationTime(dateStr: string | null | undefined): string {
if (!dateStr) {
return "-";
}
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) {
return "-";
}
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function formatMessageTime(dateStr: string | null | undefined): string {
if (!dateStr) {
return "";
}
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) {
return "";
}
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
return date.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
});
}
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
export function buildMessagePreview(content: string, maxLength = 50): string {
if (!content) {
return "";
}
if (content.length <= maxLength) {
return content;
}
return `${content.substring(0, maxLength)}...`;
}
// 判断访客是否在线(根据 last_seen_at 字段)
// 如果 last_seen_at 在最近 10 秒内,则认为在线
export function isVisitorOnline(lastSeenAt: string | null | undefined): boolean {
if (!lastSeenAt) {
return false;
}
const lastSeen = new Date(lastSeenAt);
if (Number.isNaN(lastSeen.getTime())) {
return false;
}
const now = new Date();
const diff = now.getTime() - lastSeen.getTime();
// 10 秒内认为在线
return diff < 10 * 1000;
}
+23
View File
@@ -0,0 +1,23 @@
import { ReactNode } from "react";
export function highlightText(text: string, keyword: string): ReactNode {
if (!keyword.trim()) {
return text;
}
const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const regex = new RegExp(`(${escaped})`, "gi");
const parts = text.split(regex);
return parts.map((part, index) =>
regex.test(part) ? (
<mark
key={`${part}-${index}`}
className="bg-yellow-300 text-gray-900 px-1 rounded"
>
{part}
</mark>
) : (
part
)
);
}
+50
View File
@@ -0,0 +1,50 @@
import { AgentUser } from "@/features/agent/types";
const AGENT_ID_KEY = "agent_user_id";
const AGENT_USERNAME_KEY = "agent_username";
const AGENT_ROLE_KEY = "agent_role";
const isBrowser = () => typeof window !== "undefined";
export function getAgentUser(): AgentUser | null {
if (!isBrowser()) {
return null;
}
const id = window.localStorage.getItem(AGENT_ID_KEY);
const username = window.localStorage.getItem(AGENT_USERNAME_KEY);
const role = window.localStorage.getItem(AGENT_ROLE_KEY);
if (!id || !username) {
return null;
}
const parsedId = Number.parseInt(id, 10);
if (Number.isNaN(parsedId)) {
return null;
}
return {
id: parsedId,
username,
role: role ?? "",
};
}
export function setAgentUser(agent: AgentUser): void {
if (!isBrowser()) {
return;
}
window.localStorage.setItem(AGENT_ID_KEY, String(agent.id));
window.localStorage.setItem(AGENT_USERNAME_KEY, agent.username);
window.localStorage.setItem(AGENT_ROLE_KEY, agent.role ?? "");
}
export function clearAgentUser(): void {
if (!isBrowser()) {
return;
}
window.localStorage.removeItem(AGENT_ID_KEY);
window.localStorage.removeItem(AGENT_USERNAME_KEY);
window.localStorage.removeItem(AGENT_ROLE_KEY);
}