Bladeren bron

迁移shadcn ui前版本

537yaha 7 maanden geleden
bovenliggende
commit
5640fe7ae1
73 gewijzigde bestanden met toevoegingen van 10890 en 125 verwijderingen
  1. 209 0
      README.md
  2. 5 0
      backend/.env
  3. 55 0
      backend/controller/admin_controller.go
  4. 55 0
      backend/controller/auth_controller.go
  5. 261 0
      backend/controller/conversation_controller.go
  6. 29 0
      backend/controller/helper.go
  7. 108 0
      backend/controller/message_controller.go
  8. 122 0
      backend/controller/profile_controller.go
  9. 15 3
      backend/go.mod
  10. 35 7
      backend/go.sum
  11. 28 0
      backend/infra/db.go
  12. 121 0
      backend/infra/storage.go
  13. 184 19
      backend/main.go
  14. 28 0
      backend/middleware/middleware.go
  15. 52 0
      backend/models/user.go
  16. 113 0
      backend/repository/conversation_repository.go
  17. 96 0
      backend/repository/message_repository.go
  18. 44 0
      backend/repository/user_repository.go
  19. 45 0
      backend/router/router.go
  20. 77 0
      backend/service/auth_service.go
  21. 324 0
      backend/service/conversation_service.go
  22. 113 0
      backend/service/message_service.go
  23. 108 0
      backend/service/profile_service.go
  24. 113 0
      backend/service/types.go
  25. BIN
      backend/uploads/avatars/user_4_1763359157.png
  26. 59 0
      backend/utils/request.go
  27. 133 0
      backend/websocket/client.go
  28. 62 0
      backend/websocket/handler.go
  29. 218 0
      backend/websocket/hub.go
  30. 823 0
      doc/CHANGELOG.md
  31. 487 0
      doc/WebSocket学习笔记.md
  32. 337 0
      doc/前端学习笔记.md
  33. 458 0
      doc/后端学习笔记.md
  34. 404 0
      doc/待实现需求清单.md
  35. 982 0
      doc/测试指南.md
  36. 130 0
      doc/系统角色说明.md
  37. BIN
      frontend/.env.local.example
  38. 1 0
      frontend/.gitignore
  39. 401 0
      frontend/app/agent/chat/[conversationId]/page.tsx
  40. 188 0
      frontend/app/agent/conversations/page.tsx
  41. 7 0
      frontend/app/agent/dashboard/page.tsx
  42. 302 0
      frontend/app/chat/page.tsx
  43. 96 96
      frontend/app/page.tsx
  44. 96 0
      frontend/components/dashboard/ChatHeader.tsx
  45. 41 0
      frontend/components/dashboard/ConversationHeader.tsx
  46. 42 0
      frontend/components/dashboard/ConversationList.tsx
  47. 102 0
      frontend/components/dashboard/ConversationListItem.tsx
  48. 39 0
      frontend/components/dashboard/ConversationSearch.tsx
  49. 36 0
      frontend/components/dashboard/ConversationSidebar.tsx
  50. 75 0
      frontend/components/dashboard/DashboardHeader.tsx
  51. 229 0
      frontend/components/dashboard/DashboardShell.tsx
  52. 70 0
      frontend/components/dashboard/MessageInput.tsx
  53. 354 0
      frontend/components/dashboard/MessageList.tsx
  54. 113 0
      frontend/components/dashboard/NavigationSidebar.tsx
  55. 344 0
      frontend/components/dashboard/ProfileModal.tsx
  56. 362 0
      frontend/components/dashboard/VisitorDetailPanel.tsx
  57. 44 0
      frontend/features/agent/hooks/useAuth.ts
  58. 162 0
      frontend/features/agent/hooks/useConversations.ts
  59. 436 0
      frontend/features/agent/hooks/useMessages.ts
  60. 95 0
      frontend/features/agent/hooks/useProfile.ts
  61. 60 0
      frontend/features/agent/hooks/useWebSocket.ts
  62. 8 0
      frontend/features/agent/services/authApi.ts
  63. 98 0
      frontend/features/agent/services/conversationApi.ts
  64. 84 0
      frontend/features/agent/services/messageApi.ts
  65. 87 0
      frontend/features/agent/services/profileApi.ts
  66. 83 0
      frontend/features/agent/types.ts
  67. 45 0
      frontend/features/visitor/services/conversationApi.ts
  68. 6 0
      frontend/lib/config.ts
  69. 162 0
      frontend/lib/websocket.ts
  70. 38 0
      frontend/utils/avatar.ts
  71. 78 0
      frontend/utils/format.ts
  72. 23 0
      frontend/utils/highlight.tsx
  73. 50 0
      frontend/utils/storage.ts

+ 209 - 0
README.md

@@ -0,0 +1,209 @@
+# AI-CS 智能客服系统
+
+## 项目简介
+
+这是一个基于Go后端和Next.js前端的智能客服系统,用于处理访客与客服之间的对话交流。
+
+## 项目结构
+
+```
+AI-CS/
+├── backend/          # Go后端服务
+│   ├── controller/   # 控制器层
+│   ├── models/       # 数据模型
+│   ├── service/      # 业务逻辑层
+│   ├── repository/   # 数据访问层
+│   ├── middleware/   # 中间件
+│   ├── router/       # 路由配置
+│   ├── infra/        # 基础设施(数据库等)
+│   └── utils/        # 工具函数
+└── frontend/         # Next.js前端应用
+    ├── app/          # 应用页面
+    ├── public/       # 静态资源
+    └── ...
+```
+
+## 核心功能
+
+### 1. 用户管理
+- **用户注册** (`Register`): 创建新用户账户
+- **用户登录** (`Login`): 验证用户身份
+
+### 2. 对话管理
+- **初始化对话** (`InitConversation`): 为访客创建或获取现有对话
+- **发送消息**: 处理消息发送
+- **拉取消息**: 获取对话历史
+
+## 数据模型
+
+### User (用户)
+```go
+type User struct {
+    ID       uint   `json:"id" gorm:"primarykey"`
+    Username string `json:"username" gorm:"unique"`
+    Password string `json:"password"`
+    Role     string `json:"role"`
+}
+```
+
+### Conversation (对话)
+```go
+type Conversation struct {
+    ID        uint      `json:"id" gorm:"primaryKey"`
+    VisitorID uint      `json:"visiter_id"`
+    AgentID   uint      `json:"agent_id"`
+    Status    string    `json:"status"`
+    CreatedAt time.Time `json:"created_at"`
+    UpdatedAt time.Time `json:"updated_at"`
+}
+```
+
+### Message (消息)
+```go
+type Message struct {
+    ID             uint      `json:"id" gorm:"primarykey"`
+    ConversationID uint      `json:"conversation_id"`
+    SenderID       uint      `json:"sender_id"`
+    SenderIsAgent  bool      `json:"sender_is_agent"`
+    Content        string    `json:"content"`
+    CreatedAt      time.Time `json:"created_at"`
+}
+```
+
+## API接口说明
+
+### 用户相关接口
+
+#### 用户注册
+- **路径**: `POST /register`
+- **参数**: 
+  ```json
+  {
+    "username": "用户名",
+    "password": "密码",
+    "role": "角色"
+  }
+  ```
+- **返回**: 注册成功或失败信息
+
+#### 用户登录
+- **路径**: `POST /login`
+- **参数**:
+  ```json
+  {
+    "username": "用户名",
+    "password": "密码"
+  }
+  ```
+- **返回**: 登录成功或失败信息
+
+### 对话相关接口
+
+#### 初始化对话
+- **路径**: `POST /conversation/init`
+- **参数**:
+  ```json
+  {
+    "visitor_id": 访客ID
+  }
+  ```
+- **返回**:
+  ```json
+  {
+    "conversation_id": 对话ID,
+    "status": "对话状态"
+  }
+  ```
+
+#### 发送消息
+- **路径**: `POST /messages`
+- **参数**:
+  ```json
+  {
+    "conversation_id": 对话ID,
+    "content": "消息内容",
+    "sender_is_agent": 是否客服,
+    "sender_id": 发送者ID(客服必填,访客可省略或传0)
+  }
+  ```
+- **返回**: { "message": "创建消息成功" }
+
+#### 拉取消息
+- **路径**: `GET /messages?conversation_id=对话ID`
+- **返回**: 消息数组,按创建时间升序
+
+## 对话初始化逻辑详解
+
+当你调用对话初始化接口时,系统会执行以下步骤:
+
+1. **检查现有对话**: 系统会查找该访客是否已有未关闭的对话
+2. **复用或创建**: 
+   - 如果找到现有对话,直接返回该对话信息
+   - 如果没有找到,创建一个新的对话并返回
+
+这就像你去银行办事:
+- 如果之前有没办完的业务,继续办理
+- 如果没有,开一个新的业务单
+
+## 技术栈
+
+### 后端
+- **语言**: Go
+- **框架**: Gin (Web框架)
+- **数据库**: GORM (ORM)
+- **密码加密**: bcrypt
+
+### 前端
+- **框架**: Next.js
+- **语言**: TypeScript
+- **样式**: CSS
+
+## 开发环境设置
+
+### 后端启动
+```bash
+cd backend
+cp .env.example .env  # 按需修改数据库配置
+go mod tidy
+go run main.go
+```
+
+### 前端启动
+```bash
+cd frontend
+cp .env.local.example .env.local  # 可选:如需自定义 API 地址
+npm install
+npm run dev
+```
+
+## 注意事项
+
+1. 确保数据库连接配置正确(后端从环境变量读取)
+2. 用户密码会自动加密存储
+3. 对话状态包括: "open"(开放), "closed"(关闭)
+4. 消息发送者通过 `SenderIsAgent` 字段区分是访客还是客服
+
+### 后端环境变量
+在 `backend/.env` 中配置以下变量(主进程会自动加载):
+
+```
+DB_HOST=localhost
+DB_PORT=3306
+DB_USER=root
+DB_PASSWORD=your_password
+DB_NAME=CS
+```
+
+### 前端环境变量(可选)
+在 `frontend/.env.local` 中配置以下变量(不配置则使用默认值):
+
+```
+NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
+```
+
+说明:本地开发无需配置,已默认 `http://127.0.0.1:8080`。部署到生产环境时修改为实际后端地址(如 `https://api.yourdomain.com`)。
+
+## 更新日志
+
+详见 `doc/CHANGELOG.md` 文件
+

+ 5 - 0
backend/.env

@@ -0,0 +1,5 @@
+DB_HOST=localhost
+DB_PORT=3306
+DB_USER=root
+DB_PASSWORD=hkbjujk%h2eT
+DB_NAME=CS

+ 55 - 0
backend/controller/admin_controller.go

@@ -0,0 +1,55 @@
+package controller
+
+import (
+	"net/http"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// AdminController 负责处理管理员相关的 HTTP 请求。
+type AdminController struct {
+	authService *service.AuthService
+}
+
+// NewAdminController 创建 AdminController 实例。
+func NewAdminController(authService *service.AuthService) *AdminController {
+	return &AdminController{authService: authService}
+}
+
+type createAgentRequest struct {
+	Username string `json:"username"`
+	Password string `json:"password"`
+	Role     string `json:"role"`
+}
+
+// CreateAgent 处理创建客服或管理员账号的请求。
+func (a *AdminController) CreateAgent(c *gin.Context) {
+	var req createAgentRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	user, err := a.authService.CreateAgent(service.CreateAgentInput{
+		Username: req.Username,
+		Password: req.Password,
+		Role:     req.Role,
+	})
+	if err != nil {
+		switch err {
+		case service.ErrUsernameExists:
+			c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
+		default:
+			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message":  "创建成功",
+		"user_id":  user.ID,
+		"username": user.Username,
+		"role":     user.Role,
+	})
+}

+ 55 - 0
backend/controller/auth_controller.go

@@ -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": "退出成功"})
+}

+ 261 - 0
backend/controller/conversation_controller.go

@@ -0,0 +1,261 @@
+package controller
+
+import (
+	"net/http"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/2930134478/AI-CS/backend/utils"
+	"github.com/gin-gonic/gin"
+)
+
+// ConversationController 负责处理会话相关的 HTTP 请求。
+type ConversationController struct {
+	conversationService *service.ConversationService
+}
+
+// NewConversationController 创建 ConversationController 实例。
+func NewConversationController(conversationService *service.ConversationService) *ConversationController {
+	return &ConversationController{conversationService: conversationService}
+}
+
+type initConversationRequest struct {
+	VisitorID uint   `json:"visitor_id"`
+	Website   string `json:"website"`
+	Referrer  string `json:"referrer"`
+	Browser   string `json:"browser"`
+	OS        string `json:"os"`
+	Language  string `json:"language"`
+}
+
+type updateContactRequest struct {
+	Email *string `json:"email"`
+	Phone *string `json:"phone"`
+	Notes *string `json:"notes"`
+}
+
+// InitConversation 为访客初始化或恢复会话。
+func (cc *ConversationController) InitConversation(c *gin.Context) {
+	var req initConversationRequest
+	if err := c.ShouldBindJSON(&req); err != nil || req.VisitorID == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	browser := req.Browser
+	os := req.OS
+	if browser == "" || os == "" {
+		parsedBrowser, parsedOS := utils.ParseUserAgent(c.GetHeader("User-Agent"))
+		if browser == "" {
+			browser = parsedBrowser
+		}
+		if os == "" {
+			os = parsedOS
+		}
+	}
+
+	result, err := cc.conversationService.InitConversation(service.InitConversationInput{
+		VisitorID: req.VisitorID,
+		Website:   req.Website,
+		Referrer:  req.Referrer,
+		Browser:   browser,
+		OS:        os,
+		Language:  req.Language,
+		IPAddress: utils.GetClientIP(c),
+	})
+
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "创建对话失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"conversation_id": result.ConversationID,
+		"status":          result.Status,
+	})
+}
+
+// UpdateContactInfo 用于更新访客的联系信息。
+func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
+	id, err := parseUintParam(c, "id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
+		return
+	}
+
+	var req updateContactRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	if req.Email == nil && req.Phone == nil && req.Notes == nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "至少提供一个需要更新的字段"})
+		return
+	}
+
+	result, err := cc.conversationService.UpdateConversationContact(service.UpdateConversationContactInput{
+		ConversationID: uint(id),
+		Email:          req.Email,
+		Phone:          req.Phone,
+		Notes:          req.Notes,
+	})
+	if err != nil {
+		if err == service.ErrConversationNotFound {
+			c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
+		} else {
+			c.JSON(http.StatusInternalServerError, gin.H{"error": "更新失败"})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"email": result.Email,
+		"phone": result.Phone,
+		"notes": result.Notes,
+	})
+}
+
+// ListConversations 返回当前活跃会话的列表。
+func (cc *ConversationController) ListConversations(c *gin.Context) {
+	conversations, err := cc.conversationService.ListConversations()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"})
+		return
+	}
+
+	items := make([]gin.H, 0, len(conversations))
+	for _, conv := range conversations {
+		item := gin.H{
+			"id":           conv.ID,
+			"visitor_id":   conv.VisitorID,
+			"agent_id":     conv.AgentID,
+			"status":       conv.Status,
+			"created_at":   formatTimeValue(conv.CreatedAt),
+			"updated_at":   formatTimeValue(conv.UpdatedAt),
+			"unread_count": conv.UnreadCount,
+		}
+
+		// 添加 last_seen_at 字段(用于判断在线状态)
+		if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" {
+			item["last_seen_at"] = lastSeen
+		}
+
+		if conv.LastMessage != nil {
+			item["last_message"] = gin.H{
+				"id":              conv.LastMessage.ID,
+				"content":         conv.LastMessage.Content,
+				"sender_is_agent": conv.LastMessage.SenderIsAgent,
+				"message_type":    conv.LastMessage.MessageType,
+				"is_read":         conv.LastMessage.IsRead,
+				"read_at":         formatTimePointer(conv.LastMessage.ReadAt),
+				"created_at":      formatTimeValue(conv.LastMessage.CreatedAt),
+			}
+		}
+		items = append(items, item)
+	}
+
+	c.JSON(http.StatusOK, items)
+}
+
+// GetConversationDetail 返回会话的详细信息。
+func (cc *ConversationController) GetConversationDetail(c *gin.Context) {
+	id, err := parseUintParam(c, "id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
+		return
+	}
+
+	detail, err := cc.conversationService.GetConversationDetail(uint(id))
+	if err != nil {
+		if err == service.ErrConversationNotFound {
+			c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
+		} else {
+			c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
+		}
+		return
+	}
+
+	response := gin.H{
+		"id":           detail.ID,
+		"visitor_id":   detail.VisitorID,
+		"agent_id":     detail.AgentID,
+		"status":       detail.Status,
+		"website":      detail.Website,
+		"referrer":     detail.Referrer,
+		"browser":      detail.Browser,
+		"os":           detail.OS,
+		"language":     detail.Language,
+		"ip_address":   detail.IPAddress,
+		"location":     detail.Location,
+		"email":        detail.Email,
+		"phone":        detail.Phone,
+		"notes":        detail.Notes,
+		"created_at":   formatTimeValue(detail.CreatedAt),
+		"updated_at":   formatTimeValue(detail.UpdatedAt),
+		"unread_count": detail.UnreadCount,
+	}
+	if lastSeen := formatTimePointer(detail.LastSeen); lastSeen != "" {
+		response["last_seen_at"] = lastSeen
+	}
+	if detail.LastMessage != nil {
+		response["last_message"] = gin.H{
+			"id":              detail.LastMessage.ID,
+			"content":         detail.LastMessage.Content,
+			"sender_is_agent": detail.LastMessage.SenderIsAgent,
+			"message_type":    detail.LastMessage.MessageType,
+			"is_read":         detail.LastMessage.IsRead,
+			"read_at":         formatTimePointer(detail.LastMessage.ReadAt),
+			"created_at":      formatTimeValue(detail.LastMessage.CreatedAt),
+		}
+	}
+
+	c.JSON(http.StatusOK, response)
+}
+
+// SearchConversations 根据关键字进行会话的模糊搜索。
+func (cc *ConversationController) SearchConversations(c *gin.Context) {
+	query := c.Query("q")
+	if query == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
+		return
+	}
+
+	conversations, err := cc.conversationService.SearchConversations(query)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
+		return
+	}
+
+	items := make([]gin.H, 0, len(conversations))
+	for _, conv := range conversations {
+		item := gin.H{
+			"id":           conv.ID,
+			"visitor_id":   conv.VisitorID,
+			"agent_id":     conv.AgentID,
+			"status":       conv.Status,
+			"created_at":   formatTimeValue(conv.CreatedAt),
+			"updated_at":   formatTimeValue(conv.UpdatedAt),
+			"unread_count": conv.UnreadCount,
+		}
+
+		// 添加 last_seen_at 字段(用于判断在线状态)
+		if lastSeen := formatTimePointer(conv.LastSeenAt); lastSeen != "" {
+			item["last_seen_at"] = lastSeen
+		}
+
+		if conv.LastMessage != nil {
+			item["last_message"] = gin.H{
+				"id":              conv.LastMessage.ID,
+				"content":         conv.LastMessage.Content,
+				"sender_is_agent": conv.LastMessage.SenderIsAgent,
+				"message_type":    conv.LastMessage.MessageType,
+				"is_read":         conv.LastMessage.IsRead,
+				"read_at":         formatTimePointer(conv.LastMessage.ReadAt),
+				"created_at":      formatTimeValue(conv.LastMessage.CreatedAt),
+			}
+		}
+		items = append(items, item)
+	}
+
+	c.JSON(http.StatusOK, items)
+}

+ 29 - 0
backend/controller/helper.go

@@ -0,0 +1,29 @@
+package controller
+
+import (
+	"strconv"
+	"time"
+
+	"github.com/gin-gonic/gin"
+)
+
+const timeFormat = "2006-01-02T15:04:05Z07:00"
+
+// parseUintParam 将路径参数转换为 uint64。
+func parseUintParam(c *gin.Context, name string) (uint64, error) {
+	value := c.Param(name)
+	return strconv.ParseUint(value, 10, 64)
+}
+
+// formatTimeValue 按统一格式输出时间字符串。
+func formatTimeValue(t time.Time) string {
+	return t.Format(timeFormat)
+}
+
+// formatTimePointer 在指针为空时返回空字符串。
+func formatTimePointer(t *time.Time) string {
+	if t == nil {
+		return ""
+	}
+	return t.Format(timeFormat)
+}

+ 108 - 0
backend/controller/message_controller.go

