mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
迁移shadcn ui前版本
This commit is contained in:
@@ -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` 文件
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=hkbjujk%h2eT
|
||||
DB_NAME=CS
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 进行数据库操作
|
||||
- 实现了完整的错误处理机制
|
||||
|
||||
### 下一步计划
|
||||
- 完善发送消息功能
|
||||
- 实现拉取消息功能
|
||||
- 添加前端界面
|
||||
|
||||
@@ -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()
|
||||
|
||||
步骤 3:Hub 广播消息
|
||||
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
@@ -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 读写统一封装
|
||||
|
||||
> 小结:页面调用 Hook,Hook 使用 Service,Service 请求后端;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
@@ -0,0 +1,458 @@
|
||||
# 后端学习笔记(Go + Gin + GORM)
|
||||
|
||||
## 一、核心概念理解
|
||||
|
||||
### 1. Gin 框架(Web框架)
|
||||
**类比**:就像餐厅的服务员,接收客人的请求,然后告诉厨房(处理函数)做什么菜
|
||||
|
||||
- `gin.Default()` = 创建一个 Gin 服务器(就像开一家餐厅)
|
||||
- `r.POST("/路径", 处理函数)` = 注册一个路由(就像在菜单上写:客人点这个菜,叫这个厨师做)
|
||||
- `r.Run(":8080")` = 启动服务器,监听 8080 端口(就像开门营业)
|
||||
|
||||
**为什么需要?**
|
||||
- 没有框架:需要自己处理 HTTP 请求、解析 JSON、路由等(很麻烦)
|
||||
- 有了框架:只需要写业务逻辑,框架帮你处理其他事情(简单)
|
||||
|
||||
### 2. GORM(ORM框架)
|
||||
**类比**:就像翻译官,帮你把 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)
|
||||
├── conn(WebSocket 连接)
|
||||
├── 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 不依赖 gin,repository 可以替换成 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
@@ -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
@@ -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 地址是否正确
|
||||
|
||||
### 问题 5:WebSocket 连接失败
|
||||
**错误**:`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
@@ -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.
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { DashboardShell } from "@/components/dashboard/DashboardShell";
|
||||
|
||||
export default function AgentDashboardPage() {
|
||||
// 页面采用纯客户端渲染,所有业务逻辑由 DashboardShell 承担
|
||||
return <DashboardShell />;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user