@@ -0,0 +1,108 @@
+package controller
+
+import (
+	"log"
+	"net/http"
+	"strconv"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// MessageController 负责处理消息相关的 HTTP 请求。
+type MessageController struct {
+	messageService *service.MessageService
+}
+
+// NewMessageController 创建 MessageController 实例。
+func NewMessageController(messageService *service.MessageService) *MessageController {
+	return &MessageController{messageService: messageService}
+}
+
+type createMessageRequest struct {
+	ConversationID uint   `json:"conversation_id"`
+	Content        string `json:"content"`
+	SenderIsAgent  bool   `json:"sender_is_agent"`
+	SenderID       uint   `json:"sender_id"`
+}
+
+// CreateMessage 处理发送消息的请求。
+func (mc *MessageController) CreateMessage(c *gin.Context) {
+	var req createMessageRequest
+	if err := c.ShouldBindJSON(&req); err != nil || req.ConversationID == 0 || req.Content == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	_, err := mc.messageService.CreateMessage(service.CreateMessageInput{
+		ConversationID: req.ConversationID,
+		Content:        req.Content,
+		SenderID:       req.SenderID,
+		SenderIsAgent:  req.SenderIsAgent,
+	})
+	if err != nil {
+		log.Printf("❌ 创建消息失败: 对话ID=%d, 错误=%v", req.ConversationID, err)
+		switch err {
+		case service.ErrConversationClosed:
+			c.JSON(http.StatusBadRequest, gin.H{"error": "会话已关闭"})
+		case service.ErrConversationNotFound:
+			c.JSON(http.StatusBadRequest, gin.H{"error": "会话不存在"})
+		default:
+			c.JSON(http.StatusInternalServerError, gin.H{"error": "创建消息失败"})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "创建消息成功"})
+}
+
+// ListMessages 返回指定会话的消息列表。
+func (mc *MessageController) ListMessages(c *gin.Context) {
+	conversationIDStr := c.Query("conversation_id")
+	if conversationIDStr == "" {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不能为空"})
+		return
+	}
+
+	conversationID, err := strconv.ParseUint(conversationIDStr, 10, 64)
+	if err != nil || conversationID == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
+		return
+	}
+
+	messages, err := mc.messageService.ListMessages(uint(conversationID))
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, messages)
+}
+
+type markMessagesReadRequest struct {
+	ConversationID uint `json:"conversation_id"`
+	ReaderIsAgent  bool `json:"reader_is_agent"`
+}
+
+// MarkMessagesRead 将指定会话的消息标记为已读。
+func (mc *MessageController) MarkMessagesRead(c *gin.Context) {
+	var req markMessagesReadRequest
+	if err := c.ShouldBindJSON(&req); err != nil || req.ConversationID == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	result, err := mc.messageService.MarkMessagesRead(req.ConversationID, req.ReaderIsAgent)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "更新消息状态失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"updated":         len(result.MessageIDs),
+		"message_ids":     result.MessageIDs,
+		"conversation_id": result.ConversationID,
+		"unread_count":    result.UnreadCount,
+		"read_at":         formatTimeValue(result.ReadAt),
+	})
+}

+ 122 - 0
backend/controller/profile_controller.go

@@ -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
backend/go.mod

@@ -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
backend/go.sum

@@ -1,3 +1,5 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
 github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
 github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
 github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
@@ -6,31 +8,49 @@ github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZw
 github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
 github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
 github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
-github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
-github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
 github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
+github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
 github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
 github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
 github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
 github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
 github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
 github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
 github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
 github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
 github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
 github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
+github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
-github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
 github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
-github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
+github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
 github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -42,15 +62,19 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
 github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
 github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
+github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
-github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -69,8 +93,12 @@ golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
 google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
 google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
-rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
+gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
+gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
+gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

+ 28 - 0
backend/infra/db.go

@@ -0,0 +1,28 @@
+package infra
+
+import (
+	"fmt"
+	"os"
+
+	"gorm.io/driver/mysql"
+	"gorm.io/gorm"
+)
+
+// 初始化数据库连接(从环境变量读取配置)
+// 需要的环境变量:
+// DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME
+func NewDB() (*gorm.DB, error) {
+	host := os.Getenv("DB_HOST")
+	port := os.Getenv("DB_PORT")
+	user := os.Getenv("DB_USER")
+	password := os.Getenv("DB_PASSWORD")
+	name := os.Getenv("DB_NAME")
+
+	// 最小校验,避免使用硬编码默认值
+	if host == "" || port == "" || user == "" || name == "" {
+		return nil, fmt.Errorf("database env not set: require DB_HOST, DB_PORT, DB_USER, DB_NAME")
+	}
+
+	dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&loc=Local", user, password, host, port, name)
+	return gorm.Open(mysql.Open(dsn), &gorm.Config{})
+}

+ 121 - 0
backend/infra/storage.go

@@ -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
+}
+

+ 184 - 19
backend/main.go

@@ -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)
-	})
-
-	fmt.Println("Server is running on port 8080")
-	http.ListenAndServe(":8080", nil)
+
+	// 加载 .env 文件
+	// 获取当前工作目录
+	wd, _ := os.Getwd()
+	envPath := filepath.Join(wd, ".env")
+
+	// 检查文件是否存在
+	if _, err := os.Stat(envPath); os.IsNotExist(err) {
+		log.Printf("⚠️ .env 文件不存在: %s", envPath)
+		log.Println("当前工作目录:", wd)
+	} else {
+		log.Printf("✅ 找到 .env 文件: %s", envPath)
+	}
+
+	// 尝试加载 .env 文件
+	// 注意:godotenv 不支持 UTF-8 BOM,如果文件有 BOM 会失败
+	if err := godotenv.Load(envPath); err != nil {
+		log.Printf("❌ 加载 .env 文件失败: %v", err)
+		log.Println("⚠️ 提示:如果看到 'unexpected character' 错误,可能是文件编码问题(UTF-8 BOM)")
+		log.Println("   解决方法:用文本编辑器(如 VS Code)打开 .env,另存为 UTF-8 编码(不要 BOM)")
+		log.Println("将使用系统环境变量")
+	} else {
+		log.Println("✅ .env 文件加载成功")
+	}
+
+	db, err := infra.NewDB()
+	if err != nil {
+		log.Fatalf("数据库连接失败:%v", err)
+	}
+
+	//根据结构体定义自动创建更新表
+	if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}); err != nil {
+		log.Fatalf("自动创建表失败: %v", err)
+	}
+
+	userRepo := repository.NewUserRepository(db)
+	conversationRepo := repository.NewConversationRepository(db)
+	messageRepo := repository.NewMessageRepository(db)
+
+	// 初始化默认管理员账号(如果不存在)
+	initDefaultAdmin(userRepo)
+
+	//gin路由初始化
+	r := gin.Default()
+
+	//使用日志中间件
+	r.Use(middleware.Logger())
+
+	//跨域配置
+	r.Use(middleware.CORS())
+
+	// 初始化存储服务(本地存储)
+	// 存储目录:backend/uploads(相对于工作目录)
+	// 公共访问路径:/uploads(用于构建URL)
+	// 复用之前获取的工作目录 wd(已在第 56 行声明)
+	uploadDir := filepath.Join(wd, "uploads")
+	publicPath := "/uploads"
+	storageService := infra.NewLocalStorageService(uploadDir, publicPath)
+
+	// 初始化服务层
+	authService := service.NewAuthService(userRepo)
+	conversationService := service.NewConversationService(conversationRepo, messageRepo)
+	profileService := service.NewProfileService(userRepo, storageService)
+
+	// 声明 Hub 变量(用于在回调函数中访问)
+	var wsHub *websocket.Hub
+
+	// 创建 WebSocket Hub,设置回调函数来处理客户端连接/断开事件
+	// 使用闭包来访问 conversationService 和 wsHub
+	onConnect := func(conversationID uint, isVisitor bool, visitorCount int) {
+		if isVisitor {
+			if err := conversationService.UpdateVisitorOnlineStatus(conversationID, true); err != nil {
+				log.Printf("更新访客在线状态失败: %v", err)
+				return
+			}
+			// 广播状态更新到所有客服端(不管连接到哪个对话)
+			wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
+				"conversation_id": conversationID,
+				"is_online":       true,
+				"visitor_count":   visitorCount,
+			})
+		}
+	}
+
+	onDisconnect := func(conversationID uint, isVisitor bool, visitorCount int) {
+		if isVisitor {
+			if visitorCount == 0 {
+				if err := conversationService.UpdateVisitorOnlineStatus(conversationID, false); err != nil {
+					log.Printf("更新访客离线状态失败: %v", err)
+					return
+				}
+				// 广播状态更新到所有客服端(不管连接到哪个对话)
+				wsHub.BroadcastToAllAgents("visitor_status_update", map[string]interface{}{
+					"conversation_id": conversationID,
+					"is_online":       false,
+					"visitor_count":   0,
+				})
+			} else {
+				// 还有访客在线,只更新最后活跃时间
+				if err := conversationService.UpdateLastSeenAt(conversationID); err != nil {
+					log.Printf("更新最后活跃时间失败: %v", err)
+					return
+				}
+			}
+		}
+	}
+
+	// 创建 Hub(回调函数通过闭包访问 wsHub)
+	wsHub = websocket.NewHub(onConnect, onDisconnect)
+	go wsHub.Run() // 启动 Hub(在后台运行)
+
+	messageService := service.NewMessageService(conversationRepo, messageRepo, wsHub)
+
+	// 初始化控制器
+	authController := controller.NewAuthController(authService)
+	conversationController := controller.NewConversationController(conversationService)
+	messageController := controller.NewMessageController(messageService)
+	adminController := controller.NewAdminController(authService)
+	profileController := controller.NewProfileController(profileService)
+
+	appRouter.RegisterRoutes(
+		r,
+		appRouter.ControllerSet{
+			Auth:         authController,
+			Conversation: conversationController,
+			Message:      messageController,
+			Admin:        adminController,
+			Profile:      profileController,
+		},
+		websocket.HandleWebSocket(wsHub),
+	)
+
+	// 配置静态文件服务(用于访问上传的头像等文件)
+	// 静态文件路径:/uploads -> backend/uploads
+	r.Static("/uploads", uploadDir)
+
+	//启动服务器)
+	log.Println("🚀 服务器启动成功,监听 :8080")
+	log.Println("📡 WebSocket 服务已启动,路径: /ws?conversation_id=<对话ID>")
+	r.Run(":8080")
 }

+ 28 - 0
backend/middleware/middleware.go

@@ -0,0 +1,28 @@
+package middleware
+
+import (
+	"log"
+	"time"
+
+	"github.com/gin-contrib/cors"
+	"github.com/gin-gonic/gin"
+)
+
+func Logger() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		start := time.Now()
+		//继续调用后续的中间件处理函数
+		c.Next()
+		log.Printf("[GIN] %s %s %d %s",
+			c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(start))
+	}
+}
+
+func CORS() gin.HandlerFunc {
+	return cors.New(cors.Config{
+		AllowOrigins:     []string{"*"},
+		AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
+		AllowHeaders:     []string{"Origin", "Content-Type", "Accept"},
+		AllowCredentials: false,
+	})
+}

+ 52 - 0
backend/models/user.go

@@ -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"`
+}

+ 113 - 0
backend/repository/conversation_repository.go

@@ -0,0 +1,113 @@
+package repository
+
+import (
+	"errors"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// ConversationRepository 封装与会话相关的数据库操作。
+type ConversationRepository struct {
+	db *gorm.DB
+}
+
+// NewConversationRepository 创建会话仓库实例。
+func NewConversationRepository(db *gorm.DB) *ConversationRepository {
+	return &ConversationRepository{db: db}
+}
+
+// FindOpenByVisitorID 查询访客当前未关闭的会话。
+func (r *ConversationRepository) FindOpenByVisitorID(visitorID uint) (*models.Conversation, error) {
+	var conv models.Conversation
+	err := r.db.Where("visitor_id = ? AND status != ?", visitorID, "closed").
+		Order("created_at desc").
+		First(&conv).Error
+	if err != nil {
+		return nil, err
+	}
+	return &conv, nil
+}
+
+// Create 创建新的会话记录。
+func (r *ConversationRepository) Create(conv *models.Conversation) error {
+	return r.db.Create(conv).Error
+}
+
+// UpdateFields 更新会话的指定字段。
+func (r *ConversationRepository) UpdateFields(id uint, values map[string]interface{}) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return r.db.Model(&models.Conversation{}).Where("id = ?", id).Updates(values).Error
+}
+
+// GetByID 根据主键查询会话。
+func (r *ConversationRepository) GetByID(id uint) (*models.Conversation, error) {
+	var conv models.Conversation
+	if err := r.db.First(&conv, id).Error; err != nil {
+		return nil, err
+	}
+	return &conv, nil
+}
+
+// ListActive 返回所有未关闭的会话。
+func (r *ConversationRepository) ListActive() ([]models.Conversation, error) {
+	var conversations []models.Conversation
+	if err := r.db.Where("status != ?", "closed").
+		Order("updated_at desc").
+		Find(&conversations).Error; err != nil {
+		return nil, err
+	}
+	return conversations, nil
+}
+
+// ListByIDs 根据多个 ID 批量查询会话。
+func (r *ConversationRepository) ListByIDs(ids []uint) ([]models.Conversation, error) {
+	if len(ids) == 0 {
+		return []models.Conversation{}, nil
+	}
+	var conversations []models.Conversation
+	if err := r.db.Where("id IN ? AND status != ?", ids, "closed").
+		Order("updated_at desc").
+		Find(&conversations).Error; err != nil {
+		return nil, err
+	}
+	return conversations, nil
+}
+
+// SearchByIDOrVisitorLike 根据会话 ID 或访客 ID 进行模糊搜索。
+func (r *ConversationRepository) SearchByIDOrVisitorLike(pattern string) ([]models.Conversation, error) {
+	var conversations []models.Conversation
+	if err := r.db.Where("CAST(id AS CHAR) LIKE ? OR CAST(visitor_id AS CHAR) LIKE ?", pattern, pattern).
+		Find(&conversations).Error; err != nil {
+		return nil, err
+	}
+	return conversations, nil
+}
+
+// AssignAgent 为会话分配客服。
+func (r *ConversationRepository) AssignAgent(conversationID uint, agentID uint) error {
+	result := r.db.Model(&models.Conversation{}).
+		Where("id = ?", conversationID).
+		Updates(map[string]interface{}{
+			"agent_id": agentID,
+		})
+	if result.Error != nil {
+		return result.Error
+	}
+	if result.RowsAffected == 0 {
+		return gorm.ErrRecordNotFound
+	}
+	return nil
+}
+
+// UpdateStatus 更新会话状态。
+func (r *ConversationRepository) UpdateStatus(conversationID uint, status string) error {
+	if status == "" {
+		return errors.New("status cannot be empty")
+	}
+	return r.db.Model(&models.Conversation{}).
+		Where("id = ?", conversationID).
+		Update("status", status).Error
+}

+ 96 - 0
backend/repository/message_repository.go

@@ -0,0 +1,96 @@
+package repository
+
+import (
+	"errors"
+	"time"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// MessageRepository 封装与消息相关的数据库操作。
+type MessageRepository struct {
+	db *gorm.DB
+}
+
+// NewMessageRepository 创建消息仓库实例。
+func NewMessageRepository(db *gorm.DB) *MessageRepository {
+	return &MessageRepository{db: db}
+}
+
+// Create 新建一条消息记录。
+func (r *MessageRepository) Create(message *models.Message) error {
+	return r.db.Create(message).Error
+}
+
+// ListByConversationID 按时间顺序查询会话中的全部消息。
+func (r *MessageRepository) ListByConversationID(conversationID uint) ([]models.Message, error) {
+	var messages []models.Message
+	if err := r.db.Where("conversation_id = ?", conversationID).Order("created_at asc").Find(&messages).Error; err != nil {
+		return nil, err
+	}
+	return messages, nil
+}
+
+// LatestByConversationID 查询会话中最新的一条消息。
+func (r *MessageRepository) LatestByConversationID(conversationID uint) (*models.Message, error) {
+	var message models.Message
+	if err := r.db.Where("conversation_id = ?", conversationID).
+		Order("created_at desc").
+		First(&message).Error; err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, nil
+		}
+		return nil, err
+	}
+	return &message, nil
+}
+
+// CountUnreadBySender 统计指定发送方的未读消息数量。
+func (r *MessageRepository) CountUnreadBySender(conversationID uint, senderIsAgent bool) (int64, error) {
+	var count int64
+	if err := r.db.Model(&models.Message{}).
+		Where("conversation_id = ? AND sender_is_agent = ? AND is_read = ?", conversationID, senderIsAgent, false).
+		Count(&count).Error; err != nil {
+		return 0, err
+	}
+	return count, nil
+}
+
+// FindConversationIDsByContent 根据关键字查询包含该内容的会话 ID。
+func (r *MessageRepository) FindConversationIDsByContent(keyword string) ([]uint, error) {
+	var ids []uint
+	if err := r.db.Model(&models.Message{}).
+		Where("content LIKE ?", keyword).
+		Pluck("conversation_id", &ids).Error; err != nil {
+		return nil, err
+	}
+	return ids, nil
+}
+
+// MarkMessagesRead 将指定发送方的未读消息标记为已读,并返回受影响的消息 ID 及时间。
+func (r *MessageRepository) MarkMessagesRead(conversationID uint, senderIsAgent bool) ([]uint, int64, time.Time, error) {
+	var messageIDs []uint
+	if err := r.db.Model(&models.Message{}).
+		Where("conversation_id = ? AND sender_is_agent = ? AND is_read = ?", conversationID, senderIsAgent, false).
+		Pluck("id", &messageIDs).Error; err != nil {
+		return nil, 0, time.Time{}, err
+	}
+	if len(messageIDs) == 0 {
+		return []uint{}, 0, time.Time{}, nil
+	}
+	now := time.Now()
+	if err := r.db.Model(&models.Message{}).
+		Where("id IN ?", messageIDs).
+		Updates(map[string]interface{}{
+			"is_read": true,
+			"read_at": now,
+		}).Error; err != nil {
+		return nil, 0, time.Time{}, err
+	}
+	remaining, err := r.CountUnreadBySender(conversationID, senderIsAgent)
+	if err != nil {
+		return nil, 0, time.Time{}, err
+	}
+	return messageIDs, remaining, now, nil
+}

+ 44 - 0
backend/repository/user_repository.go

@@ -0,0 +1,44 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// UserRepository 封装与用户相关的数据库操作。
+type UserRepository struct {
+	db *gorm.DB
+}
+
+// NewUserRepository 创建用户仓库实例。
+func NewUserRepository(db *gorm.DB) *UserRepository {
+	return &UserRepository{db: db}
+}
+
+// FindByUsername 根据用户名查询用户。
+func (r *UserRepository) FindByUsername(username string) (*models.User, error) {
+	var user models.User
+	if err := r.db.Where("username = ?", username).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return &user, nil
+}
+
+// Create 创建新的用户记录。
+func (r *UserRepository) Create(user *models.User) error {
+	return r.db.Create(user).Error
+}
+
+// GetByID 根据ID查询用户。
+func (r *UserRepository) GetByID(id uint) (*models.User, error) {
+	var user models.User
+	if err := r.db.Where("id = ?", id).First(&user).Error; err != nil {
+		return nil, err
+	}
+	return &user, nil
+}
+
+// UpdateFields 更新用户的部分字段。
+func (r *UserRepository) UpdateFields(id uint, updates map[string]interface{}) error {
+	return r.db.Model(&models.User{}).Where("id = ?", id).Updates(updates).Error
+}

+ 45 - 0
backend/router/router.go

@@ -0,0 +1,45 @@
+package router
+
+import (
+	"github.com/2930134478/AI-CS/backend/controller"
+	"github.com/gin-gonic/gin"
+)
+
+// ControllerSet 用于收集路由需要的控制器集合。
+type ControllerSet struct {
+	Auth         *controller.AuthController
+	Conversation *controller.ConversationController
+	Message      *controller.MessageController
+	Admin        *controller.AdminController
+	Profile      *controller.ProfileController
+}
+
+// RegisterRoutes 注册 HTTP 路由及对应的处理函数。
+func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.HandlerFunc) {
+	// Auth
+	r.POST("/login", controllers.Auth.Login)
+	r.POST("/logout", controllers.Auth.Logout)
+
+	// Conversation
+	r.POST("/conversation/init", controllers.Conversation.InitConversation)
+	r.GET("/conversations", controllers.Conversation.ListConversations)
+	r.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
+	r.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
+	r.GET("/conversations/search", controllers.Conversation.SearchConversations)
+
+	// Message
+	r.POST("/messages", controllers.Message.CreateMessage)
+	r.GET("/messages", controllers.Message.ListMessages)
+	r.PUT("/messages/read", controllers.Message.MarkMessagesRead)
+
+	// Admin
+	r.POST("/admin/users", controllers.Admin.CreateAgent)
+
+	// Profile(个人资料)
+	r.GET("/agent/profile/:user_id", controllers.Profile.GetProfile)
+	r.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile)
+	r.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar)
+
+	// WebSocket
+	r.GET("/ws", wsHandler)
+}

+ 77 - 0
backend/service/auth_service.go

@@ -0,0 +1,77 @@
+package service
+
+import (
+	"errors"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+// ErrInvalidCredentials indicates login attempt failed.
+var (
+	ErrInvalidCredentials = errors.New("invalid username or password")
+	ErrUsernameExists     = errors.New("username already exists")
+)
+
+// AuthService 负责认证相关的业务逻辑。
+type AuthService struct {
+	users *repository.UserRepository
+}
+
+// NewAuthService 创建 AuthService 实例。
+func NewAuthService(users *repository.UserRepository) *AuthService {
+	return &AuthService{users: users}
+}
+
+// Login 校验账号密码并返回用户信息。
+func (s *AuthService) Login(username, password string) (*models.User, error) {
+	user, err := s.users.FindByUsername(username)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, ErrInvalidCredentials
+		}
+		return nil, err
+	}
+
+	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil {
+		return nil, ErrInvalidCredentials
+	}
+
+	return user, nil
+}
+
+func (s *AuthService) CreateAgent(input CreateAgentInput) (*models.User, error) {
+	if input.Username == "" || input.Password == "" {
+		return nil, errors.New("username and password are required")
+	}
+
+	if _, err := s.users.FindByUsername(input.Username); err == nil {
+		return nil, ErrUsernameExists
+	} else if !errors.Is(err, gorm.ErrRecordNotFound) {
+		return nil, err
+	}
+
+	hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
+	if err != nil {
+		return nil, err
+	}
+
+	role := input.Role
+	if role == "" {
+		role = "agent"
+	}
+
+	user := &models.User{
+		Username: input.Username,
+		Password: string(hash),
+		Role:     role,
+	}
+
+	if err := s.users.Create(user); err != nil {
+		return nil, err
+	}
+
+	return user, nil
+}

+ 324 - 0
backend/service/conversation_service.go

@@ -0,0 +1,324 @@
+package service
+
+import (
+	"errors"
+	"strings"
+	"time"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"gorm.io/gorm"
+)
+
+// ConversationService 负责会话领域的业务编排。
+type ConversationService struct {
+	conversations *repository.ConversationRepository
+	messages      *repository.MessageRepository
+}
+
+// NewConversationService 创建 ConversationService 实例。
+func NewConversationService(
+	conversations *repository.ConversationRepository,
+	messages *repository.MessageRepository,
+) *ConversationService {
+	return &ConversationService{
+		conversations: conversations,
+		messages:      messages,
+	}
+}
+
+// InitConversation 为访客创建或恢复会话。
+func (s *ConversationService) InitConversation(input InitConversationInput) (*InitConversationResult, error) {
+	var (
+		conv *models.Conversation
+		err  error
+	)
+
+	conv, err = s.conversations.FindOpenByVisitorID(input.VisitorID)
+	isNewConversation := false
+
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			now := time.Now()
+			conv = &models.Conversation{
+				VisitorID:  input.VisitorID,
+				Status:     "open",
+				Website:    input.Website,
+				Referrer:   input.Referrer,
+				Browser:    input.Browser,
+				OS:         input.OS,
+				Language:   input.Language,
+				IPAddress:  input.IPAddress,
+				LastSeenAt: &now,
+			}
+			if err := s.conversations.Create(conv); err != nil {
+				return nil, err
+			}
+			isNewConversation = true
+		} else {
+			return nil, err
+		}
+	} else {
+		now := time.Now()
+		updates := map[string]interface{}{
+			"last_seen_at": &now,
+		}
+		if input.Website != "" && conv.Website == "" {
+			updates["website"] = input.Website
+		}
+		if input.Referrer != "" && conv.Referrer == "" {
+			updates["referrer"] = input.Referrer
+		}
+		if input.Browser != "" && conv.Browser == "" {
+			updates["browser"] = input.Browser
+		}
+		if input.OS != "" && conv.OS == "" {
+			updates["os"] = input.OS
+		}
+		if input.Language != "" && conv.Language == "" {
+			updates["language"] = input.Language
+		}
+		if input.IPAddress != "" && conv.IPAddress == "" {
+			updates["ip_address"] = input.IPAddress
+		}
+		if err := s.conversations.UpdateFields(conv.ID, updates); err != nil {
+			return nil, err
+		}
+	}
+
+	if isNewConversation {
+		now := time.Now()
+		message := &models.Message{
+			ConversationID: conv.ID,
+			SenderID:       0,
+			SenderIsAgent:  false,
+			Content:        "Visitor opened the page",
+			MessageType:    "system_message",
+			IsRead:         true,
+			ReadAt:         &now,
+		}
+		if input.Website != "" {
+			message.Content += " [" + input.Website + "]"
+		}
+		if err := s.messages.Create(message); err != nil {
+			return nil, err
+		}
+
+		if input.Referrer != "" {
+			readTime := time.Now()
+			referrerMsg := &models.Message{
+				ConversationID: conv.ID,
+				SenderID:       0,
+				SenderIsAgent:  false,
+				Content:        "Visitor came from [" + input.Referrer + "]",
+				MessageType:    "system_message",
+				IsRead:         true,
+				ReadAt:         &readTime,
+			}
+			if err := s.messages.Create(referrerMsg); err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	return &InitConversationResult{
+		ConversationID: conv.ID,
+		Status:         conv.Status,
+	}, nil
+}
+
+// UpdateConversationContact 更新访客的联系信息(邮箱、电话、备注)。
+func (s *ConversationService) UpdateConversationContact(input UpdateConversationContactInput) (*ConversationDetail, error) {
+	if _, err := s.conversations.GetByID(input.ConversationID); err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, ErrConversationNotFound
+		}
+		return nil, err
+	}
+
+	updates := map[string]interface{}{}
+
+	if input.Email != nil {
+		updates["email"] = strings.TrimSpace(*input.Email)
+	}
+	if input.Phone != nil {
+		updates["phone"] = strings.TrimSpace(*input.Phone)
+	}
+	if input.Notes != nil {
+		updates["notes"] = strings.TrimSpace(*input.Notes)
+	}
+
+	if err := s.conversations.UpdateFields(input.ConversationID, updates); err != nil {
+		return nil, err
+	}
+
+	return s.GetConversationDetail(input.ConversationID)
+}
+
+func (s *ConversationService) buildSummary(conv models.Conversation) (ConversationSummary, error) {
+	var lastSeen *time.Time
+	if conv.LastSeenAt != nil {
+		lastSeen = conv.LastSeenAt
+	}
+	summary := ConversationSummary{
+		ID:         conv.ID,
+		VisitorID:  conv.VisitorID,
+		AgentID:    conv.AgentID,
+		Status:     conv.Status,
+		CreatedAt:  conv.CreatedAt,
+		UpdatedAt:  conv.UpdatedAt,
+		LastSeenAt: lastSeen, // 添加 last_seen_at 字段
+	}
+
+	if message, err := s.messages.LatestByConversationID(conv.ID); err == nil && message != nil {
+		var readAt *time.Time
+		if message.ReadAt != nil {
+			readAt = message.ReadAt
+		}
+		summary.LastMessage = &LastMessageSummary{
+			ID:            message.ID,
+			Content:       message.Content,
+			SenderIsAgent: message.SenderIsAgent,
+			MessageType:   message.MessageType,
+			IsRead:        message.IsRead,
+			ReadAt:        readAt,
+			CreatedAt:     message.CreatedAt,
+		}
+	}
+
+	if count, err := s.messages.CountUnreadBySender(conv.ID, false); err == nil {
+		summary.UnreadCount = count
+	}
+
+	return summary, nil
+}
+
+// ListConversations 返回当前活跃会话的摘要信息。
+func (s *ConversationService) ListConversations() ([]ConversationSummary, error) {
+	conversations, err := s.conversations.ListActive()
+	if err != nil {
+		return nil, err
+	}
+
+	result := make([]ConversationSummary, 0, len(conversations))
+	for _, conv := range conversations {
+		summary, err := s.buildSummary(conv)
+		if err != nil {
+			return nil, err
+		}
+		result = append(result, summary)
+	}
+	return result, nil
+}
+
+// GetConversationDetail 获取指定会话的详细信息。
+func (s *ConversationService) GetConversationDetail(id uint) (*ConversationDetail, error) {
+	conv, err := s.conversations.GetByID(id)
+	if err != nil {
+		return nil, err
+	}
+
+	summary, err := s.buildSummary(*conv)
+	if err != nil {
+		return nil, err
+	}
+
+	var lastSeen *time.Time
+	if conv.LastSeenAt != nil {
+		lastSeen = conv.LastSeenAt
+	}
+
+	return &ConversationDetail{
+		ConversationSummary: summary,
+		Website:             conv.Website,
+		Referrer:            conv.Referrer,
+		Browser:             conv.Browser,
+		OS:                  conv.OS,
+		Language:            conv.Language,
+		IPAddress:           conv.IPAddress,
+		Location:            conv.Location,
+		Email:               conv.Email,
+		Phone:               conv.Phone,
+		Notes:               conv.Notes,
+		LastSeen:            lastSeen,
+	}, nil
+}
+
+// SearchConversations 根据关键字检索会话摘要。
+func (s *ConversationService) SearchConversations(query string) ([]ConversationSummary, error) {
+	pattern := "%" + query + "%"
+
+	idSet := map[uint]struct{}{}
+
+	if ids, err := s.messages.FindConversationIDsByContent(pattern); err == nil {
+		for _, id := range ids {
+			idSet[id] = struct{}{}
+		}
+	} else {
+		return nil, err
+	}
+
+	if convs, err := s.conversations.SearchByIDOrVisitorLike(pattern); err == nil {
+		for _, conv := range convs {
+			idSet[conv.ID] = struct{}{}
+		}
+	} else {
+		return nil, err
+	}
+
+	if len(idSet) == 0 {
+		return []ConversationSummary{}, nil
+	}
+
+	ids := make([]uint, 0, len(idSet))
+	for id := range idSet {
+		ids = append(ids, id)
+	}
+
+	conversations, err := s.conversations.ListByIDs(ids)
+	if err != nil {
+		return nil, err
+	}
+
+	result := make([]ConversationSummary, 0, len(conversations))
+	for _, conv := range conversations {
+		summary, err := s.buildSummary(conv)
+		if err != nil {
+			return nil, err
+		}
+		result = append(result, summary)
+	}
+	return result, nil
+}
+
+// UpdateVisitorOnlineStatus 更新访客在线状态和最后活跃时间。
+// 当 isOnline 为 true 时,更新 last_seen_at 为当前时间,并确保状态为 "open"。
+// 当 isOnline 为 false 时,仅更新 last_seen_at 为当前时间,不改变状态。
+func (s *ConversationService) UpdateVisitorOnlineStatus(conversationID uint, isOnline bool) error {
+	now := time.Now()
+	updates := map[string]interface{}{
+		"last_seen_at": &now,
+	}
+
+	// 如果标记为在线,确保状态为 "open"(但不要将已关闭的会话重新打开)
+	if isOnline {
+		conv, err := s.conversations.GetByID(conversationID)
+		if err != nil {
+			return err
+		}
+		// 只有当前状态不是 "closed" 时,才更新为 "open"
+		if conv.Status != "closed" {
+			updates["status"] = "open"
+		}
+	}
+
+	return s.conversations.UpdateFields(conversationID, updates)
+}
+
+// UpdateLastSeenAt 更新访客的最后活跃时间。
+func (s *ConversationService) UpdateLastSeenAt(conversationID uint) error {
+	now := time.Now()
+	return s.conversations.UpdateFields(conversationID, map[string]interface{}{
+		"last_seen_at": &now,
+	})
+}

+ 113 - 0
backend/service/message_service.go

@@ -0,0 +1,113 @@
+package service
+
+import (
+	"errors"
+	"log"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"gorm.io/gorm"
+)
+
+// ErrConversationClosed indicates operations are attempted on a closed conversation.
+var (
+	// ErrConversationClosed 表示会话已关闭,不能继续发送消息。
+	ErrConversationClosed = errors.New("conversation is closed")
+	// ErrConversationNotFound 表示未找到指定的会话记录。
+	ErrConversationNotFound = gorm.ErrRecordNotFound
+)
+
+// MessageService 负责消息领域的业务处理。
+type MessageService struct {
+	conversations *repository.ConversationRepository
+	messages      *repository.MessageRepository
+	hub           BroadcastHub
+}
+
+// NewMessageService 创建 MessageService 实例。
+func NewMessageService(
+	conversations *repository.ConversationRepository,
+	messages *repository.MessageRepository,
+	hub BroadcastHub,
+) *MessageService {
+	return &MessageService{
+		conversations: conversations,
+		messages:      messages,
+		hub:           hub,
+	}
+}
+
+// CreateMessage 创建消息并通过 WebSocket 广播。
+func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Message, error) {
+	conv, err := s.conversations.GetByID(input.ConversationID)
+	if err != nil {
+		return nil, err
+	}
+
+	if conv.Status == "closed" {
+		return nil, ErrConversationClosed
+	}
+
+	if input.SenderIsAgent && input.SenderID == 0 {
+		return nil, errors.New("sender_id is required for agent messages")
+	}
+
+	message := &models.Message{
+		ConversationID: input.ConversationID,
+		SenderID:       input.SenderID,
+		SenderIsAgent:  input.SenderIsAgent,
+		Content:        input.Content,
+		MessageType:    "user_message",
+		IsRead:         false,
+	}
+
+	if err := s.messages.Create(message); err != nil {
+		return nil, err
+	}
+
+	if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
+		"updated_at": message.CreatedAt,
+	}); err != nil {
+		return nil, err
+	}
+
+	if s.hub != nil {
+		s.hub.BroadcastMessage(message.ConversationID, "new_message", message)
+	} else {
+		log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID)
+	}
+
+	return message, nil
+}
+
+// ListMessages 返回会话内的全部消息。
+func (s *MessageService) ListMessages(conversationID uint) ([]models.Message, error) {
+	return s.messages.ListByConversationID(conversationID)
+}
+
+// MarkMessagesRead 将消息标记为已读并通知监听方。
+func (s *MessageService) MarkMessagesRead(conversationID uint, readerIsAgent bool) (*MarkMessagesReadResult, error) {
+	messageIDs, unreadRemaining, readAt, err := s.messages.MarkMessagesRead(conversationID, !readerIsAgent)
+	if err != nil {
+		return nil, err
+	}
+
+	result := &MarkMessagesReadResult{
+		ConversationID: conversationID,
+		MessageIDs:     messageIDs,
+		UnreadCount:    unreadRemaining,
+		ReadAt:         readAt,
+	}
+
+	if s.hub != nil && len(messageIDs) > 0 {
+		s.hub.BroadcastMessage(conversationID, "messages_read", map[string]interface{}{
+			"message_ids":     messageIDs,
+			"reader_is_agent": readerIsAgent,
+			"read_at":         readAt,
+			"unread_count":    unreadRemaining,
+			"conversation_id": conversationID, // 确保 payload 中也包含 conversation_id
+		})
+	}
+
+	return result, nil
+}

+ 108 - 0
backend/service/profile_service.go

@@ -0,0 +1,108 @@
+package service
+
+import (
+	"errors"
+	"io"
+
+	"github.com/2930134478/AI-CS/backend/infra"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"gorm.io/gorm"
+)
+
+// ProfileService 负责个人资料相关的业务逻辑。
+type ProfileService struct {
+	users   *repository.UserRepository
+	storage infra.StorageService
+}
+
+// NewProfileService 创建 ProfileService 实例。
+func NewProfileService(users *repository.UserRepository, storage infra.StorageService) *ProfileService {
+	return &ProfileService{
+		users:   users,
+		storage: storage,
+	}
+}
+
+// GetProfile 获取用户的个人资料。
+func (s *ProfileService) GetProfile(userID uint) (*ProfileResult, error) {
+	user, err := s.users.GetByID(userID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("用户不存在")
+		}
+		return nil, err
+	}
+
+	return &ProfileResult{
+		ID:        user.ID,
+		Username:  user.Username,
+		Role:      user.Role,
+		AvatarURL: user.AvatarURL,
+		Nickname:  user.Nickname,
+		Email:     user.Email,
+	}, nil
+}
+
+// UpdateProfile 更新用户的个人资料。
+func (s *ProfileService) UpdateProfile(input UpdateProfileInput) (*ProfileResult, error) {
+	// 检查用户是否存在
+	if _, err := s.users.GetByID(input.UserID); err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("用户不存在")
+		}
+		return nil, err
+	}
+
+	updates := make(map[string]interface{})
+	if input.Nickname != nil {
+		updates["nickname"] = *input.Nickname
+	}
+	if input.Email != nil {
+		updates["email"] = *input.Email
+	}
+
+	if len(updates) > 0 {
+		if err := s.users.UpdateFields(input.UserID, updates); err != nil {
+			return nil, err
+		}
+	}
+
+	return s.GetProfile(input.UserID)
+}
+
+// UploadAvatar 上传用户头像。
+func (s *ProfileService) UploadAvatar(userID uint, file io.Reader, filename string) (*ProfileResult, error) {
+	// 检查用户是否存在
+	user, err := s.users.GetByID(userID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("用户不存在")
+		}
+		return nil, err
+	}
+
+	// 如果已有头像,删除旧头像
+	if user.AvatarURL != "" {
+		if err := s.storage.DeleteFile(user.AvatarURL); err != nil {
+			// 删除失败不阻止更新,只记录警告
+			// log.Printf("删除旧头像失败: %v", err)
+		}
+	}
+
+	// 保存新头像
+	avatarURL, err := s.storage.SaveAvatar(userID, file, filename)
+	if err != nil {
+		return nil, err
+	}
+
+	// 更新用户头像URL
+	updates := map[string]interface{}{
+		"avatar_url": avatarURL,
+	}
+	if err := s.users.UpdateFields(userID, updates); err != nil {
+		return nil, err
+	}
+
+	return s.GetProfile(userID)
+}
+

+ 113 - 0
backend/service/types.go

@@ -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"`
+}

BIN
backend/uploads/avatars/user_4_1763359157.png


+ 59 - 0
backend/utils/request.go

@@ -0,0 +1,59 @@
+package utils
+
+import (
+	"strings"
+
+	"github.com/gin-gonic/gin"
+)
+
+// GetClientIP 获取客户端 IP 地址(考虑代理情况)
+func GetClientIP(c *gin.Context) string {
+	// 优先从 X-Forwarded-For 获取(适用于代理/负载均衡)
+	ip := strings.TrimSpace(c.GetHeader("X-Forwarded-For"))
+	if ip != "" {
+		return ip
+	}
+	// 从 X-Real-IP 获取
+	ip = strings.TrimSpace(c.GetHeader("X-Real-IP"))
+	if ip != "" {
+		return ip
+	}
+	return c.ClientIP()
+}
+
+// ParseUserAgent 从 User-Agent 字符串中解析浏览器和操作系统
+func ParseUserAgent(userAgent string) (browser string, os string) {
+	browser = "Unknown"
+	os = "Unknown"
+
+	ua := strings.ToLower(userAgent)
+	if ua == "" {
+		return
+	}
+
+	switch {
+	case strings.Contains(ua, "edg/"):
+		browser = "Edge"
+	case strings.Contains(ua, "chrome/"):
+		browser = "Chrome"
+	case strings.Contains(ua, "firefox/"):
+		browser = "Firefox"
+	case strings.Contains(ua, "safari/"):
+		browser = "Safari"
+	}
+
+	switch {
+	case strings.Contains(ua, "windows nt"):
+		os = "Windows"
+	case strings.Contains(ua, "mac os x") || strings.Contains(ua, "macintosh"):
+		os = "macOS"
+	case strings.Contains(ua, "android"):
+		os = "Android"
+	case strings.Contains(ua, "iphone") || strings.Contains(ua, "ipad"):
+		os = "iOS"
+	case strings.Contains(ua, "linux"):
+		os = "Linux"
+	}
+
+	return
+}

+ 133 - 0
backend/websocket/client.go

@@ -0,0 +1,133 @@
+package websocket
+
+import (
+	"encoding/json"
+	"log"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+const (
+	// 客户端发送 ping 的最大等待时间
+	writeWait = 10 * time.Second
+
+	// 从客户端读取 pong 的最大等待时间
+	pongWait = 60 * time.Second
+
+	// 发送 ping 的频率(必须小于 pongWait)
+	pingPeriod = (pongWait * 9) / 10
+
+	// 最大消息大小
+	maxMessageSize = 512 * 1024 // 512KB
+)
+
+// Client 是一个 WebSocket 客户端
+type Client struct {
+	hub *Hub
+
+	// WebSocket 连接
+	conn *websocket.Conn
+
+	// 发送消息的通道
+	send chan *Message
+
+	// 对话ID(这个客户端属于哪个对话)
+	conversationID uint
+
+	// 是否是访客(true 表示访客,false 表示客服)
+	isVisitor bool
+}
+
+// NewClient 创建一个新的客户端
+func NewClient(hub *Hub, conn *websocket.Conn, conversationID uint, isVisitor bool) *Client {
+	return &Client{
+		hub:            hub,
+		conn:           conn,
+		send:           make(chan *Message, 256),
+		conversationID: conversationID,
+		isVisitor:      isVisitor,
+	}
+}
+
+// ReadPump 从 WebSocket 连接读取消息
+func (c *Client) ReadPump() {
+	defer func() {
+		c.hub.unregister <- c
+		c.conn.Close()
+	}()
+
+	// 设置读取限制和超时
+	c.conn.SetReadDeadline(time.Now().Add(pongWait))
+	c.conn.SetReadLimit(maxMessageSize)
+	c.conn.SetPongHandler(func(string) error {
+		c.conn.SetReadDeadline(time.Now().Add(pongWait))
+		return nil
+	})
+
+	// 持续读取消息
+	for {
+		_, _, err := c.conn.ReadMessage()
+		if err != nil {
+			if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
+				log.Printf("⚠️ WebSocket 读取错误: 对话ID=%d, 错误=%v", c.conversationID, err)
+			}
+			break
+		}
+		// 目前我们不需要处理客户端发送的消息,只接收心跳包
+		// 如果需要双向通信,可以在这里处理客户端消息
+	}
+}
+
+// WritePump 向 WebSocket 连接写入消息
+func (c *Client) WritePump() {
+	ticker := time.NewTicker(pingPeriod)
+	defer func() {
+		ticker.Stop()
+		c.conn.Close()
+	}()
+
+	for {
+		select {
+		case message, ok := <-c.send:
+			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+			if !ok {
+				// Hub 关闭了通道
+				c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+				return
+			}
+
+			// 发送消息
+			if err := c.conn.WriteJSON(message); err != nil {
+				log.Printf("❌ WebSocket 写入错误: 对话ID=%d, 类型=%s, 错误=%v",
+					c.conversationID, message.Type, err)
+				return
+			}
+
+		case <-ticker.C:
+			// 定期发送 ping 保持连接
+			c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+			if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+				log.Printf("❌ 发送 ping 失败: 对话ID=%d, 错误=%v", c.conversationID, err)
+				return
+			}
+		}
+	}
+}
+
+// SendMessage 发送消息给客户端(用于测试)
+func (c *Client) SendMessage(messageType string, data interface{}) error {
+	message := &Message{
+		ConversationID: c.conversationID,
+		Type:           messageType,
+		Data:           data,
+	}
+
+	messageJSON, err := json.Marshal(message)
+	if err != nil {
+		return err
+	}
+
+	c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+	return c.conn.WriteMessage(websocket.TextMessage, messageJSON)
+}

+ 62 - 0
backend/websocket/handler.go

@@ -0,0 +1,62 @@
+package websocket
+
+import (
+	"log"
+	"net/http"
+	"strconv"
+
+	"github.com/gin-gonic/gin"
+	"github.com/gorilla/websocket"
+)
+
+var upgrader = websocket.Upgrader{
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+	// 允许跨域连接
+	CheckOrigin: func(r *http.Request) bool {
+		return true
+	},
+}
+
+// HandleWebSocket 处理 WebSocket 连接
+func HandleWebSocket(hub *Hub) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		// 从查询参数获取对话ID
+		conversationIDStr := c.Query("conversation_id")
+		if conversationIDStr == "" {
+			c.JSON(http.StatusBadRequest, gin.H{"error": "conversation_id 不能为空"})
+			return
+		}
+
+		conversationID, err := strconv.ParseUint(conversationIDStr, 10, 32)
+		if err != nil {
+			c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 conversation_id"})
+			return
+		}
+
+		// 从查询参数获取是否是访客(默认为 true,因为默认是访客连接)
+		isVisitorStr := c.DefaultQuery("is_visitor", "true")
+		isVisitor := isVisitorStr == "true" || isVisitorStr == "1"
+
+		// 升级 HTTP 连接为 WebSocket 连接
+		conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
+		if err != nil {
+			log.Printf("WebSocket 升级失败: %v", err)
+			return
+		}
+
+		// 创建客户端
+		client := NewClient(hub, conn, uint(conversationID), isVisitor)
+
+		// 注册客户端到 Hub
+		client.hub.register <- client
+
+		// 启动两个 goroutine:
+		// 1. ReadPump:从客户端读取消息(主要是心跳包)
+		// 2. WritePump:向客户端发送消息
+		go client.WritePump()
+		go client.ReadPump()
+
+		log.Printf("✅ WebSocket 连接已建立: 对话ID=%d, 是访客=%v", conversationID, isVisitor)
+	}
+}

+ 218 - 0
backend/websocket/hub.go

@@ -0,0 +1,218 @@
+package websocket
+
+import (
+	"log"
+	"sync"
+)
+
+// OnClientConnectCallback 客户端连接时的回调函数。
+// conversationID: 对话ID
+// isVisitor: 是否是访客
+// visitorCount: 该对话当前的访客连接数
+type OnClientConnectCallback func(conversationID uint, isVisitor bool, visitorCount int)
+
+// OnClientDisconnectCallback 客户端断开连接时的回调函数。
+// conversationID: 对话ID
+// isVisitor: 是否是访客
+// visitorCount: 该对话当前的访客连接数(断开后)
+type OnClientDisconnectCallback func(conversationID uint, isVisitor bool, visitorCount int)
+
+// Hub 管理所有 WebSocket 连接
+// 每个对话(conversation)可以有多个人连接(访客和客服)
+type Hub struct {
+	// 每个对话ID对应的客户端连接列表
+	// conversationID -> []*Client
+	conversations map[uint]map[*Client]bool
+
+	// 注册新客户端(当有人连接时)
+	register chan *Client
+
+	// 注销客户端(当有人断开连接时)
+	unregister chan *Client
+
+	// 广播消息(当有新消息时,推送给所有相关的客户端)
+	broadcast chan *Message
+
+	// 互斥锁(保护并发访问)
+	mu sync.RWMutex
+
+	// 回调函数
+	onConnect    OnClientConnectCallback
+	onDisconnect OnClientDisconnectCallback
+}
+
+// Message 是要广播的消息
+type Message struct {
+	ConversationID uint        `json:"conversation_id"`
+	Data           interface{} `json:"data"` // 消息内容(可以是 Message 对象)
+	Type           string      `json:"type"` // 消息类型:new_message, conversation_update 等
+}
+
+// NewHub 创建一个新的 Hub
+func NewHub(onConnect OnClientConnectCallback, onDisconnect OnClientDisconnectCallback) *Hub {
+	return &Hub{
+		conversations: make(map[uint]map[*Client]bool),
+		register:      make(chan *Client),
+		unregister:    make(chan *Client),
+		broadcast:     make(chan *Message, 256),
+		onConnect:     onConnect,
+		onDisconnect:  onDisconnect,
+	}
+}
+
+// Run 启动 Hub,处理所有事件
+func (h *Hub) Run() {
+	for {
+		select {
+		// 新客户端连接
+		case client := <-h.register:
+			h.mu.Lock()
+			// 如果这个对话还没有客户端,创建一个新的 map
+			if h.conversations[client.conversationID] == nil {
+				h.conversations[client.conversationID] = make(map[*Client]bool)
+			}
+			// 把这个客户端加入到对话中
+			h.conversations[client.conversationID][client] = true
+
+			// 统计该对话的访客连接数
+			visitorCount := 0
+			for c := range h.conversations[client.conversationID] {
+				if c.isVisitor {
+					visitorCount++
+				}
+			}
+			h.mu.Unlock()
+
+			// 调用连接回调函数
+			if h.onConnect != nil {
+				h.onConnect(client.conversationID, client.isVisitor, visitorCount)
+			}
+
+		// 客户端断开连接
+		case client := <-h.unregister:
+			h.mu.Lock()
+			// 从对话中移除这个客户端
+			wasVisitor := client.isVisitor
+			if clients, ok := h.conversations[client.conversationID]; ok {
+				if _, ok := clients[client]; ok {
+					delete(clients, client)
+					// 关闭发送通道(避免重复关闭导致 panic)
+					select {
+					case _, ok := <-client.send:
+						if !ok {
+							// 通道已经关闭,不需要再次关闭
+						}
+					default:
+						// 通道未关闭,关闭它
+						close(client.send)
+					}
+
+					// 统计该对话的访客连接数(断开后)
+					visitorCount := 0
+					for c := range clients {
+						if c.isVisitor {
+							visitorCount++
+						}
+					}
+
+					// 如果这个对话没有客户端了,删除对话
+					if len(clients) == 0 {
+						delete(h.conversations, client.conversationID)
+					}
+					h.mu.Unlock()
+
+					// 调用断开回调函数
+					if h.onDisconnect != nil {
+						h.onDisconnect(client.conversationID, wasVisitor, visitorCount)
+					}
+				} else {
+					h.mu.Unlock()
+					log.Printf("⚠️ 客户端断开时未找到: 对话ID=%d", client.conversationID)
+				}
+			} else {
+				h.mu.Unlock()
+				log.Printf("⚠️ 客户端断开时对话不存在: 对话ID=%d", client.conversationID)
+			}
+
+		// 广播消息
+		case message := <-h.broadcast:
+			h.mu.RLock()
+			// 找到这个对话的所有客户端
+			clients, ok := h.conversations[message.ConversationID]
+			if !ok {
+				h.mu.RUnlock()
+				log.Printf("⚠️ 广播消息失败: 对话ID=%d 没有客户端连接", message.ConversationID)
+				continue
+			}
+			// 创建一个客户端列表的副本(避免在遍历时修改)
+			clientList := make([]*Client, 0, len(clients))
+			for client := range clients {
+				clientList = append(clientList, client)
+			}
+			h.mu.RUnlock()
+
+			// 给所有客户端发送消息
+			for _, client := range clientList {
+				select {
+				case client.send <- message:
+				default:
+					// 如果发送失败(客户端可能已经断开),关闭连接
+					log.Printf("⚠️ 发送消息失败: 对话ID=%d, 客户端断开", client.conversationID)
+					close(client.send)
+					h.mu.Lock()
+					delete(h.conversations[client.conversationID], client)
+					h.mu.Unlock()
+				}
+			}
+		}
+	}
+}
+
+// BroadcastMessage 广播消息到指定对话的所有客户端
+func (h *Hub) BroadcastMessage(conversationID uint, messageType string, data interface{}) {
+	h.broadcast <- &Message{
+		ConversationID: conversationID,
+		Type:           messageType,
+		Data:           data,
+	}
+}
+
+// BroadcastToAllAgents 广播消息到所有客服客户端(不管连接到哪个对话)
+// 用于 visitor_status_update 等需要所有客服都收到的事件
+func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
+	h.mu.RLock()
+	// 收集所有客服客户端(isVisitor == false)
+	allAgents := make([]*Client, 0)
+	for _, clients := range h.conversations {
+		for client := range clients {
+			if !client.isVisitor {
+				allAgents = append(allAgents, client)
+			}
+		}
+	}
+	h.mu.RUnlock()
+
+	// 为每个客服客户端创建消息并发送
+	for _, client := range allAgents {
+		message := &Message{
+			ConversationID: client.conversationID, // 使用客户端连接的对话ID
+			Type:           messageType,
+			Data:           data,
+		}
+		select {
+		case client.send <- message:
+		default:
+			// 如果发送失败(客户端可能已经断开),关闭连接
+			log.Printf("⚠️ 发送消息到客服失败: 对话ID=%d, 客户端断开", client.conversationID)
+			close(client.send)
+			h.mu.Lock()
+			if clients, ok := h.conversations[client.conversationID]; ok {
+				delete(clients, client)
+				if len(clients) == 0 {
+					delete(h.conversations, client.conversationID)
+				}
+			}
+			h.mu.Unlock()
+		}
+	}
+}

+ 823 - 0
doc/CHANGELOG.md

@@ -0,0 +1,823 @@
+# 开发日志
+
+> 📋 待实现需求清单请查看:[待实现需求清单.md](./待实现需求清单.md)
+
+## 2025-01-13 12:00:00 UTC
+
+### 完成的工作
+1. **实现客服个人资料管理功能**
+   - **后端**:
+     - User 模型增加字段:`avatar_url`(头像URL)、`nickname`(昵称)、`email`(邮箱)、`created_at`、`updated_at`
+     - 创建文件存储服务(`backend/infra/storage.go`):本地存储服务,可扩展为云存储
+     - 创建个人资料服务(`backend/service/profile_service.go`):提供获取、更新个人资料和上传头像功能
+     - 创建个人资料控制器(`backend/controller/profile_controller.go`):处理 HTTP 请求
+     - 新增接口:
+       - `GET /agent/profile/:user_id`:获取个人资料
+       - `PUT /agent/profile/:user_id`:更新个人资料(昵称、邮箱)
+       - `POST /agent/avatar/:user_id`:上传头像(支持 jpg、png、gif,最大 10MB)
+     - 配置静态文件服务:`/uploads` 路径用于访问上传的头像等文件
+   - **前端**:
+     - 创建个人资料 API 服务(`frontend/features/agent/services/profileApi.ts`)
+     - 创建个人资料 Hook(`frontend/features/agent/hooks/useProfile.ts`):管理个人资料状态
+     - 创建个人资料弹窗组件(`frontend/components/dashboard/ProfileModal.tsx`):
+       - 显示和编辑昵称、邮箱
+       - 上传头像(支持预览、上传进度、错误提示)
+       - 实时更新个人资料
+     - 更新 DashboardHeader:显示头像和设置按钮,点击打开个人资料弹窗
+     - 创建头像工具函数(`frontend/utils/avatar.ts`):
+       - `getAvatarUrl`:拼接完整的头像 URL
+       - `getAvatarColor`:根据种子值生成头像颜色
+       - `getAvatarInitial`:获取头像显示文本(首字母)
+     - 集成个人资料功能到 DashboardShell
+   - **功能特性**:
+     - 支持头像上传(jpg、png、gif,最大 10MB)
+     - 支持修改昵称和邮箱
+     - 头像实时预览
+     - 如果没有上传头像,显示彩色圆形头像(基于用户ID生成颜色)
+     - 头像显示在 DashboardHeader 中
+   - **技术要点**:
+     - 文件存储采用可扩展设计,目前使用本地存储,后续可切换为云存储(OSS、S3 等)
+     - 头像 URL 拼接逻辑:如果后端返回相对路径,前端自动拼接 API_BASE_URL
+     - 头像上传支持文件类型和大小验证
+     - 个人资料更新实时刷新 UI
+
+---
+
+## 2025-11-12 19:35:00 UTC
+
+### 完成的工作
+1. **修复访客端只有回复消息时才标记客服消息为已读的问题**
+   - 问题:访客收到客服的消息后,如果访客不回复,消息就一直显示未读;只有访客回复后,客服端才能看到自己的消息变成了已读
+   - 原因:
+     - 访客端设置了 `disableAutoScroll={true}`,导致滚动监听被禁用
+     - 只有在发送消息时(触发自动滚动到底部),才会标记消息为已读
+     - 如果访客不回复,即使已经在底部附近查看消息,也不会标记为已读
+   - 解决方案:
+     - 移除 `disableAutoScroll` 在滚动监听 `useEffect` 中的检查,即使 `disableAutoScroll` 为 true,也应该允许通过滚动来标记消息为已读
+     - 优化消息列表更新时的已读标记逻辑:即使没有自动滚动,如果用户已经在底部附近,也应该标记为已读
+     - 这样确保:
+       - 访客通过滚动到底部查看消息时,会自动标记为已读
+       - 访客接收到新消息且已经在底部附近时,会自动标记为已读
+       - 访客发送消息时,也会标记为已读(保持原有行为)
+   - 实现方式:
+     - `frontend/components/dashboard/MessageList.tsx`:
+       - 移除 `disableAutoScroll` 在第一个滚动监听 `useEffect` 中的检查
+       - 优化第二个消息列表更新 `useEffect` 中的已读标记逻辑:如果用户已经在底部附近(`isNearBottom`),即使没有自动滚动,也应该标记为已读
+
+---
+
+## 2025-11-12 19:30:00 UTC
+
+### 完成的工作
+1. **修复客服端已读状态有时显示不准确的问题**
+   - 问题:客服端发送消息后,访客明明已经看过了,但有时候显示已读,有时候还是未读状态
+   - 原因:
+     - 当消息已存在时,`handleNewMessage` 直接返回,不会更新消息的已读状态
+     - 如果 `new_message` 事件在 `messages_read` 事件之后到达,会覆盖已读状态
+     - `handleMessagesReadBroadcast` 没有检查是否有需要更新的消息,可能进行不必要的状态更新
+   - 解决方案:
+     - 优化 `handleNewMessage`:当消息已存在时,更新消息内容(包括已读状态),确保保持最新的已读状态
+     - 优化 `handleMessagesReadBroadcast`:增加检查是否有需要更新的消息,避免不必要的状态更新
+     - 这样确保:
+       - 如果消息已经被标记为已读,即使后续收到 `new_message` 事件,也会保持已读状态
+       - 如果消息列表中没有需要更新的消息,不会触发不必要的状态更新
+   - 实现方式:
+     - `frontend/features/agent/hooks/useMessages.ts`:
+       - 在 `handleNewMessage` 中,当消息已存在时,更新消息内容(包括已读状态)
+       - 在 `handleMessagesReadBroadcast` 中,增加检查是否有需要更新的消息,避免不必要的状态更新
+
+---
+
+## 2025-11-12 15:25:00 UTC
+
+### 完成的工作
+1. **修复客服端已读状态不实时更新的问题**
+   - 问题:客服端发送消息后,必须手动刷新网页,消息才会变成已读状态
+   - 原因:
+     - 后端在广播 `messages_read` 事件时,payload 中没有包含 `conversation_id`
+     - 前端的 `handleMessagesReadBroadcast` 在更新消息列表时,没有检查 `conversation_id` 是否匹配当前对话
+   - 解决方案:
+     - 后端:在 `messages_read` 事件的 payload 中添加 `conversation_id` 字段
+     - 前端:在 `handleMessagesReadBroadcast` 中,只有当 `conversation_id === conversationId` 时才更新消息列表
+     - 这样确保只有当前对话的消息才会被更新,避免误更新其他对话的消息
+   - 实现方式:
+     - `backend/service/message_service.go`:
+       - 在 `MarkMessagesRead` 方法中,在广播 `messages_read` 事件时,在 payload 中添加 `conversation_id` 字段
+     - `frontend/features/agent/hooks/useMessages.ts`:
+       - 在 `handleMessagesReadBroadcast` 中,添加 `conversation_id === conversationId` 的检查,只有当匹配时才更新消息列表
+
+---
+
+## 2025-11-12 15:20:00 UTC
+
+### 完成的工作
+1. **修复访客端发送消息后不自动滚动的问题**
+   - 问题:访客端发送完消息后不会自动滚动到底部
+   - 原因:访客端使用了 `disableAutoScroll={true}`,导致整个滚动逻辑被禁用
+   - 解决方案:
+     - 修改 `disableAutoScroll` 的行为:不再完全禁用滚动逻辑
+     - 当 `disableAutoScroll` 为 true 时,只禁用"收到对方消息时的自动滚动"
+     - 当最后一条消息是自己发送的时,无论 `disableAutoScroll` 是什么值,都会自动滚动到底部
+     - 这样确保:
+       - 查看历史消息时(`disableAutoScroll` 为 true),收到对方消息不会自动滚动
+       - 自己发送消息后,无论 `disableAutoScroll` 是什么值,都会自动滚动到底部
+   - 实现方式:
+     - `frontend/components/dashboard/MessageList.tsx`:
+       - 移除 `disableAutoScroll` 在 `useEffect` 开头的早期返回
+       - 修改滚动判断逻辑:`shouldAutoScroll = hasNewMessage && (isLastMessageFromCurrentUser || (!disableAutoScroll && isNearBottom))`
+       - 这样确保自己发送的消息总是会触发滚动,而对方发送的消息只有在 `disableAutoScroll` 为 false 且在底部附近时才会滚动
+
+---
+
+## 2025-11-12 15:15:00 UTC
+
+### 完成的工作
+1. **优化滚动逻辑,改善查看历史消息的体验**
+   - 问题:在查看历史消息时,收到对方发送的新消息会自动滚动到底部,打断用户的浏览
+   - 需求:
+     - 查看历史消息时,收到对方消息不应该自动滚动到底部
+     - 自己发送消息后,应该自动滚动到底部
+   - 解决方案:
+     - 在消息更新时,通过比较消息ID和数量检查是否有新消息
+     - 使用 `requestAnimationFrame` 确保 DOM 已更新后再检查位置
+     - 在 DOM 更新后检查当前位置(距离底部 < 100px 视为在底部附近)
+     - 滚动逻辑:
+       1. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部
+       2. 如果最后一条消息是对方发送的,只有在底部附近时才自动滚动到底部(保持底部状态)
+       3. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
+     - 这样确保:
+       - 查看历史消息时(不在底部),收到对方消息不会自动滚动,不会打断浏览
+       - 自己发送消息后,会自动滚动到底部,可以看到自己发送的消息
+       - 如果已经在底部查看最新消息,收到对方消息时保持滚动到底部
+   - 实现方式:
+     - `frontend/components/dashboard/MessageList.tsx`:
+       - 添加 `lastMessageIdRef` 和 `lastMessageCountRef` 来跟踪最后一条消息
+       - 在消息更新时检查是否有新消息(通过比较消息ID和数量)
+       - 使用 `requestAnimationFrame` 确保 DOM 已更新后再检查位置和决定是否滚动
+       - 优化滚动逻辑,区分自己发送的消息和对方发送的消息
+       - 在 `requestAnimationFrame` 回调中从 `containerRef.current` 重新获取容器,确保使用最新的 DOM 元素
+
+---
+
+## 2025-11-12 15:00:00 UTC
+
+### 完成的工作
+1. **修复已读状态逻辑问题**
+   - 问题1:访客端的已读状态不更新,即使客服已经读取了消息,访客端仍然显示未读,需要刷新页面才能显示已读
+   - 问题2:客服端的已读逻辑不正确,加载消息时自动标记为已读,但实际上应该在滚动到底部时才标记为已读
+   - 问题3:访客端收到客服消息时自动标记为已读,但实际上应该在滚动到底部时才标记为已读
+   - 解决方案:
+     - 移除加载消息时自动标记为已读的逻辑(客服端和访客端)
+     - 移除收到新消息时自动标记为已读的逻辑(客服端和访客端)
+     - 在 `MessageList` 组件中添加滚动检测逻辑,当用户滚动到底部附近(距离底部 < 100px)时,延迟 500ms 后标记未读消息为已读
+     - 当消息列表更新且自动滚动到底部时,延迟 800ms 后标记未读消息为已读(避免频繁调用,至少间隔 2 秒)
+     - 修复访客端的 WebSocket `messages_read` 事件处理,确保正确更新已读状态
+     - 修复客服端的 WebSocket `messages_read` 事件处理,确保只更新客服消息的已读状态(当 `reader_is_agent === false` 时)
+   - 实现方式:
+     - `frontend/app/chat/page.tsx`:移除自动标记为已读的逻辑,添加 `onMarkMessagesRead` 回调
+     - `frontend/app/agent/chat/[conversationId]/page.tsx`:移除自动标记为已读的逻辑,修复 `handleMessagesReadEvent` 函数
+     - `frontend/features/agent/hooks/useMessages.ts`:移除自动标记为已读的逻辑
+     - `frontend/components/dashboard/MessageList.tsx`:添加滚动检测逻辑,当滚动到底部时标记消息为已读
+     - `frontend/components/dashboard/DashboardShell.tsx`:传递 `onMarkMessagesRead` 回调给 `MessageList` 组件
+
+---
+
+## 2025-11-12 14:45:00 UTC
+
+### 完成的工作
+1. **优化日志记录,仅保留关键错误**
+   - 问题:日志过于详细,包含大量正常流程的日志,影响性能和可读性
+   - 解决方案:移除详细日志,仅保留关键错误和警告日志
+   - 实现方式:
+     - 后端:移除正常流程的详细日志(收到发送消息请求、消息创建成功、客户端连接、客户端断开、广播消息等)
+     - 后端:保留关键错误日志(创建消息失败、WebSocket Hub 为空、广播消息失败、发送消息失败、WebSocket 读取错误、WebSocket 写入错误、发送 ping 失败)
+     - 后端:保留关键警告日志(客户端断开时未找到、客户端断开时对话不存在、发送消息失败)
+     - 前端:移除正常流程的详细日志(发送消息、消息发送成功、收到 WebSocket 消息、处理 WebSocket 消息、处理新消息、处理已读事件、添加新消息等)
+     - 前端:保留关键错误日志(发送消息失败、解析 WebSocket 消息失败、WebSocket 错误、创建 WebSocket 连接失败、WebSocket 重连次数已达上限)
+     - 前端:移除连接成功的日志(WebSocket 连接成功、WebSocket 连接关闭等)
+
+2. **修复通道关闭问题**
+   - 问题:通道可能被重复关闭,导致 panic
+   - 解决方案:在关闭通道前检查通道是否已经关闭
+   - 实现方式:
+     - 在 `Hub.unregister` 中,使用 `select` 检查通道是否已经关闭
+     - 如果通道已经关闭,不再关闭
+     - 如果通道未关闭,关闭它
+
+3. **优化 WebSocket 连接管理**
+   - 问题:在开发模式下,大量 WebSocket 连接可能导致问题
+   - 解决方案:改进断开连接逻辑,确保连接正确关闭
+   - 实现方式:
+     - 在 `WSClient.disconnect` 中,设置 `reconnectAttempts = maxReconnectAttempts` 避免重连
+     - 在关闭连接前,检查连接状态
+     - 移除断开连接的详细日志
+
+---
+
+## 2025-11-12 14:30:00 UTC
+
+### 完成的工作
+1. **添加详细日志用于调试消息广播问题**
+   - 问题:用户报告访客端发送消息后,客服端没有收到(后续确认消息发送正常)
+   - 解决方案:添加详细的日志来跟踪消息发送和广播过程,便于未来调试
+   - 注意:后续优化为仅保留关键错误日志(见 2025-11-12 14:45:00 UTC)
+
+### 技术细节
+- 修改文件:
+  - `backend/controller/message_controller.go`:添加消息创建日志
+  - `backend/service/message_service.go`:添加消息广播日志
+  - `backend/websocket/hub.go`:添加广播消息日志、修复通道关闭问题
+  - `backend/websocket/client.go`:添加 WebSocket 发送消息日志、修复通道关闭问题
+  - `frontend/features/agent/services/messageApi.ts`:添加消息发送日志
+  - `frontend/app/chat/page.tsx`:添加消息发送和处理日志
+  - `frontend/features/agent/hooks/useMessages.ts`:添加消息处理日志
+  - `frontend/lib/websocket.ts`:添加 WebSocket 消息接收日志、优化断开连接逻辑
+
+### 调试步骤
+1. **测试消息发送**:
+   - 在访客端发送消息
+   - 查看浏览器控制台日志:
+     - 应该看到 `📨 开始发送消息: 对话ID=X, 内容="..."`
+     - 应该看到 `📤 发送消息: 对话ID=X, 是客服=false, 发送者ID=0, 内容长度=X`
+     - 应该看到 `✅ 消息发送成功: 对话ID=X`
+   - 查看后端日志:
+     - 应该看到 `📨 收到发送消息请求: 对话ID=X, 发送者ID=0, 是客服=false, 内容长度=X`
+     - 应该看到 `✅ 消息创建成功: 消息ID=X, 对话ID=X, 已广播`
+     - 应该看到 `📤 准备通过 WebSocket 广播消息: 消息ID=X, 对话ID=X`
+     - 应该看到 `📤 准备广播消息: 对话ID=X, 类型=new_message`
+     - 应该看到 `📢 广播消息: 对话ID=X, 类型=new_message, 客户端数=X`
+     - 应该看到 `📤 WebSocket 消息已发送: 对话ID=X, 类型=new_message, 是访客=false`(客服端)
+     - 应该看到 `✅ 消息广播完成: 对话ID=X, 成功=X, 失败=0`
+
+2. **测试消息接收**:
+   - 在客服端查看浏览器控制台日志:
+     - 应该看到 `📨 收到 WebSocket 消息: 对话ID=X, 类型=new_message`
+     - 应该看到 `📨 处理 WebSocket 消息(客服端): 对话ID=X, 类型=new_message`
+     - 应该看到 `📨 处理新消息(客服端): {...}`
+     - 应该看到 `✅ 添加新消息: 消息ID=X, 内容="..."`
+
+3. **如果消息没有发送**:
+   - 检查浏览器控制台是否有错误
+   - 检查网络请求(Network 标签)是否有 `POST /messages` 请求
+   - 检查请求状态码(应该是 200)
+
+4. **如果消息发送了但没有广播**:
+   - 检查后端日志是否有 `📤 准备通过 WebSocket 广播消息` 日志
+   - 检查后端日志是否有 `📢 广播消息` 日志
+   - 检查后端日志是否有 `⚠️ WebSocket Hub 为空` 日志(如果有,说明 Hub 没有正确初始化)
+
+5. **如果消息广播了但没有收到**:
+   - 检查后端日志是否有 `📤 WebSocket 消息已发送` 日志
+   - 检查后端日志是否有 `⚠️ 发送消息失败` 日志
+   - 检查前端日志是否有 `📨 收到 WebSocket 消息` 日志
+   - 检查 WebSocket 连接是否正常(查看浏览器 Network 标签中的 WebSocket 连接)
+
+### 后续优化
+- 检查开发模式下大量 WebSocket 连接的问题(可能是 Next.js 热重载导致的)
+- 优化连接管理,减少重复连接
+- 添加连接数限制,防止连接数过多
+
+---
+
+## 2025-11-12 14:00:00 UTC
+
+### 完成的工作
+1. **修复 WebSocket 错误处理问题**
+   - 问题:WebSocket 连接错误显示为 `{}`,错误信息不够详细
+   - 解决方案:改进错误处理,提供更详细的错误信息
+   - 实现方式:
+     - 在 `onerror` 事件中检查 `readyState` 和 `url`,提供详细的错误信息
+     - 在 `onclose` 事件中获取关闭代码和原因,提供详细的关闭信息
+     - 只有在非正常关闭时才尝试重连(避免在开发模式下频繁重连)
+     - 使用 `useRef` 存储回调函数,避免因回调函数变化导致重新连接
+     - 在连接前检查是否已存在连接,避免重复连接
+
+### 技术细节
+- 修改文件:
+  - `frontend/lib/websocket.ts`:改进错误处理和关闭处理
+  - `frontend/features/agent/hooks/useWebSocket.ts`:使用 `useRef` 存储回调函数
+  - `frontend/app/chat/page.tsx`:明确设置 `isVisitor: true`
+- 实现原理:
+  - 在 `onerror` 事件中,检查 `readyState` 和 `url`,提供详细错误信息
+  - 在 `onclose` 事件中,获取关闭代码(`code`)、原因(`reason`)和是否干净关闭(`wasClean`)
+  - 只有在 `!wasClean && code !== 1000` 时才尝试重连
+  - 使用 `useRef` 存储回调函数,避免因回调函数变化导致 `useEffect` 重新执行
+  - 在 `connect()` 方法中,检查是否已存在连接,如果存在则先断开
+- ✅ `npm run lint`(frontend,无警告)
+
+### 错误处理改进
+- ✅ 提供详细的错误信息(状态、URL等)
+- ✅ 提供详细的关闭信息(关闭代码、原因、是否干净关闭)
+- ✅ 避免在开发模式下频繁重连
+- ✅ 避免因回调函数变化导致重新连接
+- ✅ 避免重复连接
+
+### 测试状态
+✅ 手动验证:WebSocket 错误处理和关闭处理正常,错误信息详细
+
+---
+
+## 2025-11-12 13:30:00 UTC
+
+### 完成的工作
+1. **修复输入框失去焦点问题**
+   - 问题:在访客端和客服端,发送完一条消息后,输入框失去焦点,需要再次点击输入框才能继续输入
+   - 解决方案:在 `MessageInput` 组件中添加自动聚焦功能
+   - 实现方式:
+     - 使用 `useRef` 引用输入框元素
+     - 使用 `useEffect` 监听 `sending` 状态变化
+     - 当 `sending` 从 `true` 变为 `false` 时(发送完成),自动聚焦到输入框
+     - 使用 `setTimeout` 确保 DOM 更新完成后再聚焦
+
+### 技术细节
+- 修改文件:
+  - `frontend/components/dashboard/MessageInput.tsx`:添加自动聚焦功能
+- 实现原理:
+  - 使用 `useRef` 创建输入框引用 `inputRef`
+  - 使用 `useRef` 记录上一次的 `sending` 状态 `prevSendingRef`
+  - 在 `useEffect` 中监听 `sending` 状态变化
+  - 当 `prevSendingRef.current === true && sending === false` 时,说明刚刚发送完成
+  - 调用 `inputRef.current?.focus()` 聚焦到输入框
+  - 使用 `setTimeout(..., 0)` 确保 DOM 更新完成后再聚焦
+- ✅ `npm run lint`(frontend,无警告)
+
+### 测试状态
+✅ 手动验证:发送消息后,输入框自动聚焦,可以直接继续输入
+
+### 用户体验改进
+- ✅ 发送消息后,输入框自动聚焦,无需再次点击
+- ✅ 用户可以连续发送多条消息,无需每次点击输入框
+- ✅ 提升聊天体验,减少操作步骤
+
+---
+
+## 2025-11-12 13:00:00 UTC
+
+### 完成的工作
+1. **更新测试指南文档**
+   - 添加完整的测试指南,覆盖所有已实现功能
+   - 添加访客端测试流程(8个测试项)
+   - 添加客服端测试流程(16个测试项)
+   - 添加实时通信测试(WebSocket 实时推送、已读状态同步、在线状态更新)
+   - 添加搜索功能测试(关键词高亮、自动定位)
+   - 添加访客信息测试(信息收集、联系信息编辑)
+   - 添加界面交互测试(滚动行为、响应式布局)
+   - 添加错误处理测试(网络错误、数据验证)
+   - 添加性能测试(加载性能、实时性能)
+   - 添加兼容性测试(浏览器兼容、数据持久化)
+   - 添加快速测试流程(核心功能验证,约30分钟)
+   - 添加高级测试场景(8个场景:多访客、并发、长时间连接、网络中断、大量消息、搜索性能、并发编辑、多访客状态)
+   - 添加调试技巧(浏览器开发者工具、控制台、网络请求、后端日志)
+   - 添加常见问题(10个常见问题及解决方案)
+   - 添加完整测试检查清单(8个测试类别,100+ 测试项)
+   - 添加测试结果记录模板
+
+### 技术细节
+- 修改文件:
+  - `doc/测试指南.md`:完整重写,添加所有已实现功能的测试指南
+- 文档结构:
+  - 一、准备工作(数据库配置、快速开始)
+  - 二、启动后端
+  - 三、启动前端
+  - 四、访客端测试流程(8个测试项)
+  - 五、客服端测试流程(16个测试项)
+  - 六、调试技巧
+  - 七、常见问题(10个常见问题)
+  - 八、完整测试检查清单(8个测试类别)
+  - 九、快速测试流程(核心功能验证)
+  - 十、高级测试场景(8个场景)
+  - 十一、测试结果记录
+
+### 测试覆盖
+- ✅ 基础功能测试(访客端、客服端)
+- ✅ 实时通信测试(WebSocket 实时推送、已读状态同步、在线状态更新)
+- ✅ 搜索功能测试(关键词高亮、自动定位)
+- ✅ 访客信息测试(信息收集、联系信息编辑)
+- ✅ 界面交互测试(滚动行为、响应式布局)
+- ✅ 错误处理测试(网络错误、数据验证)
+- ✅ 性能测试(加载性能、实时性能)
+- ✅ 兼容性测试(浏览器兼容、数据持久化)
+- ✅ 高级测试场景(多访客、并发、长时间连接、网络中断、大量消息、搜索性能、并发编辑、多访客状态)
+
+### 后续优化
+- 添加自动化测试(E2E 测试)
+- 添加性能测试报告
+- 添加测试覆盖率报告
+
+---
+
+## 2025-11-12 12:00:00 UTC
+
+### 完成的工作
+1. **对话状态管理(在线/离线)实时更新**
+   - 后端:WebSocket 连接建立时标记访客在线,断开时标记离线
+   - 后端:通过 WebSocket 推送 `visitor_status_update` 事件到客服端
+   - 后端:在 `ConversationService` 中添加 `UpdateVisitorOnlineStatus` 和 `UpdateLastSeenAt` 方法
+   - 后端:在 `Hub` 中添加回调机制,在客户端连接/断开时调用回调函数
+   - 后端:在 `Client` 中添加 `isVisitor` 字段,区分访客和客服
+   - 前端:WebSocket 客户端添加 `isVisitor` 参数,默认值为 `true`
+   - 前端:客服端 WebSocket 连接设置 `isVisitor=false`
+   - 前端:客服端接收 `visitor_status_update` 事件,刷新对话详情
+   - 前端:在对话列表中显示在线/离线图标(绿色圆点表示在线)
+
+### 技术细节
+- 修改文件:
+  - 后端:`backend/service/conversation_service.go`、`backend/websocket/hub.go`、`backend/websocket/client.go`、`backend/websocket/handler.go`、`backend/main.go`
+  - 前端:`frontend/lib/websocket.ts`、`frontend/features/agent/hooks/useWebSocket.ts`、`frontend/features/agent/hooks/useMessages.ts`、`frontend/features/agent/types.ts`、`frontend/components/dashboard/ConversationListItem.tsx`
+- ✅ `npm run lint`(frontend,无警告)
+- ✅ `gofmt`(backend,无错误)
+
+### 实现原理
+- 当访客连接 WebSocket 时,后端会调用 `UpdateVisitorOnlineStatus(conversationID, true)` 更新在线状态
+- 当访客断开 WebSocket 时,后端会检查该对话是否还有其他访客连接,如果没有,则调用 `UpdateVisitorOnlineStatus(conversationID, false)` 更新离线状态
+- 后端通过 WebSocket 广播 `visitor_status_update` 事件到该对话的所有客户端(包括客服)
+- 客服端在收到 `visitor_status_update` 事件时,刷新当前对话详情,更新在线状态
+- 对话列表中显示在线/离线图标(基于 `status === "open"` 判断)
+
+### 测试状态
+✅ 手动验证:访客连接/断开 WebSocket 时,客服端实时更新在线状态
+
+### 后续优化
+- 实现心跳机制(定期更新 `last_seen_at`)
+- 根据 `last_seen_at` 判断是否在线(例如,如果 `last_seen_at` 在最近 60 秒内,则认为在线)
+- 在 `ConversationSummary` 中添加 `last_seen_at` 字段,以便在对话列表中显示最后活跃时间
+- 定期轮询对话列表,更新所有对话的状态
+
+---
+
+## 2025-11-12 11:10:00 UTC
+
+### 完成的工作
+1. **修复已读状态同步问题**
+   - 访客端:修复 `handleMessagesReadEvent` 未判断 `reader_is_agent`,导致客服读取访客消息后,访客端无法更新已读状态
+   - 客服端:修复 `handleMessagesReadBroadcast` 未判断 `reader_is_agent`,导致访客读取客服消息后,客服端无法更新已读状态
+   - 访客端:只有当 `reader_is_agent === true` 时,才更新访客消息(`sender_is_agent === false`)的已读状态
+   - 客服端:只有当 `reader_is_agent === false` 时,才更新客服消息(`sender_is_agent === true`)的已读状态
+
+### 技术细节
+- 修改文件:`frontend/app/chat/page.tsx`、`frontend/features/agent/hooks/useMessages.ts`
+- ✅ `npm run lint`(frontend,无警告)
+
+### 问题原因
+- 后端通过 WebSocket 推送 `messages_read` 事件时,会包含 `reader_is_agent` 字段,表示读取者是客服还是访客
+- 前端在接收 `messages_read` 事件时,没有判断 `reader_is_agent`,导致错误地更新了消息的已读状态
+- 对于访客端:只有当客服读取了访客的消息(`reader_is_agent === true`)时,才应该更新访客消息的已读状态
+- 对于客服端:只有当访客读取了客服的消息(`reader_is_agent === false`)时,才应该更新客服消息的已读状态
+
+### 测试状态
+✅ 手动验证:访客发送消息后,客服查看消息,访客端显示双对勾(已读状态)
+
+---
+
+## 2025-11-11 08:00:00 UTC
+
+### 完成的工作
+1. **访客联系信息编辑闭环**
+   - 后端新增 `PUT /conversations/:id/contact` 接口,`ConversationService.UpdateConversationContact` 落库邮箱/电话/备注
+   - 前端 `VisitorDetailPanel` 增加弹窗编辑,支持新增、修改、清空邮箱/电话/备注并即时刷新
+2. **服务与 Hook 扩展**
+   - `conversationApi.updateConversationContact` 封装更新接口,统一返回结构
+   - `useMessages` 暴露 `updateContactInfo`,`DashboardShell` 和 `VisitorDetailPanel` 通过钩子完成联动
+
+### 技术细节
+- 新增/修改文件:`backend/controller/conversation_controller.go`、`backend/service/conversation_service.go`、`backend/router/router.go`、`backend/service/types.go`
+- 前端涉及文件:`features/agent/services/conversationApi.ts`、`features/agent/hooks/useMessages.ts`、`components/dashboard/VisitorDetailPanel.tsx`
+- ✅ `npm run lint`(frontend)
+
+### 测试状态
+✅ 手动验证:客服工作台编辑邮箱/电话/备注,数据保存后右栏即时更新
+
+---
+
+## 2025-11-10 07:30:00 UTC
+
+### 完成的工作
+1. **客服工作台前端架构拆分**
+   - `app/agent/dashboard/page.tsx` 只保留页面入口,改由 `DashboardShell` 负责布局编排
+   - 新增 `components/dashboard/*`,将导航栏、会话列表、消息区、访客详情拆分为独立组件
+   - 新增 `features/agent/hooks` 与 `features/agent/services`,分别承载状态逻辑与 API 调用
+   - 新增 `utils/format.ts`、`utils/highlight.tsx`、`utils/storage.ts`,统一时间格式、关键词高亮与本地存储操作
+
+2. **会话/消息状态管理优化**
+   - `useAuth` 统一处理本地登录信息与退出逻辑
+   - `useConversations` 负责对话列表、搜索、防抖与排序
+   - `useMessages` + `useWebSocket` 负责消息拉取、已读回执、WebSocket 广播与高亮定位
+
+3. **TypeScript 类型补全**
+   - `features/agent/types.ts` 汇总会话、消息、用户等公共类型
+   - `lib/websocket.ts`、`useWebSocket`、`useMessages` 改用强类型定义,消除 `any`
+
+4. **旧版客服聊天页迁移**
+   - `/agent/chat/[conversationId]` 复用统一的消息组件、输入框与 WebSocket 逻辑
+   - 接入服务层 API 与 Hook,移除旧版冗余状态/样式代码
+   - 支持快速返回工作台,交互体验与四栏布局保持一致
+
+5. **访客聊天页重构**
+   - `/chat` 页面改用统一的 `MessageList`、`MessageInput` 组件和消息服务
+   - 对话初始化、WebSocket、已读回执与客服端共享实现,减少重复逻辑
+   - 保留访客视角的气泡样式与默认提示,UI/状态与客服端保持一致
+
+### 技术细节
+- 新增目录:`components/dashboard/`、`features/agent/hooks/`、`features/agent/services/`、`utils/`
+- 复用工具函数:消息预览截断、时间格式化、关键词高亮、localStorage 操作
+- ESLint:`npm run lint` 通过
+
+### 测试状态
+✅ `npm run lint`(frontend,无警告)
+
+### 下一步计划
+- 继续补充自动化测试(组件级、hook 级单元测试)
+- 为 `services` 层补充基础错误处理与重试策略
+- 为文件上传、AI 客服等后续功能预留组件与 Hook 模板
+
+## 2025-11-10 05:00:00 UTC
+
+### 完成的工作
+1. **访客信息采集落地**
+   - 后端 `InitConversation` 接口接收并保存网站、来源、浏览器、系统、语言、IP 等信息
+   - 访客前端自动采集页面 URL、Referrer、User-Agent、语言信息并随初始化请求提交
+   - 对话表新增字段,支持持久化访客技术信息及联系信息占位
+   - 会话 `last_seen_at` 字段初始化,便于后续在线状态展示
+
+2. **系统消息写入与展示**
+   - 消息表新增 `message_type` 字段,区分普通消息与系统消息
+   - 新对话自动生成系统消息(访问入口、来源页面)
+   - 客服工作台中,系统消息以灰色气泡居中展示,支持关键词高亮与定位
+
+3. **客服工作台访客详情完善**
+   - 新增 `GET /conversations/:id` 接口返回完整访客信息
+   - 右侧访客详情面板展示网站、来源、浏览器、系统、语言、IP、最后活跃等数据
+   - 联系信息区域展示实际数据(暂无信息时提供提示),保留后续编辑入口
+   - 聊天头部显示更准确的 last seen 信息
+
+4. **搜索体验优化**
+   - 搜索匹配的系统消息支持高亮与自动定位
+   - WebSocket 收到新消息时自动刷新会话详情,保持访客信息实时
+
+5. **消息已读/未读状态(基础版)**
+   - 数据库新增 `is_read` / `read_at` 字段,支持已读记录
+   - 新增 `PUT /messages/read` 接口及 WebSocket `messages_read` 事件,同步状态
+   - 客服端/访客端聊天气泡显示单/双对勾,已读回执实时可见
+   - 对话列表同步显示最后一条消息的已读状态
+
+### 技术细节
+- MySQL 表结构:`conversations` 新增多项访客字段,`messages` 新增 `message_type`
+- 后端:新增 `ConversationDetailRes`、`GetConversationDetail`,并统一时间格式输出
+- 前端:新增访客详情状态管理、系统消息渲染分支、技术信息展示组件
+- WebSocket:收到新消息后同步刷新会话详情,确保右侧数据与消息流一致
+
+### 测试状态
+✅ 通过手动测试:访问 `/chat` 生成新对话,确认数据库记录访客信息与系统消息  
+✅ 通过客服工作台验证:系统消息样式正常,访客详情与数据库数据一致  
+✅ 搜索“关键词”后点击结果,可自动定位系统消息并高亮
+
+### 下一步计划
+- 实现客服端联系信息的手动添加/编辑
+- 基于 `last_seen_at` 和 WebSocket 心跳完成在线状态实时更新
+- 继续扩展系统消息(如客服加入/离开、对话状态变化)
+- 访客位置字段对接外部服务(基于 IP 定位)
+
+## 2025-01-16 18:00:00 UTC
+
+### 完成的工作
+1. **客服工作台四栏布局实现**
+   - 实现最左侧导航菜单栏(固定宽度 64px,浅灰色背景)
+   - 实现左侧对话列表栏(固定宽度 320px,显示所有对话)
+   - 实现中间聊天内容栏(自适应宽度,集成完整聊天功能)
+   - 实现右侧访客详情栏(固定宽度 320px,显示联系信息和技术信息)
+   - 统一顶部栏高度(h-16),确保三栏对齐
+
+2. **中间栏聊天功能集成**
+   - 集成消息显示功能(客服消息在右,访客消息在左)
+   - 集成消息发送功能(支持实时发送)
+   - 集成 WebSocket 实时通信(自动接收新消息)
+   - 实现自动滚动到底部(新消息自动可见)
+   - 实现智能时间格式化(今天显示时间,更早显示日期+时间)
+   - 实现消息加载状态和发送状态
+
+3. **右侧栏访客详情实现**
+   - 显示访客头像(基于 visitor_id 生成颜色)
+   - 显示在线/离线状态(基于对话状态)
+   - 显示联系信息(邮箱、电话、备注,支持添加按钮)
+   - 显示技术信息(网站、来源、位置等,占位待实现)
+   - 实现刷新按钮(可刷新消息)
+
+4. **UI 优化**
+   - 移除右侧栏重复的基础信息(对话ID、状态等已在左侧显示)
+   - 统一联系信息的交互方式(邮箱、电话、备注都有"+ Add"按钮)
+   - 优化导航栏颜色(与聊天内容背景一致)
+   - 优化对话列表显示(头像、ID、状态、时间)
+
+### 技术细节
+- 使用 React 状态管理多个对话和消息
+- 使用 `useEffect` 实现对话切换时自动加载消息
+- 使用 WebSocket 实现实时消息推送
+- 使用 `useRef` 实现自动滚动功能
+- 使用 Tailwind CSS 实现响应式布局
+- 对话切换时自动清空消息列表并重新加载
+
+### 用户体验
+- 无需跳转页面,在同一页面内切换对话
+- 实时接收新消息,无需手动刷新
+- 消息发送后立即显示,体验流畅
+- 界面布局清晰,信息层次分明
+- 右侧栏专注于显示左侧看不到的详细信息
+
+### 测试状态
+✅ 功能测试通过:四栏布局、对话切换、消息发送、实时通信均正常工作
+
+### 下一步计划
+- 实现会话搜索功能(左侧栏搜索框)
+- 实现客服个人资料管理(头像上传、信息修改)
+- 实现访客信息自动收集(技术信息、来源页面等)
+- 实现对话状态管理(在线/离线状态实时更新)
+
+## 2025-01-15 21:00:00 UTC
+
+### 完成的工作
+1. **WebSocket 实时通信功能**
+   - 实现后端 WebSocket Hub 管理器:管理所有客户端连接,按对话ID分组
+   - 实现 WebSocket 客户端处理:处理连接、心跳检测、消息发送
+   - 实现 WebSocket 路由处理:升级 HTTP 连接为 WebSocket 连接
+   - 集成消息推送:消息创建后自动通过 WebSocket 推送给所有相关客户端
+   - 实现前端 WebSocket 客户端:封装连接、自动重连、消息接收
+   - 访客端和客服端都支持实时消息推送
+
+2. **技术实现**
+   - 后端使用 `gorilla/websocket` 库
+   - 前端使用原生 WebSocket API
+   - 实现心跳检测(Ping/Pong)保持连接活跃
+   - 实现自动重连机制(最多 5 次)
+   - 消息去重,避免重复显示
+
+3. **文档更新**
+   - 创建 `doc/WebSocket学习笔记.md`:详细解释 WebSocket 工作原理
+   - 包含前后端代码示例和完整流程说明
+
+### 技术细节
+- WebSocket 路由:`/ws?conversation_id=<对话ID>`
+- 消息格式:`{ type: "new_message", conversation_id: number, data: Message }`
+- 连接升级:HTTP 连接升级为 WebSocket 连接(101 状态码)
+- 心跳间隔:每 54 秒发送一次 Ping
+- 重连策略:断开后等待 3 秒,最多重试 5 次
+
+### 用户体验改进
+- ✅ 访客发送消息后,客服立即看到(无需刷新)
+- ✅ 客服发送消息后,访客立即看到(无需刷新)
+- ✅ 真正的实时双向通信
+- ✅ 连接断开后自动重连
+
+### 测试状态
+✅ 功能实现完成,待测试
+
+## 2025-01-15 20:00:00 UTC
+
+### 完成的工作
+1. **客服端功能完整实现**
+   - 实现客服登录页面(`/`):使用默认管理员账号(admin/admin123)登录
+   - 实现对话列表页面(`/agent/conversations`):显示所有未关闭的对话,支持点击进入聊天
+   - 实现客服聊天页面(`/agent/chat/[conversationId]`):客服可以查看和回复访客消息
+   - 实现登录状态检查:未登录自动跳转到登录页
+   - 实现退出登录功能:清除登录状态并跳转
+
+2. **消息显示优化**
+   - 客服端消息布局:客服消息在右侧(蓝色气泡),访客消息在左侧(白色气泡)
+   - 从客服视角优化 UI,符合客服使用习惯
+
+3. **后端功能完善**
+   - 实现默认管理员账号自动创建(首次启动时创建 admin/admin123)
+   - 实现对话列表查询接口(`GET /conversations`):返回所有未关闭的对话
+   - 实现创建客服账号接口(`POST /admin/users`):管理员可以创建新的客服/管理员账号
+   - 实现退出登录接口(`POST /logout`):用于前端清除登录状态
+
+4. **文档更新**
+   - 更新测试指南:添加完整的客服端测试流程
+   - 更新 CHANGELOG:记录客服端开发过程
+
+### 技术细节
+- 使用 `localStorage` 存储客服登录信息(`agent_user_id`、`agent_username`、`agent_role`)
+- 使用 Next.js 路由保护:检查登录状态,未登录自动跳转
+- 客服聊天页面复用访客聊天页面的核心逻辑,调整消息显示位置
+- 后端使用 `initDefaultAdmin` 函数在首次启动时自动创建默认管理员
+
+### 用户体验
+- 客服登录流程简单:使用默认账号即可登录
+- 对话列表清晰:显示对话状态、访客ID、时间等信息
+- 客服聊天界面直观:客服消息在右,访客消息在左
+- 登录状态管理完善:未登录自动跳转,退出登录清除状态
+
+### 测试状态
+✅ 功能测试通过:登录、对话列表、客服聊天、退出登录均正常工作
+
+### 下一步计划
+- 添加实时消息推送(WebSocket)
+- 添加对话状态管理(关闭对话、重新打开等)
+- 优化 UI 和用户体验
+
+## 2025-01-15 18:00:00 UTC
+
+### 完成的工作
+1. **访客聊天页面完整实现**
+   - 实现消息发送功能(`POST /messages`)
+   - 实现消息拉取功能(`GET /messages`)
+   - 实现消息列表展示(区分访客和客服消息)
+   - 实现自动滚动到底部(新消息自动可见)
+   - 实现时间格式化显示(今天显示时间,更早显示日期+时间)
+   - 优化 UI:删除多余标签,位置已能区分发送者
+
+2. **代码优化和注释**
+   - 为所有代码添加详细的中文注释,解释每行代码的作用
+   - 使用通俗易懂的类比(如"数据盒子"、"书签"等)帮助理解
+   - 优化代码结构,确保逻辑清晰
+
+3. **文档完善**
+   - 创建 `doc/前端学习笔记.md`:核心概念解释、英文单词记忆、常见错误
+   - 创建 `doc/测试指南.md`:完整的测试步骤和问题排查指南
+   - 创建 `doc/系统角色说明.md`:解释访客和客服的区别
+   - 修复测试指南中的 localStorage 说明(同一浏览器标签页共享)
+
+4. **Bug修复**
+   - 修复 `.env` 文件 UTF-8 BOM 编码问题(godotenv 不支持 BOM)
+   - 添加后端 `.env` 文件加载的详细调试信息
+   - 修复代码结构问题(消息列表和输入框位置错误)
+
+### 技术细节
+- 使用 `useState` 管理消息列表、输入框、加载状态
+- 使用 `useEffect` 实现自动拉取消息和自动滚动
+- 使用 `useRef` 实现自动滚动到底部的功能
+- 使用 `localStorage` 持久化访客ID(同一浏览器标签页共享)
+
+### 用户体验
+- 访客无需登录,直接访问 `/chat` 即可使用
+- 发送消息后自动滚动到底部,无需手动滚动
+- 时间显示智能优化:今天只显示时间,更早显示日期+时间
+- UI 简洁:位置已能区分发送者,无需额外标签
+
+### 测试状态
+✅ 功能测试通过:消息发送、接收、显示、自动滚动均正常工作
+
+### 下一步计划
+- 开发客服端功能(客服登录、对话列表、客服聊天页面)
+- 或优化现有功能(消息状态、错误重试、UI 优化等)
+
+## 2025-01-15 10:00:00 UTC
+
+### 完成的工作
+1. 前端配置化改进
+   - 创建 `frontend/lib/config.ts` 统一管理 API 地址配置
+   - 使用环境变量 `NEXT_PUBLIC_API_BASE_URL` 配置后端地址
+   - 移除前端所有硬编码 API 地址
+   - 支持通过 `.env.local` 配置不同环境的后端地址
+2. 更新文档
+   - README 添加前端环境变量配置说明
+   - 补充部署环境切换的使用说明
+
+### 意义
+- 本地开发:无需配置,默认使用 `http://127.0.0.1:8080`
+- 生产部署:只需修改 `.env.local` 中的 `NEXT_PUBLIC_API_BASE_URL` 为实际域名
+- 好处:类比后端数据库配置,前端 API 配置也可通过环境变量灵活切换
+
+## 2025-10-30 12:00:00 UTC
+
+### 完成的工作
+1. 统一后端接口路径与方法
+   - 新增规范路由:`POST /conversation/init`、`POST /messages`、`GET /messages`
+   - 删除旧路由:`/initconversation`、`/createmessage`、`/listmessage`
+2. 数据库配置改为环境变量
+   - 从 `.env` 读取 `DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME`
+   - 移除硬编码 DSN,提升安全性与可配置性
+3. 更新 README
+   - 补充接口文档与后端环境变量示例,增加 `.env` 使用说明
+
+### 风险与注意
+- 该变更为不向后兼容的变更,请确保前端使用新路由
+- 需要在 `backend/.env` 正确配置数据库连接参数
+
+## 2024-12-19 15:30:00 UTC
+
+### 完成的工作
+1. **完善对话初始化功能**
+   - 修复了 `InitConversation` 函数中不完整的代码逻辑
+   - 添加了完整的错误处理和响应返回
+   - 实现了查找现有对话或创建新对话的逻辑
+
+2. **创建项目文档**
+   - 编写了详细的 README.md 文件
+   - 包含项目结构、功能说明、API接口文档
+   - 添加了对话初始化逻辑的详细解释
+
+3. **代码优化**
+   - 统一了变量命名(req 替代 in)
+   - 改进了错误提示信息
+   - 添加了详细的中文注释
+
+### 技术细节
+- 对话初始化逻辑:先查找访客的现有开放对话,如果没有则创建新对话
+- 使用 GORM 进行数据库操作
+- 实现了完整的错误处理机制
+
+### 下一步计划
+- 完善发送消息功能
+- 实现拉取消息功能
+- 添加前端界面
+

+ 487 - 0
doc/WebSocket学习笔记.md

@@ -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
doc/前端学习笔记.md

@@ -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
doc/后端学习笔记.md

@@ -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
doc/待实现需求清单.md

@@ -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
doc/测试指南.md

@@ -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
doc/系统角色说明.md

@@ -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` 就可以发送消息!
+
+**后端已经准备好了**,支持访客和客服两种角色,只是前端客服页面还没做而已。
+

BIN
frontend/.env.local.example


+ 1 - 0
frontend/.gitignore

@@ -32,6 +32,7 @@ yarn-error.log*
 
 # env files (can opt-in for committing if needed)
 .env*
+!.env*.example
 
 # vercel
 .vercel

+ 401 - 0
frontend/app/agent/chat/[conversationId]/page.tsx

@@ -0,0 +1,401 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useParams, useRouter } from "next/navigation";
+
+import { MessageList } from "@/components/dashboard/MessageList";
+import { MessageInput } from "@/components/dashboard/MessageInput";
+import { ChatHeader } from "@/components/dashboard/ChatHeader";
+import { useAuth } from "@/features/agent/hooks/useAuth";
+import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
+import {
+  fetchMessages,
+  sendMessage as sendMessageApi,
+  markMessagesRead,
+} from "@/features/agent/services/messageApi";
+import { fetchConversationDetail } from "@/features/agent/services/conversationApi";
+import {
+  ConversationDetail,
+  ConversationSummary,
+  MessageItem,
+  MessagesReadPayload,
+  ChatWebSocketPayload,
+} from "@/features/agent/types";
+import type { WSMessage } from "@/lib/websocket";
+
+export default function AgentChatPage() {
+  const params = useParams();
+  const router = useRouter();
+
+  const { agent, loading: authLoading } = useAuth();
+
+  const conversationId = useMemo(() => {
+    const id = params?.conversationId;
+    if (!id) {
+      return null;
+    }
+    const parsed = Number.parseInt(String(id), 10);
+    return Number.isNaN(parsed) ? null : parsed;
+  }, [params]);
+
+  const [messages, setMessages] = useState<MessageItem[]>([]);
+  const [conversationDetail, setConversationDetail] =
+    useState<ConversationDetail | null>(null);
+  const [conversationSummary, setConversationSummary] =
+    useState<ConversationSummary | null>(null);
+  const [messageInput, setMessageInput] = useState("");
+  const [loadingMessages, setLoadingMessages] = useState(true);
+  const [sending, setSending] = useState(false);
+  const [highlightKeyword, setHighlightKeyword] = useState("");
+
+  const handleMarkMessagesRead = useCallback(
+    async (
+      conversationIdParam?: number,
+      readerIsAgentParam?: boolean
+    ) => {
+      const targetConversationId = conversationIdParam ?? conversationId;
+      const targetReaderIsAgent = readerIsAgentParam ?? true;
+      if (!targetConversationId) {
+        return;
+      }
+      const result = await markMessagesRead(
+        targetConversationId,
+        targetReaderIsAgent
+      );
+      if (!result || result.message_ids.length === 0) {
+        return;
+      }
+      const idSet = new Set(result.message_ids);
+      setMessages((prev) =>
+        prev.map((msg) =>
+          idSet.has(msg.id)
+            ? {
+                ...msg,
+                is_read: true,
+                read_at: result.read_at ?? msg.read_at ?? null,
+              }
+            : msg
+        )
+      );
+      if (targetReaderIsAgent) {
+        setConversationDetail((prev) =>
+          prev ? { ...prev, unread_count: result.unread_count } : prev
+        );
+        setConversationSummary((prev) =>
+          prev ? { ...prev, unread_count: result.unread_count } : prev
+        );
+      } else {
+        setConversationDetail((prev) =>
+          prev
+            ? {
+                ...prev,
+                last_seen_at: result.read_at ?? prev.last_seen_at ?? null,
+              }
+            : prev
+        );
+      }
+    },
+    [conversationId]
+  );
+
+  const loadConversationDetail = useCallback(async () => {
+    if (!conversationId) {
+      return;
+    }
+    const detail = await fetchConversationDetail(conversationId);
+    if (detail) {
+      setConversationDetail(detail);
+      setConversationSummary({
+        id: detail.id,
+        visitor_id: detail.visitor_id,
+        agent_id: detail.agent_id,
+        status: detail.status,
+        created_at: detail.created_at,
+        updated_at: detail.updated_at,
+        last_message: detail.last_message,
+        unread_count: detail.unread_count ?? 0,
+      });
+    }
+  }, [conversationId]);
+
+  const loadMessages = useCallback(async () => {
+    if (!conversationId) {
+      return;
+    }
+    setLoadingMessages(true);
+    try {
+      const data = await fetchMessages(conversationId);
+      setMessages(data);
+      // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
+    } catch (error) {
+      console.error("拉取消息失败:", error);
+    } finally {
+      setLoadingMessages(false);
+    }
+  }, [conversationId]);
+
+  useEffect(() => {
+    if (!conversationId) {
+      router.push("/agent/dashboard");
+    }
+  }, [conversationId, router]);
+
+  useEffect(() => {
+    if (!conversationId || !agent) {
+      return;
+    }
+    loadConversationDetail();
+    loadMessages();
+  }, [conversationId, agent, loadConversationDetail, loadMessages]);
+
+  const handleSendMessage = useCallback(async () => {
+    if (!conversationId || !agent?.id || !messageInput.trim() || sending) {
+      return;
+    }
+    setSending(true);
+    try {
+      await sendMessageApi({
+        conversationId,
+        content: messageInput,
+        senderId: agent.id,
+      });
+      setMessageInput("");
+    } catch (error) {
+      console.error(error);
+      alert((error as Error).message);
+    } finally {
+      setSending(false);
+    }
+  }, [agent?.id, conversationId, messageInput, sending]);
+
+  const handleNewMessage = useCallback(
+    (message: MessageItem) => {
+      setMessages((prev) => {
+        const exists = prev.some((item) => item.id === message.id);
+        if (exists) {
+          return prev;
+        }
+        return [...prev, message];
+      });
+
+      setConversationDetail((prev) => {
+        if (!prev) {
+          return prev;
+        }
+        const nextUnread =
+          !message.sender_is_agent && message.message_type !== "system_message"
+            ? (prev.unread_count ?? 0) + 1
+            : prev.unread_count ?? 0;
+        return {
+          ...prev,
+          updated_at: message.created_at,
+          unread_count:
+            message.sender_is_agent || message.message_type === "system_message"
+              ? prev.unread_count ?? 0
+              : nextUnread,
+          last_message: {
+            id: message.id,
+            content: message.content,
+            sender_is_agent: message.sender_is_agent,
+            message_type: message.message_type ?? "user_message",
+            is_read: Boolean(message.is_read),
+            read_at: message.read_at ?? null,
+            created_at: message.created_at,
+          },
+        };
+      });
+
+      setConversationSummary((prev) => {
+        if (!prev) {
+          return prev;
+        }
+        const nextUnread =
+          !message.sender_is_agent && message.message_type !== "system_message"
+            ? (prev.unread_count ?? 0) + 1
+            : prev.unread_count ?? 0;
+        return {
+          ...prev,
+          updated_at: message.created_at,
+          unread_count:
+            message.sender_is_agent || message.message_type === "system_message"
+              ? prev.unread_count ?? 0
+              : nextUnread,
+          last_message: {
+            id: message.id,
+            content: message.content,
+            sender_is_agent: message.sender_is_agent,
+            message_type: message.message_type ?? "user_message",
+            is_read: Boolean(message.is_read),
+            read_at: message.read_at ?? null,
+            created_at: message.created_at,
+          },
+        };
+      });
+
+      // 注意:不再自动标记访客消息为已读,而是通过滚动检测来处理
+
+      if (message.conversation_id === conversationId) {
+        loadConversationDetail();
+      }
+    },
+    [conversationId, loadConversationDetail]
+  );
+
+  const handleMessagesReadEvent = useCallback(
+    (payload: MessagesReadPayload) => {
+      if (!conversationId) {
+        return;
+      }
+      // 检查对话ID是否匹配
+      const payloadConversationId = payload?.conversation_id;
+      if (payloadConversationId && payloadConversationId !== conversationId) {
+        return;
+      }
+      const ids = Array.isArray(payload?.message_ids)
+        ? payload.message_ids
+        : [];
+      if (ids.length === 0) {
+        return;
+      }
+      const readAt = payload?.read_at;
+      const readerIsAgent = Boolean(payload?.reader_is_agent);
+      const unreadCount =
+        typeof payload?.unread_count === "number"
+          ? payload.unread_count
+          : undefined;
+
+      // 对于客服端:只有当 reader_is_agent === false 时(访客读取了客服的消息),
+      // 才更新客服消息(sender_is_agent === true)的已读状态
+      if (readerIsAgent) {
+        // 客服读取了访客的消息,更新未读数(不更新消息的已读状态,因为这是客服读取的)
+        if (unreadCount !== undefined) {
+          setConversationDetail((prev) =>
+            prev ? { ...prev, unread_count: unreadCount } : prev
+          );
+          setConversationSummary((prev) =>
+            prev ? { ...prev, unread_count: unreadCount } : prev
+          );
+        }
+        return;
+      }
+
+      // 访客读取了客服的消息,更新客服消息的已读状态
+      const messageIdSet = new Set(ids);
+      setMessages((prev) =>
+        prev.map((msg) =>
+          // 只更新客服自己的消息(sender_is_agent === true)的已读状态
+          messageIdSet.has(msg.id) && msg.sender_is_agent
+            ? {
+                ...msg,
+                is_read: true,
+                read_at: readAt ?? msg.read_at ?? null,
+              }
+            : msg
+        )
+      );
+
+      // 更新访客的最后活跃时间
+      setConversationDetail((prev) =>
+        prev
+          ? { ...prev, last_seen_at: readAt ?? prev.last_seen_at ?? null }
+          : prev
+      );
+    },
+    [conversationId]
+  );
+
+  const handleWebSocketMessage = useCallback(
+    (event: WSMessage<ChatWebSocketPayload>) => {
+      if (!event) {
+        return;
+      }
+      if (event.type === "new_message" && event.data) {
+        const data = event.data as MessageItem;
+        if (typeof data.conversation_id === "number") {
+          handleNewMessage(data);
+        }
+      } else if (event.type === "messages_read") {
+        // 确保处理已读事件,并传入对话ID
+        const payload = event.data as MessagesReadPayload;
+        if (!payload.conversation_id && event.conversation_id) {
+          payload.conversation_id = event.conversation_id;
+        }
+        handleMessagesReadEvent(payload);
+      }
+    },
+    [handleMessagesReadEvent, handleNewMessage]
+  );
+
+  useWebSocket<ChatWebSocketPayload>({
+    conversationId,
+    enabled: Boolean(conversationId),
+    onMessage: handleWebSocketMessage,
+    onError: (error) => console.error("WebSocket 连接错误:", error),
+    onClose: () => console.log("WebSocket 连接已关闭"),
+  });
+
+  const handleBack = useCallback(() => {
+    router.push("/agent/dashboard");
+  }, [router]);
+
+  const unreadCount =
+    conversationDetail?.unread_count ?? conversationSummary?.unread_count ?? 0;
+
+  if (authLoading || !agent) {
+    return (
+      <div className="flex justify-center items-center min-h-screen bg-gray-50 text-gray-600">
+        加载中...
+      </div>
+    );
+  }
+
+  if (!conversationId) {
+    return null;
+  }
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-50">
+      <div className="bg-white border-b border-gray-200">
+        <div className="flex items-center gap-4 px-4 h-16">
+          <button
+            onClick={handleBack}
+            className="px-3 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors"
+          >
+            ← 返回
+          </button>
+          <div className="flex-1">
+            <ChatHeader
+              conversationId={conversationId}
+              lastSeenAt={conversationDetail?.last_seen_at}
+              unreadCount={unreadCount}
+              onMarkAllRead={() => handleMarkMessagesRead(conversationId, true)}
+              onRefresh={() => {
+                loadMessages();
+                loadConversationDetail();
+              }}
+            />
+          </div>
+        </div>
+      </div>
+
+      <div className="flex-1 flex flex-col bg-white">
+        <MessageList
+          messages={messages}
+          loading={loadingMessages}
+          highlightKeyword={highlightKeyword}
+          onHighlightClear={() => setHighlightKeyword("")}
+          currentUserIsAgent={true}
+          conversationId={conversationId}
+          onMarkMessagesRead={handleMarkMessagesRead}
+        />
+        <MessageInput
+          value={messageInput}
+          onChange={setMessageInput}
+          onSubmit={handleSendMessage}
+          sending={sending}
+        />
+      </div>
+    </div>
+  );
+}
+

+ 188 - 0
frontend/app/agent/conversations/page.tsx

@@ -0,0 +1,188 @@
+"use client";
+import { useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { API_BASE_URL } from "@/lib/config";
+
+// 对话类型定义
+interface Conversation {
+  id: number;
+  visitor_id: number;
+  agent_id: number;
+  status: string;
+  created_at: string;
+  updated_at: string;
+}
+
+export default function ConversationsPage() {
+  const [conversations, setConversations] = useState<Conversation[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [username, setUsername] = useState<string>("");
+  const [role, setRole] = useState<string>("");
+  const router = useRouter();
+
+  // 检查是否已登录
+  useEffect(() => {
+    const userId = localStorage.getItem("agent_user_id");
+    const savedUsername = localStorage.getItem("agent_username");
+    const savedRole = localStorage.getItem("agent_role");
+
+    if (!userId || !savedUsername) {
+      // 未登录,跳转到登录页面
+      router.push("/");
+      return;
+    }
+
+    setUsername(savedUsername);
+    setRole(savedRole || "");
+  }, [router]);
+
+  // 拉取对话列表
+  const fetchConversations = async () => {
+    try {
+      const res = await fetch(`${API_BASE_URL}/conversations`);
+      if (res.ok) {
+        const data = await res.json();
+        if (Array.isArray(data)) {
+          setConversations(data);
+        }
+      } else {
+        console.error("获取对话列表失败");
+      }
+    } catch (error) {
+      console.error("获取对话列表失败:", error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  // 页面加载时拉取对话列表
+  useEffect(() => {
+    const userId = localStorage.getItem("agent_user_id");
+    if (userId) {
+      fetchConversations();
+    }
+  }, []);
+
+  // 退出登录
+  const handleLogout = async () => {
+    try {
+      await fetch(`${API_BASE_URL}/logout`, {
+        method: "POST",
+      });
+    } catch (error) {
+      console.error("退出登录失败:", error);
+    } finally {
+      // 清空本地存储
+      localStorage.removeItem("agent_user_id");
+      localStorage.removeItem("agent_username");
+      localStorage.removeItem("agent_role");
+      // 跳转到登录页面
+      router.push("/");
+    }
+  };
+
+  // 格式化时间显示
+  const formatTime = (dateStr: string) => {
+    const date = new Date(dateStr);
+    const now = new Date();
+    const diff = now.getTime() - date.getTime();
+
+    // 今天:只显示时间
+    if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
+      return date.toLocaleTimeString("zh-CN", {
+        hour: "2-digit",
+        minute: "2-digit",
+      });
+    }
+    // 更早:显示日期+时间
+    return date.toLocaleString("zh-CN", {
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  };
+
+  // 点击对话,跳转到聊天页面
+  const handleConversationClick = (conversationId: number) => {
+    router.push(`/agent/chat/${conversationId}`);
+  };
+
+  if (loading) {
+    return (
+      <div className="flex justify-center items-center min-h-screen">
+        <div className="text-lg">加载中...</div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="flex flex-col h-screen bg-gray-50">
+      {/* 顶部标题栏 */}
+      <div className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-white p-4 shadow-md">
+        <div className="flex justify-between items-center">
+          <div>
+            <h1 className="text-xl font-bold">对话列表</h1>
+            <div className="text-sm opacity-90 mt-1">
+              {username} ({role === "admin" ? "管理员" : "客服"})
+            </div>
+          </div>
+          <button
+            onClick={handleLogout}
+            className="px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-lg transition-colors text-sm"
+          >
+            退出登录
+          </button>
+        </div>
+      </div>
+
+      {/* 对话列表区域 */}
+      <div className="flex-1 overflow-y-auto p-4">
+        {conversations.length === 0 ? (
+          <div className="text-center text-gray-400 mt-8">
+            暂无对话
+          </div>
+        ) : (
+          <div className="space-y-2">
+            {conversations.map((conv) => (
+              <div
+                key={conv.id}
+                onClick={() => handleConversationClick(conv.id)}
+                className="bg-white p-4 rounded-lg shadow-sm border border-gray-200 hover:shadow-md cursor-pointer transition-shadow"
+              >
+                <div className="flex justify-between items-start">
+                  <div className="flex-1">
+                    <div className="flex items-center gap-2 mb-1">
+                      <span className="font-medium text-gray-800">
+                        对话 #{conv.id}
+                      </span>
+                      <span
+                        className={`px-2 py-1 rounded text-xs ${
+                          conv.status === "open"
+                            ? "bg-green-100 text-green-700"
+                            : "bg-gray-100 text-gray-700"
+                        }`}
+                      >
+                        {conv.status === "open" ? "进行中" : conv.status}
+                      </span>
+                    </div>
+                    <div className="text-sm text-gray-600">
+                      访客ID: {conv.visitor_id}
+                    </div>
+                    <div className="text-xs text-gray-400 mt-1">
+                      创建时间: {formatTime(conv.created_at)}
+                    </div>
+                  </div>
+                  <div className="text-xs text-gray-400">
+                    最后更新: {formatTime(conv.updated_at)}
+                  </div>
+                </div>
+              </div>
+            ))}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+

+ 7 - 0
frontend/app/agent/dashboard/page.tsx

@@ -0,0 +1,7 @@
+import { DashboardShell } from "@/components/dashboard/DashboardShell";
+
+export default function AgentDashboardPage() {
+  // 页面采用纯客户端渲染,所有业务逻辑由 DashboardShell 承担
+  return <DashboardShell />;
+}
+

+ 302 - 0
frontend/app/chat/page.tsx

@@ -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
frontend/app/page.tsx

@@ -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"
-          >
-            <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>
-        </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}
+        <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}
           />
-          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}
+          <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}
           />
-          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>
+
+          {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"
+          >
+            {loading ? "登录中..." : "登录"}
+          </button>
+        </form>
+
+        <div className="mt-4 text-center text-xs text-gray-400">
+          <p>默认管理员账号:admin / admin123</p>
+        </div>
+      </div>
     </div>
   );
-}
+} 

+ 96 - 0
frontend/components/dashboard/ChatHeader.tsx

@@ -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>
+  );
+}
+

+ 41 - 0
frontend/components/dashboard/ConversationHeader.tsx

@@ -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>
+  );
+}
+

+ 42 - 0
frontend/components/dashboard/ConversationList.tsx

@@ -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>
+  );
+}
+

+ 102 - 0
frontend/components/dashboard/ConversationListItem.tsx

@@ -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>
+  );
+}
+

+ 39 - 0
frontend/components/dashboard/ConversationSearch.tsx

@@ -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>
+  );
+}
+

+ 36 - 0
frontend/components/dashboard/ConversationSidebar.tsx

@@ -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>
+  );
+}
+

+ 75 - 0
frontend/components/dashboard/DashboardHeader.tsx

@@ -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>
+  );
+}
+

+ 229 - 0
frontend/components/dashboard/DashboardShell.tsx

@@ -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>
+  );
+}
+

+ 70 - 0
frontend/components/dashboard/MessageInput.tsx

@@ -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>
+  );
+}
+

+ 354 - 0
frontend/components/dashboard/MessageList.tsx

@@ -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>
+  );
+}
+

+ 113 - 0
frontend/components/dashboard/NavigationSidebar.tsx

@@ -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>
+  );
+}
+

+ 344 - 0
frontend/components/dashboard/ProfileModal.tsx

@@ -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>
+  );
+}
+

+ 362 - 0
frontend/components/dashboard/VisitorDetailPanel.tsx

@@ -0,0 +1,362 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { ConversationDetail, ConversationSummary } from "@/features/agent/types";
+import {
+  formatConversationTime,
+  isVisitorOnline,
+} from "@/utils/format";
+
+type ContactField = "email" | "phone" | "notes";
+type ContactUpdatePayload = Partial<Record<ContactField, string>>;
+
+interface VisitorDetailPanelProps {
+  conversation: ConversationSummary | null;
+  detail: ConversationDetail | null;
+  onRefresh: () => void;
+  onUpdateContact: (payload: ContactUpdatePayload) => Promise<unknown>;
+}
+
+const displayValue = (value?: string | null, placeholder = "暂未填写") => {
+  if (!value) {
+    return placeholder;
+  }
+  const trimmed = value.trim();
+  return trimmed || placeholder;
+};
+
+export function VisitorDetailPanel({
+  conversation,
+  detail,
+  onRefresh,
+  onUpdateContact,
+}: VisitorDetailPanelProps) {
+  const [editingField, setEditingField] = useState<ContactField | null>(null);
+  const [editingValue, setEditingValue] = useState("");
+  const [saving, setSaving] = useState(false);
+  const [errorMessage, setErrorMessage] = useState("");
+
+  const fieldLabels = useMemo<Record<ContactField, string>>(
+    () => ({
+      email: "邮箱",
+      phone: "电话",
+      notes: "备注",
+    }),
+    []
+  );
+  if (!conversation) {
+    return (
+      <div className="w-80 bg-white border-l border-gray-200 flex flex-col min-h-0">
+        <div className="flex-1 flex items-center justify-center">
+          <div className="text-center text-gray-400 text-sm">
+            选择一个对话查看详情
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  const avatarColor = `hsl(${(conversation.visitor_id * 137.5) % 360}, 70%, 50%)`;
+  // 根据 last_seen_at 判断是否在线(优先使用 detail,因为它是最新的)
+  // 如果 detail 不存在,使用 conversation.last_seen_at
+  const isOnline = isVisitorOnline(
+    detail?.last_seen_at ?? conversation.last_seen_at ?? null
+  );
+
+  const getFieldValue = (field: ContactField) => {
+    if (!detail) {
+      return "";
+    }
+    switch (field) {
+      case "email":
+        return detail.email ?? "";
+      case "phone":
+        return detail.phone ?? "";
+      case "notes":
+        return detail.notes ?? "";
+      default:
+        return "";
+    }
+  };
+
+  const handleOpenEditor = (field: ContactField) => {
+    setEditingField(field);
+    setEditingValue(getFieldValue(field));
+    setErrorMessage("");
+  };
+
+  const handleCloseEditor = () => {
+    if (saving) {
+      return;
+    }
+    setEditingField(null);
+    setEditingValue("");
+    setErrorMessage("");
+  };
+
+  const handleSubmit = async () => {
+    if (!editingField) {
+      return;
+    }
+    setSaving(true);
+    try {
+      const payload: ContactUpdatePayload = {
+        [editingField]: editingValue,
+      };
+      await onUpdateContact(payload);
+      setEditingField(null);
+      setEditingValue("");
+      setErrorMessage("");
+    } catch (error) {
+      setErrorMessage((error as Error).message || "保存失败,请稍后重试");
+    } finally {
+      setSaving(false);
+    }
+  };
+
+  const actionLabel = (field: ContactField) => {
+    const current = getFieldValue(field).trim();
+    return current ? "编辑" : "+ Add";
+  };
+
+  return (
+    <div className="w-80 bg-white border-l border-gray-200 flex flex-col min-h-0">
+      <div className="h-16 border-b border-gray-200 flex items-center justify-between px-4 flex-shrink-0">
+        <div className="flex items-center gap-3">
+          <div
+            className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0"
+            style={{ backgroundColor: avatarColor }}
+          >
+            {conversation.visitor_id.toString().slice(-2)}
+          </div>
+          <div>
+            <div className="font-semibold text-gray-800 text-sm">
+              访客 #{conversation.visitor_id}
+            </div>
+            <div className="text-xs text-gray-500">
+              {isOnline ? (
+                <span className="text-green-600">● 在线</span>
+              ) : (
+                <span className="text-gray-400">● 离线</span>
+              )}
+            </div>
+          </div>
+        </div>
+        <div className="flex items-center gap-2">
+          <button
+            className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
+            title="刷新"
+            onClick={onRefresh}
+          >
+            <svg
+              className="w-5 h-5 text-gray-600"
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
+              />
+            </svg>
+          </button>
+          <button
+            className="w-8 h-8 flex items-center justify-center hover:bg-gray-100 rounded transition-colors"
+            title="更多选项"
+          >
+            <svg
+              className="w-5 h-5 text-gray-600"
+              fill="none"
+              stroke="currentColor"
+              viewBox="0 0 24 24"
+            >
+              <path
+                strokeLinecap="round"
+                strokeLinejoin="round"
+                strokeWidth={2}
+                d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"
+              />
+            </svg>
+          </button>
+        </div>
+      </div>
+
+      <div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
+        {/* 联系信息区域 */}
+        <div className="p-4 border-b border-gray-200">
+          <h3 className="text-sm font-semibold text-gray-700 mb-3">联系信息</h3>
+          <div className="space-y-3 text-sm">
+            <div>
+              <div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
+                <span>邮箱</span>
+                <button
+                  className="text-blue-500 text-xs hover:text-blue-600"
+                  onClick={() => handleOpenEditor("email")}
+                >
+                  {actionLabel("email")}
+                </button>
+              </div>
+              <div className="text-xs text-gray-700 break-all">
+                {displayValue(detail?.email, "暂未填写")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
+                <span>电话</span>
+                <button
+                  className="text-blue-500 text-xs hover:text-blue-600"
+                  onClick={() => handleOpenEditor("phone")}
+                >
+                  {actionLabel("phone")}
+                </button>
+              </div>
+              <div className="text-xs text-gray-700 break-all">
+                {displayValue(detail?.phone, "暂未填写")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs flex items-center justify-between">
+                <span>备注</span>
+                <button
+                  className="text-blue-500 text-xs hover:text-blue-600"
+                  onClick={() => handleOpenEditor("notes")}
+                >
+                  {actionLabel("notes")}
+                </button>
+              </div>
+              <div className="text-xs text-gray-700 whitespace-pre-wrap break-words min-h-[1rem]">
+                {displayValue(detail?.notes, "暂无备注")}
+              </div>
+            </div>
+          </div>
+        </div>
+
+        {/* 技术信息区域 */}
+        <div className="p-4">
+          <h3 className="text-sm font-semibold text-gray-700 mb-3">技术信息</h3>
+          <div className="space-y-3 text-sm">
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">网站</div>
+              {detail?.website ? (
+                <a
+                  href={detail.website}
+                  target="_blank"
+                  rel="noreferrer"
+                  className="text-xs text-blue-600 break-all hover:underline"
+                >
+                  {detail.website}
+                </a>
+              ) : (
+                <div className="text-gray-400 text-xs">暂未收集</div>
+              )}
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">来源</div>
+              {detail?.referrer ? (
+                <a
+                  href={detail.referrer}
+                  target="_blank"
+                  rel="noreferrer"
+                  className="text-xs text-blue-600 break-all hover:underline"
+                >
+                  {detail.referrer}
+                </a>
+              ) : (
+                <div className="text-gray-400 text-xs">暂无来源信息</div>
+              )}
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">语言</div>
+              <div className="text-gray-700 text-xs">
+                {displayValue(detail?.language, "暂未收集")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">浏览器</div>
+              <div className="text-gray-700 text-xs">
+                {displayValue(detail?.browser, "暂未收集")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">操作系统</div>
+              <div className="text-gray-700 text-xs">
+                {displayValue(detail?.os, "暂未收集")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">IP 地址</div>
+              <div className="text-gray-700 text-xs">
+                {displayValue(detail?.ip_address, "暂未收集")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">位置</div>
+              <div className="text-gray-700 text-xs">
+                {displayValue(detail?.location, "暂未收集")}
+              </div>
+            </div>
+            <div>
+              <div className="text-gray-500 mb-1 text-xs">最后活跃</div>
+              <div className="text-gray-700 text-xs">
+                {detail?.last_seen_at
+                  ? formatConversationTime(detail.last_seen_at)
+                  : "未知"}
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+
+      {editingField && (
+        <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 px-4">
+          <div className="bg-white rounded-xl shadow-xl w-full max-w-sm p-6">
+            <h3 className="text-base font-semibold text-gray-800 mb-3">
+              编辑{fieldLabels[editingField]}
+            </h3>
+            {editingField === "notes" ? (
+              <textarea
+                className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 resize-none h-32"
+                value={editingValue}
+                onChange={(event) => setEditingValue(event.target.value)}
+                placeholder={`请输入${fieldLabels[editingField]}`}
+              />
+            ) : (
+              <input
+                type="text"
+                className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
+                value={editingValue}
+                onChange={(event) => setEditingValue(event.target.value)}
+                placeholder={`请输入${fieldLabels[editingField]}`}
+              />
+            )}
+            {errorMessage && (
+              <div className="text-xs text-red-500 mt-2">{errorMessage}</div>
+            )}
+            <div className="mt-4 flex justify-end gap-2">
+              <button
+                type="button"
+                className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 text-gray-700 hover:bg-gray-200"
+                onClick={handleCloseEditor}
+                disabled={saving}
+              >
+                取消
+              </button>
+              <button
+                type="button"
+                className="px-3 py-1.5 text-sm rounded-lg bg-blue-500 text-white hover:bg-blue-600 disabled:bg-blue-300 disabled:cursor-not-allowed"
+                onClick={handleSubmit}
+                disabled={saving}
+              >
+                {saving ? "保存中..." : "保存"}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+

+ 44 - 0
frontend/features/agent/hooks/useAuth.ts

@@ -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,
+  };
+}
+

+ 162 - 0
frontend/features/agent/hooks/useConversations.ts

@@ -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;
+}
+

+ 436 - 0
frontend/features/agent/hooks/useMessages.ts

@@ -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;
+}
+

+ 95 - 0
frontend/features/agent/hooks/useProfile.ts

@@ -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,
+  };
+}
+

+ 60 - 0
frontend/features/agent/hooks/useWebSocket.ts

@@ -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]);
+}
+

+ 8 - 0
frontend/features/agent/services/authApi.ts

@@ -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",
+  });
+}
+

+ 98 - 0
frontend/features/agent/services/conversationApi.ts

@@ -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 ?? "",
+  };
+}
+

+ 84 - 0
frontend/features/agent/services/messageApi.ts

@@ -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,
+  };
+}
+

+ 87 - 0
frontend/features/agent/services/profileApi.ts

@@ -0,0 +1,87 @@
+// 客服个人资料 API 服务
+import { API_BASE_URL } from "@/lib/config";
+import { Profile } from "../types";
+
+// 获取个人资料
+export async function fetchProfile(userId: number): Promise<Profile | null> {
+  const res = await fetch(`${API_BASE_URL}/agent/profile/${userId}`, {
+    cache: "no-store",
+  });
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(
+      error.error || error.message || `获取个人资料失败 (${res.status})`
+    );
+  }
+  const data = await res.json();
+  return {
+    id: data.id ?? 0,
+    username: data.username ?? "",
+    role: data.role ?? "",
+    avatar_url: data.avatar_url ?? "",
+    nickname: data.nickname ?? "",
+    email: data.email ?? "",
+  };
+}
+
+// 更新个人资料
+export interface UpdateProfilePayload {
+  nickname?: string;
+  email?: string;
+}
+
+export async function updateProfile(
+  userId: number,
+  payload: UpdateProfilePayload
+): Promise<Profile> {
+  const res = await fetch(`${API_BASE_URL}/agent/profile/${userId}`, {
+    method: "PUT",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify(payload),
+  });
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(
+      error.error || error.message || `更新个人资料失败 (${res.status})`
+    );
+  }
+  const data = await res.json();
+  return {
+    id: data.id ?? 0,
+    username: data.username ?? "",
+    role: data.role ?? "",
+    avatar_url: data.avatar_url ?? "",
+    nickname: data.nickname ?? "",
+    email: data.email ?? "",
+  };
+}
+
+// 上传头像
+export async function uploadAvatar(
+  userId: number,
+  file: File
+): Promise<Profile> {
+  const formData = new FormData();
+  formData.append("avatar", file);
+
+  const res = await fetch(`${API_BASE_URL}/agent/avatar/${userId}`, {
+    method: "POST",
+    body: formData,
+  });
+  if (!res.ok) {
+    const error = await res.json().catch(() => ({}));
+    throw new Error(
+      error.error || error.message || `上传头像失败 (${res.status})`
+    );
+  }
+  const data = await res.json();
+  return {
+    id: data.id ?? 0,
+    username: data.username ?? "",
+    role: data.role ?? "",
+    avatar_url: data.avatar_url ?? "",
+    nickname: data.nickname ?? "",
+    email: data.email ?? "",
+  };
+}
+

+ 83 - 0
frontend/features/agent/types.ts

@@ -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;
+

+ 45 - 0
frontend/features/visitor/services/conversationApi.ts

@@ -0,0 +1,45 @@
+import { API_BASE_URL } from "@/lib/config";
+
+export interface InitVisitorConversationPayload {
+  visitorId: number;
+  website?: string;
+  referrer?: string;
+  browser?: string;
+  os?: string;
+  language?: string;
+  ipAddress?: string;
+}
+
+export interface InitVisitorConversationResult {
+  conversation_id: number;
+  status: string;
+}
+
+export async function initVisitorConversation(
+  payload: InitVisitorConversationPayload
+): Promise<InitVisitorConversationResult> {
+  const res = await fetch(`${API_BASE_URL}/conversation/init`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({
+      visitor_id: payload.visitorId,
+      website: payload.website,
+      referrer: payload.referrer,
+      browser: payload.browser,
+      os: payload.os,
+      language: payload.language,
+      ip_address: payload.ipAddress,
+    }),
+  });
+
+  if (!res.ok) {
+    throw new Error("初始化对话失败");
+  }
+
+  const data = await res.json();
+  return {
+    conversation_id: data.conversation_id ?? 0,
+    status: data.status ?? "open",
+  };
+}
+

+ 6 - 0
frontend/lib/config.ts

@@ -0,0 +1,6 @@
+// 统一的 API 配置
+// 读取 NEXT_PUBLIC_ 开头的环境变量(Next.js 会自动暴露到浏览器)
+// 使用默认值方便本地开发
+export const API_BASE_URL =
+  process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8080";
+

+ 162 - 0
frontend/lib/websocket.ts

@@ -0,0 +1,162 @@
+// WebSocket 客户端工具
+// 用于连接后端 WebSocket 服务,接收实时消息
+
+// WebSocket 消息类型
+export interface WSMessage<T = unknown> {
+  type: string; // "new_message" | "conversation_update" 等
+  conversation_id: number;
+  data: T; // 消息内容(Message 对象)
+}
+
+// WebSocket 连接选项
+export interface WSOptions<T = unknown> {
+  conversationId: number; // 对话ID
+  isVisitor?: boolean; // 是否是访客(默认为 true)
+  onMessage?: (message: WSMessage<T>) => void; // 收到消息时的回调
+  onError?: (error: Event) => void; // 连接错误时的回调
+  onClose?: () => void; // 连接关闭时的回调
+}
+
+// WebSocket 客户端类
+export class WSClient<T = unknown> {
+  private ws: WebSocket | null = null;
+  private conversationId: number;
+  private isVisitor: boolean;
+  private onMessage?: (message: WSMessage<T>) => void;
+  private onError?: (error: Event) => void;
+  private onClose?: () => void;
+  private reconnectTimer: NodeJS.Timeout | null = null;
+  private reconnectAttempts = 0;
+  private maxReconnectAttempts = 5;
+  private reconnectDelay = 3000; // 3秒
+
+  constructor(options: WSOptions<T>) {
+    this.conversationId = options.conversationId;
+    this.isVisitor = options.isVisitor !== undefined ? options.isVisitor : true;
+    this.onMessage = options.onMessage;
+    this.onError = options.onError;
+    this.onClose = options.onClose;
+  }
+
+  // 连接 WebSocket
+  connect() {
+    // 如果已经连接,先断开
+    if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
+      this.ws.close();
+      this.ws = null;
+    }
+
+    // 获取 API 基地址
+    const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8080";
+    // 将 http:// 替换为 ws://,将 https:// 替换为 wss://
+    const wsUrl =
+      apiBaseUrl.replace(/^http/, "ws") +
+      `/ws?conversation_id=${this.conversationId}&is_visitor=${this.isVisitor}`;
+
+    try {
+      this.ws = new WebSocket(wsUrl);
+
+      this.ws.onopen = () => {
+        this.reconnectAttempts = 0; // 重置重连次数
+      };
+
+      this.ws.onmessage = (event) => {
+        try {
+          const message: WSMessage<T> = JSON.parse(event.data);
+          if (this.onMessage) {
+            this.onMessage(message);
+          }
+        } catch (error) {
+          console.error(
+            `❌ 解析 WebSocket 消息失败: 对话ID=${this.conversationId}`,
+            error
+          );
+        }
+      };
+
+      this.ws.onerror = (error) => {
+        const state = this.ws?.readyState;
+        const stateText =
+          state === WebSocket.CONNECTING
+            ? "连接中"
+            : state === WebSocket.OPEN
+              ? "已连接"
+              : state === WebSocket.CLOSING
+                ? "关闭中"
+                : state === WebSocket.CLOSED
+                  ? "已关闭"
+                  : "未知";
+        const url = this.ws?.url || wsUrl;
+        console.error(
+          `❌ WebSocket 错误: 对话ID=${this.conversationId}, 状态=${stateText}, URL=${url}`,
+          error
+        );
+        if (this.onError) {
+          this.onError(error);
+        }
+      };
+
+      this.ws.onclose = (event) => {
+        this.ws = null;
+        if (this.onClose) {
+          this.onClose();
+        }
+        // 只有在非正常关闭时才尝试重连(避免在开发模式下频繁重连)
+        const code = event.code;
+        const wasClean = event.wasClean;
+        if (!wasClean && code !== 1000) {
+          this.attemptReconnect();
+        }
+      };
+    } catch (error) {
+      console.error(
+        `❌ 创建 WebSocket 连接失败: 对话ID=${this.conversationId}, URL=${wsUrl}`,
+        error
+      );
+      if (this.onError) {
+        // 创建一个错误事件对象
+        const errorEvent = new Event("error");
+        this.onError(errorEvent);
+      }
+    }
+  }
+
+  // 尝试重连
+  private attemptReconnect() {
+    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
+      console.error(`❌ WebSocket 重连次数已达上限,停止重连: 对话ID=${this.conversationId}`);
+      return;
+    }
+
+    this.reconnectAttempts++;
+    this.reconnectTimer = setTimeout(() => {
+      this.connect();
+    }, this.reconnectDelay);
+  }
+
+  // 断开连接
+  disconnect() {
+    // 取消重连
+    if (this.reconnectTimer) {
+      clearTimeout(this.reconnectTimer);
+      this.reconnectTimer = null;
+    }
+
+    // 关闭 WebSocket 连接
+    if (this.ws) {
+      // 设置标志,避免重连
+      this.reconnectAttempts = this.maxReconnectAttempts;
+      // 关闭连接
+      if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
+        this.ws.close();
+      }
+      this.ws = null;
+    }
+  }
+
+  // 检查是否已连接
+  isConnected(): boolean {
+    return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
+  }
+}
+

+ 38 - 0
frontend/utils/avatar.ts

@@ -0,0 +1,38 @@
+// 头像工具函数
+import { API_BASE_URL } from "@/lib/config";
+
+/**
+ * 获取完整的头像 URL
+ * 如果 avatarUrl 已经是完整 URL(以 http:// 或 https:// 开头),直接返回
+ * 否则拼接 API_BASE_URL
+ */
+export function getAvatarUrl(avatarUrl: string | null | undefined): string | null {
+  if (!avatarUrl) {
+    return null;
+  }
+  // 如果已经是完整 URL,直接返回
+  if (avatarUrl.startsWith("http://") || avatarUrl.startsWith("https://")) {
+    return avatarUrl;
+  }
+  // 如果是相对路径,拼接 API_BASE_URL
+  // 确保路径以 / 开头
+  const path = avatarUrl.startsWith("/") ? avatarUrl : `/${avatarUrl}`;
+  return `${API_BASE_URL}${path}`;
+}
+
+/**
+ * 根据用户信息生成头像颜色
+ */
+export function getAvatarColor(seed: string | number): string {
+  const value = typeof seed === "string" ? seed.length : seed;
+  return `hsl(${(value * 137.5) % 360}, 70%, 50%)`;
+}
+
+/**
+ * 获取头像显示文本(首字母或用户名)
+ */
+export function getAvatarInitial(username: string, nickname?: string): string {
+  const displayName = nickname || username || "?";
+  return displayName.charAt(0).toUpperCase();
+}
+

+ 78 - 0
frontend/utils/format.ts

@@ -0,0 +1,78 @@
+export function formatConversationTime(dateStr: string | null | undefined): string {
+  if (!dateStr) {
+    return "-";
+  }
+  const date = new Date(dateStr);
+  if (Number.isNaN(date.getTime())) {
+    return "-";
+  }
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+
+  if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
+    return date.toLocaleTimeString("zh-CN", {
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  }
+
+  return date.toLocaleString("zh-CN", {
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+  });
+}
+
+export function formatMessageTime(dateStr: string | null | undefined): string {
+  if (!dateStr) {
+    return "";
+  }
+  const date = new Date(dateStr);
+  if (Number.isNaN(date.getTime())) {
+    return "";
+  }
+  const now = new Date();
+  const diff = now.getTime() - date.getTime();
+
+  if (diff < 24 * 3600 * 1000 && date.getDate() === now.getDate()) {
+    return date.toLocaleTimeString("zh-CN", {
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  }
+
+  return date.toLocaleString("zh-CN", {
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+  });
+}
+
+export function buildMessagePreview(content: string, maxLength = 50): string {
+  if (!content) {
+    return "";
+  }
+  if (content.length <= maxLength) {
+    return content;
+  }
+  return `${content.substring(0, maxLength)}...`;
+}
+
+// 判断访客是否在线(根据 last_seen_at 字段)
+// 如果 last_seen_at 在最近 10 秒内,则认为在线
+export function isVisitorOnline(lastSeenAt: string | null | undefined): boolean {
+  if (!lastSeenAt) {
+    return false;
+  }
+  const lastSeen = new Date(lastSeenAt);
+  if (Number.isNaN(lastSeen.getTime())) {
+    return false;
+  }
+  const now = new Date();
+  const diff = now.getTime() - lastSeen.getTime();
+  // 10 秒内认为在线
+  return diff < 10 * 1000;
+}
+

+ 23 - 0
frontend/utils/highlight.tsx

@@ -0,0 +1,23 @@
+import { ReactNode } from "react";
+
+export function highlightText(text: string, keyword: string): ReactNode {
+  if (!keyword.trim()) {
+    return text;
+  }
+  const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+  const regex = new RegExp(`(${escaped})`, "gi");
+  const parts = text.split(regex);
+  return parts.map((part, index) =>
+    regex.test(part) ? (
+      <mark
+        key={`${part}-${index}`}
+        className="bg-yellow-300 text-gray-900 px-1 rounded"
+      >
+        {part}
+      </mark>
+    ) : (
+      part
+    )
+  );
+}
+

+ 50 - 0
frontend/utils/storage.ts

@@ -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);
+}
+