537yaha há 7 meses atrás
pai
commit
109e927152
100 ficheiros alterados com 9084 adições e 1191 exclusões
  1. 53 0
      .gitignore
  2. 151 166
      README.md
  3. 266 2
      backend/controller/admin_controller.go
  4. 157 0
      backend/controller/ai_config_controller.go
  5. 85 33
      backend/controller/conversation_controller.go
  6. 149 0
      backend/controller/faq_controller.go
  7. 120 8
      backend/controller/message_controller.go
  8. 7 5
      backend/controller/profile_controller.go
  9. 35 0
      backend/controller/visitor_controller.go
  10. 47 0
      backend/infra/storage.go
  11. 95 11
      backend/main.go
  12. 26 0
      backend/models/ai_config.go
  13. 17 0
      backend/models/faq.go
  14. 21 9
      backend/models/user.go
  15. 305 0
      backend/package-lock.json
  16. 6 0
      backend/package.json
  17. 79 0
      backend/repository/ai_config_repository.go
  18. 74 0
      backend/repository/faq_repository.go
  19. 46 1
      backend/repository/message_repository.go
  20. 41 0
      backend/repository/user_repository.go
  21. 31 2
      backend/router/router.go
  22. 228 0
      backend/service/ai_config_service.go
  23. 262 0
      backend/service/ai_provider.go
  24. 154 0
      backend/service/ai_service.go
  25. 137 16
      backend/service/conversation_service.go
  26. 168 0
      backend/service/faq_service.go
  27. 112 5
      backend/service/message_service.go
  28. 10 6
      backend/service/profile_service.go
  29. 100 18
      backend/service/types.go
  30. 257 0
      backend/service/user_service.go
  31. 60 0
      backend/service/visitor_service.go
  32. 0 0
      backend/uploads/avatars/user_4_1764663058.png
  33. BIN
      backend/uploads/messages/23/1763991925_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  34. BIN
      backend/uploads/messages/23/1763991942_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  35. BIN
      backend/uploads/messages/23/1763991961_委托服务合同-5000.pdf
  36. BIN
      backend/uploads/messages/23/1763992488_委托服务合同-5000.pdf
  37. BIN
      backend/uploads/messages/23/1763992539_微信图片_20251120123549_93_228.png
  38. BIN
      backend/uploads/messages/23/1763992817_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  39. BIN
      backend/uploads/messages/23/1763992836_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  40. BIN
      backend/uploads/messages/23/1763992845_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  41. BIN
      backend/uploads/messages/23/1763993203_112048.png
  42. BIN
      backend/uploads/messages/23/1763993225_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  43. BIN
      backend/uploads/messages/23/1763993238_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  44. BIN
      backend/uploads/messages/23/1763993260_112048.png
  45. BIN
      backend/uploads/messages/23/1764039045_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  46. BIN
      backend/uploads/messages/23/1764039057_c248590134d469ed61a3d722a2e733a.png
  47. BIN
      backend/uploads/messages/23/1764039076_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png
  48. BIN
      backend/uploads/messages/23/1764039089_c248590134d469ed61a3d722a2e733a.png
  49. BIN
      backend/uploads/messages/23/1764039200_c248590134d469ed61a3d722a2e733a.png
  50. BIN
      backend/uploads/messages/23/1764039214_DALL·E 2024-06-29 16.49.18 - A minimalist avatar for a social media account featuring the correctly
  51. BIN
      backend/uploads/messages/23/1764039231_c248590134d469ed61a3d722a2e733a.png
  52. BIN
      backend/uploads/messages/23/1764577446_image.png
  53. 118 0
      backend/utils/crypto.go
  54. 5 1
      backend/websocket/client.go
  55. 12 1
      backend/websocket/handler.go
  56. 39 3
      backend/websocket/hub.go
  57. 65 0
      frontend/README-配置说明.md
  58. 11 5
      frontend/app/agent/chat/[conversationId]/page.tsx
  59. 8 6
      frontend/app/agent/conversations/page.tsx
  60. 476 0
      frontend/app/agent/faqs/page.tsx
  61. 108 0
      frontend/app/agent/login/page.tsx
  62. 513 0
      frontend/app/agent/settings/page.tsx
  63. 667 0
      frontend/app/agent/users/page.tsx
  64. 22 276
      frontend/app/chat/page.tsx
  65. 136 11
      frontend/app/globals.css
  66. 2 2
      frontend/app/layout.tsx
  67. 472 93
      frontend/app/page.tsx
  68. 21 0
      frontend/components.json
  69. 64 0
      frontend/components/ScreenshotDisplay.tsx
  70. 32 33
      frontend/components/dashboard/ChatHeader.tsx
  71. 38 20
      frontend/components/dashboard/ConversationHeader.tsx
  72. 3 3
      frontend/components/dashboard/ConversationList.tsx
  73. 18 17
      frontend/components/dashboard/ConversationListItem.tsx
  74. 6 4
      frontend/components/dashboard/ConversationSearch.tsx
  75. 6 2
      frontend/components/dashboard/ConversationSidebar.tsx
  76. 7 40
      frontend/components/dashboard/DashboardHeader.tsx
  77. 146 80
      frontend/components/dashboard/DashboardShell.tsx
  78. 252 26
      frontend/components/dashboard/MessageInput.tsx
  79. 295 115
      frontend/components/dashboard/MessageList.tsx
  80. 226 36
      frontend/components/dashboard/NavigationSidebar.tsx
  81. 20 0
      frontend/components/dashboard/PageWrapper.tsx
  82. 53 63
      frontend/components/dashboard/ProfileModal.tsx
  83. 99 72
      frontend/components/dashboard/VisitorDetailPanel.tsx
  84. 153 0
      frontend/components/layout/Footer.tsx
  85. 99 0
      frontend/components/layout/Header.tsx
  86. 121 0
      frontend/components/layout/ResponsiveLayout.tsx
  87. 6 0
      frontend/components/layout/index.ts
  88. 37 0
      frontend/components/ui/badge.tsx
  89. 57 0
      frontend/components/ui/button.tsx
  90. 80 0
      frontend/components/ui/card.tsx
  91. 31 0
      frontend/components/ui/checkbox.tsx
  92. 121 0
      frontend/components/ui/dialog.tsx
  93. 26 0
      frontend/components/ui/input.tsx
  94. 27 0
      frontend/components/ui/label.tsx
  95. 30 0
      frontend/components/ui/separator.tsx
  96. 145 0
      frontend/components/ui/sheet.tsx
  97. 120 0
      frontend/components/ui/tabs.tsx
  98. 25 0
      frontend/components/ui/textarea.tsx
  99. 156 0
      frontend/components/visitor/ChatModeSelector.tsx
  100. 611 0
      frontend/components/visitor/ChatWidget.tsx

+ 53 - 0
.gitignore

@@ -0,0 +1,53 @@
+# ---- Go ----
+# 编译后的二进制文件
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+*.test
+
+# go build / install 产生的可执行文件
+bin/
+build/
+dist/
+
+# Go modules 缓存(不要上传)
+vendor/
+
+# IDE 临时文件
+*.out
+
+# ---- Node / Next.js ----
+# 依赖包
+node_modules/
+
+# 构建输出目录
+.next/
+out/
+
+# 调试日志
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# 环境变量文件(敏感信息)
+.env
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+# 覆盖率结果
+coverage/
+
+# Mac / Windows / Linux 系统文件
+.DS_Store
+Thumbs.db
+
+# 编辑器配置
+.vscode/
+.idea/
+
+# 文档目录不上传
+doc/

+ 151 - 166
README.md

@@ -1,209 +1,194 @@
 # AI-CS 智能客服系统
 
-## 项目简介
+> 一个融合 AI 技术与人工客服的现代化智能客服解决方案
 
-这是一个基于Go后端和Next.js前端的智能客服系统,用于处理访客与客服之间的对话交流。
+## ✨ 核心特性
 
-## 项目结构
+- 🤖 **AI 客服支持**:支持多厂商 AI 模型,可配置 API 和模型选择
+- 👥 **人工客服**:实时在线状态显示,支持多客服协作
+- 💬 **实时通信**:基于 WebSocket 的双向实时消息推送
+- 📁 **文件传输**:支持图片、文档上传和预览
+- 📚 **FAQ 管理**:知识库管理,关键词搜索
+- 👤 **用户管理**:完整的用户权限管理系统
+- 🎨 **现代化 UI**:基于 Shadcn UI 的响应式设计
+- 🔌 **访客小窗插件**:可嵌入任何网站的客服小窗组件
+- 🌐 **产品官网**:内置产品展示页面
 
-```
-AI-CS/
-├── backend/          # Go后端服务
-│   ├── controller/   # 控制器层
-│   ├── models/       # 数据模型
-│   ├── service/      # 业务逻辑层
-│   ├── repository/   # 数据访问层
-│   ├── middleware/   # 中间件
-│   ├── router/       # 路由配置
-│   ├── infra/        # 基础设施(数据库等)
-│   └── utils/        # 工具函数
-└── frontend/         # Next.js前端应用
-    ├── app/          # 应用页面
-    ├── public/       # 静态资源
-    └── ...
-```
-
-## 核心功能
+## 🏗️ 技术栈
 
-### 1. 用户管理
-- **用户注册** (`Register`): 创建新用户账户
-- **用户登录** (`Login`): 验证用户身份
+### 后端
+- **语言**: Go 1.21+
+- **框架**: Gin (Web 框架)
+- **ORM**: GORM
+- **数据库**: MySQL 8.0+
+- **实时通信**: WebSocket (gorilla/websocket)
+- **密码加密**: bcrypt
+- **文件存储**: 本地存储(可扩展为云存储)
 
-### 2. 对话管理
-- **初始化对话** (`InitConversation`): 为访客创建或获取现有对话
-- **发送消息**: 处理消息发送
-- **拉取消息**: 获取对话历史
+### 前端
+- **框架**: Next.js 14+ (App Router)
+- **语言**: TypeScript
+- **UI 组件**: Shadcn UI
+- **样式**: Tailwind CSS
+- **状态管理**: React Hooks
+- **实时通信**: WebSocket Client
 
-## 数据模型
+## 📁 项目结构
 
-### 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"`
-}
+AI-CS/
+├── backend/              # Go 后端服务
+│   ├── controller/       # 控制器层(HTTP 处理)
+│   ├── service/          # 业务逻辑层
+│   ├── repository/       # 数据访问层
+│   ├── models/           # 数据模型
+│   ├── router/           # 路由配置
+│   ├── middleware/       # 中间件(认证、CORS、日志)
+│   ├── websocket/        # WebSocket Hub
+│   ├── infra/            # 基础设施(数据库、存储)
+│   ├── utils/            # 工具函数(加密、验证等)
+│   └── main.go           # 入口文件
+├── frontend/             # Next.js 前端应用
+│   ├── app/              # 页面和路由
+│   │   ├── page.tsx      # 官网首页
+│   │   ├── chat/         # 访客聊天页面
+│   │   └── agent/        # 客服工作台
+│   ├── components/       # React 组件
+│   │   ├── ui/           # Shadcn UI 基础组件
+│   │   ├── dashboard/    # 客服端组件
+│   │   ├── visitor/      # 访客端组件
+│   │   └── layout/       # 布局组件
+│   ├── features/         # 功能模块
+│   │   ├── agent/        # 客服端功能
+│   │   └── visitor/      # 访客端功能
+│   └── lib/              # 工具库和配置
+├── doc/                  # 项目文档
+│   ├── CHANGELOG.md      # 更新日志
+│   ├── 测试指南.md       # 测试文档
+│   ├── 后端学习笔记.md   # 后端架构说明
+│   └── 前端学习笔记.md   # 前端架构说明
+└── README.md             # 本文件
 ```
 
-### 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
+- Go 1.21 或更高版本
+- Node.js 18+ 和 npm/yarn
+- MySQL 8.0 或更高版本
 
-### 前端
-- **框架**: Next.js
-- **语言**: TypeScript
-- **样式**: CSS
+### 1. 克隆项目
+
+```bash
+git clone <repository-url>
+cd AI-CS
+```
 
-## 开发环境设置
+### 2. 配置后端
 
-### 后端启动
 ```bash
 cd backend
-cp .env.example .env  # 按需修改数据库配置
+
+# 创建 .env 文件
+cat > .env << EOF
+DB_HOST=localhost
+DB_PORT=3306
+DB_USER=root
+DB_PASSWORD=your_password
+DB_NAME=ai_cs
+EOF
+
+# 安装依赖
 go mod tidy
+
+# 启动服务(默认端口 8080)
 go run main.go
 ```
 
-### 前端启动
+### 3. 配置前端
+
 ```bash
 cd frontend
-cp .env.local.example .env.local  # 可选:如需自定义 API 地址
+
+# 安装依赖
 npm install
+
+# 启动开发服务器(默认端口 3000)
 npm run dev
 ```
 
-## 注意事项
+### 4. 访问应用
+
+- **官网首页**: http://localhost:3000
+- **访客聊天**: http://localhost:3000/chat
+- **客服登录**: http://localhost:3000/agent/login
+
+### 5. 默认账号
+
+系统会自动创建默认管理员账号:
+- **用户名**: `admin`
+- **密码**: `admin123`
 
-1. 确保数据库连接配置正确(后端从环境变量读取)
-2. 用户密码会自动加密存储
-3. 对话状态包括: "open"(开放), "closed"(关闭)
-4. 消息发送者通过 `SenderIsAgent` 字段区分是访客还是客服
+> ⚠️ 生产环境请务必修改默认密码!
+
+## 📖 主要功能
+
+### 访客端
+- 人工/AI 客服模式切换
+- 实时消息收发
+- 文件/图片上传
+- 在线客服列表查看
+- 访客小窗插件(可嵌入第三方网站)
+
+### 客服端
+- 对话列表管理(全部/我的/他人的对话)
+- 实时消息推送
+- 访客信息查看和编辑
+- 在线状态显示
+- 消息已读状态同步
+- AI 配置管理(多厂商支持)
+- FAQ 知识库管理
+- 用户权限管理
+- 个人资料管理
+
+## ⚙️ 配置说明
 
 ### 后端环境变量
-在 `backend/.env` 中配置以下变量(主进程会自动加载):
 
-```
-DB_HOST=localhost
-DB_PORT=3306
-DB_USER=root
-DB_PASSWORD=your_password
-DB_NAME=CS
+在 `backend/.env` 中配置:
+
+```env
+DB_HOST=localhost          # 数据库主机
+DB_PORT=3306              # 数据库端口
+DB_USER=root              # 数据库用户名
+DB_PASSWORD=your_password # 数据库密码
+DB_NAME=ai_cs             # 数据库名称
 ```
 
 ### 前端环境变量(可选)
-在 `frontend/.env.local` 中配置以下变量(不配置则使用默认值):
 
-```
+在 `frontend/.env.local` 中配置(不配置则使用默认值):
+
+```env
 NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8080
 ```
 
-说明:本地开发无需配置,已默认 `http://127.0.0.1:8080`。部署到生产环境时修改为实际后端地址(如 `https://api.yourdomain.com`)。
+> 本地开发无需配置,已默认 `http://127.0.0.1:8080`。生产环境请修改为实际后端地址。
+
+
+## 🤝 贡献
+
+欢迎提交 Issue 和 Pull Request!
+
+## 📄 许可证
+
+[待添加]
+
+## 🙏 致谢
 
-## 更新日志
+感谢所有为这个项目做出贡献的开发者!
 
-详见 `doc/CHANGELOG.md` 文件
+---
 
+**最后更新**: 2025-01-XX

+ 266 - 2
backend/controller/admin_controller.go

@@ -1,7 +1,9 @@
 package controller
 
 import (
+	"log"
 	"net/http"
+	"strconv"
 
 	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/gin-gonic/gin"
@@ -10,11 +12,49 @@ import (
 // AdminController 负责处理管理员相关的 HTTP 请求。
 type AdminController struct {
 	authService *service.AuthService
+	userService *service.UserService
 }
 
 // NewAdminController 创建 AdminController 实例。
-func NewAdminController(authService *service.AuthService) *AdminController {
-	return &AdminController{authService: authService}
+func NewAdminController(authService *service.AuthService, userService *service.UserService) *AdminController {
+	return &AdminController{
+		authService: authService,
+		userService: userService,
+	}
+}
+
+// checkAdminPermission 检查当前用户是否是管理员。
+// 暂时从 query 参数获取 current_user_id,后续可以改为从 JWT token 获取。
+func (a *AdminController) checkAdminPermission(c *gin.Context) (uint, bool) {
+	userIDStr := c.Query("current_user_id")
+	if userIDStr == "" {
+		// 也可以从请求头获取
+		userIDStr = c.GetHeader("X-Current-User-ID")
+	}
+	if userIDStr == "" {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供当前用户ID"})
+		return 0, false
+	}
+
+	userID, err := strconv.ParseUint(userIDStr, 10, 64)
+	if err != nil || userID == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "用户ID不合法"})
+		return 0, false
+	}
+
+	// 检查用户是否是管理员
+	user, err := a.userService.GetUser(uint(userID))
+	if err != nil {
+		c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
+		return 0, false
+	}
+
+	if user.Role != "admin" {
+		c.JSON(http.StatusForbidden, gin.H{"error": "权限不足,只有管理员才能执行此操作"})
+		return 0, false
+	}
+
+	return uint(userID), true
 }
 
 type createAgentRequest struct {
@@ -53,3 +93,227 @@ func (a *AdminController) CreateAgent(c *gin.Context) {
 		"role":     user.Role,
 	})
 }
+
+// ListUsers 获取所有用户列表。
+func (a *AdminController) ListUsers(c *gin.Context) {
+	// 检查权限
+	currentUserID, ok := a.checkAdminPermission(c)
+	if !ok {
+		return
+	}
+	_ = currentUserID // 暂时不使用,但保留用于后续日志记录
+
+	users, err := a.userService.ListUsers()
+	if err != nil {
+		log.Printf("❌ 获取用户列表失败: %v", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户列表失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, users)
+}
+
+// GetUser 获取用户详情。
+func (a *AdminController) GetUser(c *gin.Context) {
+	// 检查权限
+	currentUserID, ok := a.checkAdminPermission(c)
+	if !ok {
+		return
+	}
+	_ = currentUserID
+
+	// 获取用户ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "用户ID不合法"})
+		return
+	}
+
+	user, err := a.userService.GetUser(uint(id))
+	if err != nil {
+		if err.Error() == "用户不存在" {
+			c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
+		} else {
+			log.Printf("❌ 获取用户详情失败: %v", err)
+			c.JSON(http.StatusInternalServerError, gin.H{"error": "获取用户详情失败"})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, user)
+}
+
+// CreateUser 处理创建新用户的请求。
+func (a *AdminController) CreateUser(c *gin.Context) {
+	// 检查权限
+	currentUserID, ok := a.checkAdminPermission(c)
+	if !ok {
+		return
+	}
+	_ = currentUserID
+
+	var req struct {
+		Username string  `json:"username"`
+		Password string  `json:"password"`
+		Role     string  `json:"role"`
+		Nickname *string `json:"nickname"`
+		Email    *string `json:"email"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	user, err := a.userService.CreateUser(service.CreateUserInput{
+		Username: req.Username,
+		Password: req.Password,
+		Role:     req.Role,
+		Nickname: req.Nickname,
+		Email:    req.Email,
+	})
+	if err != nil {
+		switch err {
+		case service.ErrUsernameExists:
+			c.JSON(http.StatusBadRequest, gin.H{"error": "用户名已存在"})
+		default:
+			log.Printf("❌ 创建用户失败: %v", err)
+			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "创建成功",
+		"user":    user,
+	})
+}
+
+// UpdateUser 处理更新用户信息的请求。
+func (a *AdminController) UpdateUser(c *gin.Context) {
+	// 检查权限
+	currentUserID, ok := a.checkAdminPermission(c)
+	if !ok {
+		return
+	}
+	_ = currentUserID
+
+	// 获取用户ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "用户ID不合法"})
+		return
+	}
+
+	var req struct {
+		Role                   *string `json:"role"`
+		Nickname               *string `json:"nickname"`
+		Email                  *string `json:"email"`
+		ReceiveAIConversations *bool   `json:"receive_ai_conversations"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	user, err := a.userService.UpdateUser(service.UpdateUserInput{
+		UserID:                 uint(id),
+		Role:                   req.Role,
+		Nickname:               req.Nickname,
+		Email:                  req.Email,
+		ReceiveAIConversations: req.ReceiveAIConversations,
+	})
+	if err != nil {
+		if err.Error() == "用户不存在" {
+			c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
+		} else {
+			log.Printf("❌ 更新用户失败: %v", err)
+			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"message": "更新成功",
+		"user":    user,
+	})
+}
+
+// DeleteUser 处理删除用户的请求。
+func (a *AdminController) DeleteUser(c *gin.Context) {
+	// 检查权限
+	currentUserID, ok := a.checkAdminPermission(c)
+	if !ok {
+		return
+	}
+
+	// 获取用户ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "用户ID不合法"})
+		return
+	}
+
+	if err := a.userService.DeleteUser(uint(id), currentUserID); err != nil {
+		if err.Error() == "用户不存在" {
+			c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
+		} else {
+			log.Printf("❌ 删除用户失败: %v", err)
+			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
+}
+
+// UpdateUserPassword 处理更新用户密码的请求。
+func (a *AdminController) UpdateUserPassword(c *gin.Context) {
+	// 检查权限
+	currentUserID, ok := a.checkAdminPermission(c)
+	if !ok {
+		return
+	}
+
+	// 获取用户ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "用户ID不合法"})
+		return
+	}
+
+	var req struct {
+		OldPassword *string `json:"old_password"`
+		NewPassword string  `json:"new_password"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	// 判断是否是管理员修改其他用户密码
+	isAdmin := uint(id) != currentUserID
+
+	if err := a.userService.UpdateUserPassword(service.UpdatePasswordInput{
+		UserID:      uint(id),
+		OldPassword: req.OldPassword,
+		NewPassword: req.NewPassword,
+		IsAdmin:     isAdmin,
+	}); err != nil {
+		if err.Error() == "用户不存在" {
+			c.JSON(http.StatusNotFound, gin.H{"error": "用户不存在"})
+		} else {
+			log.Printf("❌ 更新密码失败: %v", err)
+			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		}
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "密码更新成功"})
+}

+ 157 - 0
backend/controller/ai_config_controller.go

@@ -0,0 +1,157 @@
+package controller
+
+import (
+	"net/http"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// AIConfigController 负责处理 AI 配置相关的 HTTP 请求。
+type AIConfigController struct {
+	aiConfigService *service.AIConfigService
+}
+
+// NewAIConfigController 创建 AI 配置控制器实例。
+func NewAIConfigController(aiConfigService *service.AIConfigService) *AIConfigController {
+	return &AIConfigController{aiConfigService: aiConfigService}
+}
+
+type createAIConfigRequest struct {
+	Provider    string `json:"provider" binding:"required"`
+	APIURL      string `json:"api_url" binding:"required"`
+	APIKey      string `json:"api_key" binding:"required"`
+	Model       string `json:"model" binding:"required"`
+	ModelType   string `json:"model_type"`
+	IsActive    bool   `json:"is_active"`
+	IsPublic    bool   `json:"is_public"` // 是否开放给访客使用
+	Description string `json:"description"`
+}
+
+type updateAIConfigRequest struct {
+	Provider    *string `json:"provider"`
+	APIURL      *string `json:"api_url"`
+	APIKey      *string `json:"api_key"`
+	Model       *string `json:"model"`
+	ModelType   *string `json:"model_type"`
+	IsActive    *bool   `json:"is_active"`
+	IsPublic    *bool   `json:"is_public"` // 是否开放给访客使用
+	Description *string `json:"description"`
+}
+
+// CreateAIConfig 创建 AI 配置。
+func (a *AIConfigController) CreateAIConfig(c *gin.Context) {
+	userID, err := parseUintParam(c, "user_id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
+		return
+	}
+
+	var req createAIConfigRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	config, err := a.aiConfigService.CreateAIConfig(service.CreateAIConfigInput{
+		UserID:      uint(userID),
+		Provider:    req.Provider,
+		APIURL:      req.APIURL,
+		APIKey:      req.APIKey,
+		Model:       req.Model,
+		ModelType:   req.ModelType,
+		IsActive:    req.IsActive,
+		IsPublic:    req.IsPublic,
+		Description: req.Description,
+	})
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, config)
+}
+
+// GetAIConfig 获取 AI 配置。
+func (a *AIConfigController) GetAIConfig(c *gin.Context) {
+	id, err := parseUintParam(c, "id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "id 不合法"})
+		return
+	}
+
+	config, err := a.aiConfigService.GetAIConfig(uint(id))
+	if err != nil {
+		c.JSON(http.StatusNotFound, gin.H{"error": "AI 配置不存在"})
+		return
+	}
+
+	c.JSON(http.StatusOK, config)
+}
+
+// ListAIConfigs 获取指定用户的所有 AI 配置。
+func (a *AIConfigController) ListAIConfigs(c *gin.Context) {
+	userID, err := parseUintParam(c, "user_id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "user_id 不合法"})
+		return
+	}
+
+	configs, err := a.aiConfigService.ListAIConfigs(uint(userID))
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, configs)
+}
+
+// UpdateAIConfig 更新 AI 配置。
+func (a *AIConfigController) UpdateAIConfig(c *gin.Context) {
+	id, err := parseUintParam(c, "id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "id 不合法"})
+		return
+	}
+
+	var req updateAIConfigRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
+		return
+	}
+
+	config, err := a.aiConfigService.UpdateAIConfig(service.UpdateAIConfigInput{
+		ID:          uint(id),
+		Provider:    req.Provider,
+		APIURL:      req.APIURL,
+		APIKey:      req.APIKey,
+		Model:       req.Model,
+		ModelType:   req.ModelType,
+		IsActive:    req.IsActive,
+		IsPublic:    req.IsPublic,
+		Description: req.Description,
+	})
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, config)
+}
+
+// DeleteAIConfig 删除 AI 配置。
+func (a *AIConfigController) DeleteAIConfig(c *gin.Context) {
+	id, err := parseUintParam(c, "id")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "id 不合法"})
+		return
+	}
+
+	if err := a.aiConfigService.DeleteAIConfig(uint(id)); err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "删除失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
+}
+

+ 85 - 33
backend/controller/conversation_controller.go

@@ -2,6 +2,7 @@ package controller
 
 import (
 	"net/http"
+	"strconv"
 
 	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/2930134478/AI-CS/backend/utils"
@@ -11,20 +12,29 @@ import (
 // ConversationController 负责处理会话相关的 HTTP 请求。
 type ConversationController struct {
 	conversationService *service.ConversationService
+	aiConfigService     *service.AIConfigService // 用于获取开放的模型列表
 }
 
 // NewConversationController 创建 ConversationController 实例。
-func NewConversationController(conversationService *service.ConversationService) *ConversationController {
-	return &ConversationController{conversationService: conversationService}
+func NewConversationController(
+	conversationService *service.ConversationService,
+	aiConfigService *service.AIConfigService,
+) *ConversationController {
+	return &ConversationController{
+		conversationService: conversationService,
+		aiConfigService:     aiConfigService,
+	}
 }
 
 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"`
+	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"`
+	ChatMode   string `json:"chat_mode"`   // 对话模式:human(人工客服)、ai(AI客服)
+	AIConfigID *uint  `json:"ai_config_id"` // AI 配置 ID(访客选择的模型配置,AI 模式时必需)
 }
 
 type updateContactRequest struct {
@@ -54,17 +64,19 @@ func (cc *ConversationController) InitConversation(c *gin.Context) {
 	}
 
 	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),
+		VisitorID:  req.VisitorID,
+		Website:    req.Website,
+		Referrer:   req.Referrer,
+		Browser:    browser,
+		OS:         os,
+		Language:   req.Language,
+		IPAddress:  utils.GetClientIP(c),
+		ChatMode:   req.ChatMode,
+		AIConfigID: req.AIConfigID,
 	})
 
 	if err != nil {
-		c.JSON(http.StatusInternalServerError, gin.H{"error": "创建对话失败"})
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
 		return
 	}
 
@@ -74,6 +86,17 @@ func (cc *ConversationController) InitConversation(c *gin.Context) {
 	})
 }
 
+// GetPublicAIModels 获取所有开放的模型配置(供访客选择)。
+func (cc *ConversationController) GetPublicAIModels(c *gin.Context) {
+	modelType := c.DefaultQuery("model_type", "text")
+	models, err := cc.aiConfigService.GetPublicModels(modelType)
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{"models": models})
+}
+
 // UpdateContactInfo 用于更新访客的联系信息。
 func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
 	id, err := parseUintParam(c, "id")
@@ -117,7 +140,16 @@ func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
 
 // ListConversations 返回当前活跃会话的列表。
 func (cc *ConversationController) ListConversations(c *gin.Context) {
-	conversations, err := cc.conversationService.ListConversations()
+	// 从查询参数获取 user_id(可选)
+	var userID uint
+	if userIDStr := c.Query("user_id"); userIDStr != "" {
+		// 使用 strconv 解析查询参数(不是路径参数)
+		if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
+			userID = uint(parsed)
+		}
+	}
+
+	conversations, err := cc.conversationService.ListConversations(userID)
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"})
 		return
@@ -126,13 +158,14 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
 	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,
+			"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,
+			"has_participated": conv.HasParticipated, // 当前用户是否参与过该会话
 		}
 
 		// 添加 last_seen_at 字段(用于判断在线状态)
@@ -165,7 +198,16 @@ func (cc *ConversationController) GetConversationDetail(c *gin.Context) {
 		return
 	}
 
-	detail, err := cc.conversationService.GetConversationDetail(uint(id))
+	// 从查询参数获取 user_id(可选,用于检查参与状态)
+	var userID uint
+	if userIDStr := c.Query("user_id"); userIDStr != "" {
+		// 使用 strconv 解析查询参数(不是路径参数)
+		if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
+			userID = uint(parsed)
+		}
+	}
+
+	detail, err := cc.conversationService.GetConversationDetail(uint(id), userID)
 	if err != nil {
 		if err == service.ErrConversationNotFound {
 			c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
@@ -220,7 +262,16 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
 		return
 	}
 
-	conversations, err := cc.conversationService.SearchConversations(query)
+	// 从查询参数获取 user_id(可选,用于检查参与状态)
+	var userID uint
+	if userIDStr := c.Query("user_id"); userIDStr != "" {
+		// 使用 strconv 解析查询参数(不是路径参数)
+		if parsed, err := strconv.ParseUint(userIDStr, 10, 32); err == nil {
+			userID = uint(parsed)
+		}
+	}
+
+	conversations, err := cc.conversationService.SearchConversations(query, userID)
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
 		return
@@ -229,13 +280,14 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
 	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,
+			"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,
+			"has_participated": conv.HasParticipated, // 当前用户是否参与过该会话
 		}
 
 		// 添加 last_seen_at 字段(用于判断在线状态)

+ 149 - 0
backend/controller/faq_controller.go

@@ -0,0 +1,149 @@
+package controller
+
+import (
+	"log"
+	"net/http"
+	"strconv"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// FAQController 负责处理 FAQ(常见问题)相关的 HTTP 请求。
+type FAQController struct {
+	faqService *service.FAQService
+}
+
+// NewFAQController 创建 FAQController 实例。
+func NewFAQController(faqService *service.FAQService) *FAQController {
+	return &FAQController{faqService: faqService}
+}
+
+// ListFAQs 获取 FAQ 列表,支持关键词搜索。
+// GET /faqs?query=openai%api%调用
+func (f *FAQController) ListFAQs(c *gin.Context) {
+	// 获取查询参数
+	query := c.Query("query")
+
+	// 查询 FAQ 列表
+	faqs, err := f.faqService.ListFAQs(query)
+	if err != nil {
+		log.Printf("查询 FAQ 列表失败: %v", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询 FAQ 列表失败"})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"faqs": faqs,
+	})
+}
+
+// GetFAQ 获取 FAQ 详情。
+// GET /faqs/:id
+func (f *FAQController) GetFAQ(c *gin.Context) {
+	// 获取 ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "FAQ ID 不合法"})
+		return
+	}
+
+	// 查询 FAQ
+	faq, err := f.faqService.GetFAQ(uint(id))
+	if err != nil {
+		log.Printf("查询 FAQ 失败: %v", err)
+		c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, faq)
+}
+
+// CreateFAQ 创建新的 FAQ 记录。
+// POST /faqs
+func (f *FAQController) CreateFAQ(c *gin.Context) {
+	var req struct {
+		Question string `json:"question" binding:"required"`
+		Answer   string `json:"answer" binding:"required"`
+		Keywords string `json:"keywords"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	// 创建 FAQ
+	faq, err := f.faqService.CreateFAQ(service.CreateFAQInput{
+		Question: req.Question,
+		Answer:   req.Answer,
+		Keywords: req.Keywords,
+	})
+	if err != nil {
+		log.Printf("创建 FAQ 失败: %v", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, faq)
+}
+
+// UpdateFAQ 更新 FAQ 记录。
+// PUT /faqs/:id
+func (f *FAQController) UpdateFAQ(c *gin.Context) {
+	// 获取 ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "FAQ ID 不合法"})
+		return
+	}
+
+	var req struct {
+		Question *string `json:"question"`
+		Answer   *string `json:"answer"`
+		Keywords *string `json:"keywords"`
+	}
+
+	if err := c.ShouldBindJSON(&req); err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	// 更新 FAQ
+	faq, err := f.faqService.UpdateFAQ(uint(id), service.UpdateFAQInput{
+		Question: req.Question,
+		Answer:   req.Answer,
+		Keywords: req.Keywords,
+	})
+	if err != nil {
+		log.Printf("更新 FAQ 失败: %v", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, faq)
+}
+
+// DeleteFAQ 删除 FAQ 记录。
+// DELETE /faqs/:id
+func (f *FAQController) DeleteFAQ(c *gin.Context) {
+	// 获取 ID
+	idStr := c.Param("id")
+	id, err := strconv.ParseUint(idStr, 10, 64)
+	if err != nil || id == 0 {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "FAQ ID 不合法"})
+		return
+	}
+
+	// 删除 FAQ
+	if err := f.faqService.DeleteFAQ(uint(id)); err != nil {
+		log.Printf("删除 FAQ 失败: %v", err)
+		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
+}
+

+ 120 - 8
backend/controller/message_controller.go

@@ -3,8 +3,11 @@ package controller
 import (
 	"log"
 	"net/http"
+	"path/filepath"
 	"strconv"
+	"strings"
 
+	"github.com/2930134478/AI-CS/backend/infra"
 	"github.com/2930134478/AI-CS/backend/service"
 	"github.com/gin-gonic/gin"
 )
@@ -12,33 +15,54 @@ import (
 // MessageController 负责处理消息相关的 HTTP 请求。
 type MessageController struct {
 	messageService *service.MessageService
+	storageService infra.StorageService
 }
 
 // NewMessageController 创建 MessageController 实例。
-func NewMessageController(messageService *service.MessageService) *MessageController {
-	return &MessageController{messageService: messageService}
+func NewMessageController(messageService *service.MessageService, storageService infra.StorageService) *MessageController {
+	return &MessageController{
+		messageService: messageService,
+		storageService: storageService,
+	}
 }
 
 type createMessageRequest struct {
-	ConversationID uint   `json:"conversation_id"`
-	Content        string `json:"content"`
-	SenderIsAgent  bool   `json:"sender_is_agent"`
-	SenderID       uint   `json:"sender_id"`
+	ConversationID uint    `json:"conversation_id"`
+	Content        string  `json:"content"`
+	SenderIsAgent  bool    `json:"sender_is_agent"`
+	SenderID       uint    `json:"sender_id"`
+	// 文件相关字段(可选)
+	FileURL  *string `json:"file_url"`
+	FileType *string `json:"file_type"`
+	FileName *string `json:"file_name"`
+	FileSize *int64  `json:"file_size"`
+	MimeType *string `json:"mime_type"`
 }
 
 // CreateMessage 处理发送消息的请求。
 func (mc *MessageController) CreateMessage(c *gin.Context) {
 	var req createMessageRequest
-	if err := c.ShouldBindJSON(&req); err != nil || req.ConversationID == 0 || req.Content == "" {
+	if err := c.ShouldBindJSON(&req); err != nil || req.ConversationID == 0 {
 		c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数错误"})
 		return
 	}
 
+	// 验证:必须有内容或文件
+	if req.Content == "" && req.FileURL == nil {
+		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,
+		FileURL:        req.FileURL,
+		FileType:       req.FileType,
+		FileName:       req.FileName,
+		FileSize:       req.FileSize,
+		MimeType:       req.MimeType,
 	})
 	if err != nil {
 		log.Printf("❌ 创建消息失败: 对话ID=%d, 错误=%v", req.ConversationID, err)
@@ -57,6 +81,9 @@ func (mc *MessageController) CreateMessage(c *gin.Context) {
 }
 
 // ListMessages 返回指定会话的消息列表。
+// 查询参数:
+//   - conversation_id: 会话ID(必需)
+//   - include_ai_messages: 是否包含 AI 消息(可选,默认 false)
 func (mc *MessageController) ListMessages(c *gin.Context) {
 	conversationIDStr := c.Query("conversation_id")
 	if conversationIDStr == "" {
@@ -70,7 +97,10 @@ func (mc *MessageController) ListMessages(c *gin.Context) {
 		return
 	}
 
-	messages, err := mc.messageService.ListMessages(uint(conversationID))
+	// 解析 include_ai_messages 参数(默认 false)
+	includeAIMessages := c.DefaultQuery("include_ai_messages", "false") == "true"
+
+	messages, err := mc.messageService.ListMessages(uint(conversationID), includeAIMessages)
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": "查询消息失败"})
 		return
@@ -106,3 +136,85 @@ func (mc *MessageController) MarkMessagesRead(c *gin.Context) {
 		"read_at":         formatTimeValue(result.ReadAt),
 	})
 }
+
+// UploadFile 处理文件上传请求。
+// 请求格式:multipart/form-data
+//   - file: 文件内容(必需)
+//   - conversation_id: 对话ID(可选,用于组织目录)
+func (mc *MessageController) UploadFile(c *gin.Context) {
+	// 解析文件
+	file, err := c.FormFile("file")
+	if err != nil {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "文件不能为空"})
+		return
+	}
+
+	// 验证文件大小(10MB)
+	const maxFileSize = 10 * 1024 * 1024 // 10MB
+	if file.Size > maxFileSize {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "文件大小超过限制(最大10MB)"})
+		return
+	}
+
+	// 验证文件类型
+	ext := strings.ToLower(filepath.Ext(file.Filename))
+	allowedExts := map[string]bool{
+		".jpg":  true,
+		".jpeg": true,
+		".png":  true,
+		".gif":  true,
+		".webp": true,
+		".pdf":  true,
+		".doc":  true,
+		".docx": true,
+		".txt":  true,
+	}
+	if !allowedExts[ext] {
+		c.JSON(http.StatusBadRequest, gin.H{"error": "不支持的文件类型"})
+		return
+	}
+
+	// 获取对话ID(可选)
+	var conversationID uint
+	if conversationIDStr := c.PostForm("conversation_id"); conversationIDStr != "" {
+		if id, err := strconv.ParseUint(conversationIDStr, 10, 64); err == nil {
+			conversationID = uint(id)
+		}
+	}
+
+	// 打开文件
+	src, err := file.Open()
+	if err != nil {
+		log.Printf("❌ 打开文件失败: %v", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "打开文件失败"})
+		return
+	}
+	defer src.Close()
+
+	// 保存文件
+	fileURL, err := mc.storageService.SaveMessageFile(conversationID, src, file.Filename)
+	if err != nil {
+		log.Printf("❌ 保存文件失败: %v", err)
+		c.JSON(http.StatusInternalServerError, gin.H{"error": "保存文件失败"})
+		return
+	}
+
+	// 判断文件类型
+	fileType := "document"
+	mimeType := file.Header.Get("Content-Type")
+	if strings.HasPrefix(mimeType, "image/") {
+		fileType = "image"
+	}
+
+	// 返回文件信息
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"data": gin.H{
+			"file_url":  fileURL,
+			"file_type": fileType,
+			"file_name": file.Filename,
+			"file_size": file.Size,
+			"mime_type": mimeType,
+		},
+	})
+}

+ 7 - 5
backend/controller/profile_controller.go

@@ -18,8 +18,9 @@ func NewProfileController(profileService *service.ProfileService) *ProfileContro
 }
 
 type updateProfileRequest struct {
-	Nickname *string `json:"nickname"`
-	Email    *string `json:"email"`
+	Nickname               *string `json:"nickname"`
+	Email                  *string `json:"email"`
+	ReceiveAIConversations *bool   `json:"receive_ai_conversations"` // 是否接收 AI 对话(可选)
 }
 
 // GetProfile 获取当前用户的个人资料。
@@ -56,9 +57,10 @@ func (p *ProfileController) UpdateProfile(c *gin.Context) {
 	}
 
 	profile, err := p.profileService.UpdateProfile(service.UpdateProfileInput{
-		UserID:   uint(userID),
-		Nickname: req.Nickname,
-		Email:    req.Email,
+		UserID:                 uint(userID),
+		Nickname:               req.Nickname,
+		Email:                  req.Email,
+		ReceiveAIConversations: req.ReceiveAIConversations,
 	})
 	if err != nil {
 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

+ 35 - 0
backend/controller/visitor_controller.go

@@ -0,0 +1,35 @@
+package controller
+
+import (
+	"net/http"
+
+	"github.com/2930134478/AI-CS/backend/service"
+	"github.com/gin-gonic/gin"
+)
+
+// VisitorController 负责处理访客相关的 HTTP 请求。
+type VisitorController struct {
+	visitorService *service.VisitorService
+}
+
+// NewVisitorController 创建 VisitorController 实例。
+func NewVisitorController(visitorService *service.VisitorService) *VisitorController {
+	return &VisitorController{
+		visitorService: visitorService,
+	}
+}
+
+// GetOnlineAgents 获取在线客服列表(供访客查看)。
+// GET /visitor/online-agents
+func (v *VisitorController) GetOnlineAgents(c *gin.Context) {
+	agents, err := v.visitorService.GetOnlineAgents()
+	if err != nil {
+		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"agents": agents,
+	})
+}
+

+ 47 - 0
backend/infra/storage.go

@@ -12,6 +12,11 @@ import (
 type StorageService interface {
 	// SaveAvatar 保存头像文件,返回文件URL
 	SaveAvatar(userID uint, file io.Reader, filename string) (string, error)
+	// SaveMessageFile 保存消息文件,返回文件URL
+	// conversationID: 对话ID,用于组织文件目录
+	// file: 文件内容
+	// filename: 原始文件名
+	SaveMessageFile(conversationID uint, file io.Reader, filename string) (string, error)
 	// DeleteFile 删除文件
 	DeleteFile(fileURL string) error
 	// GetFileURL 获取文件的完整URL
@@ -99,6 +104,48 @@ func (s *LocalStorageService) DeleteFile(fileURL string) error {
 	return nil
 }
 
+// SaveMessageFile 保存消息文件
+func (s *LocalStorageService) SaveMessageFile(conversationID uint, file io.Reader, filename string) (string, error) {
+	// 获取文件扩展名
+	ext := filepath.Ext(filename)
+	if ext == "" {
+		ext = ".bin" // 默认扩展名
+	}
+	// 生成唯一文件名:{timestamp}_{原始文件名}
+	timestamp := time.Now().Unix()
+	// 清理文件名,移除特殊字符
+	safeFilename := filepath.Base(filename)
+	if len(safeFilename) > 100 {
+		// 文件名过长,截断
+		safeFilename = safeFilename[:100]
+	}
+	newFilename := fmt.Sprintf("%d_%s", timestamp, safeFilename)
+	
+	// 按对话ID组织目录:messages/{conversationID}/
+	messageDir := filepath.Join(s.baseDir, "messages", fmt.Sprintf("%d", conversationID))
+	if err := os.MkdirAll(messageDir, 0755); err != nil {
+		return "", fmt.Errorf("创建消息文件目录失败: %w", err)
+	}
+	
+	filePath := filepath.Join(messageDir, 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("messages", fmt.Sprintf("%d", conversationID), newFilename)
+	return s.GetFileURL(relativePath), nil
+}
+
 // GetFileURL 获取文件的完整URL
 func (s *LocalStorageService) GetFileURL(filePath string) string {
 	// 确保路径使用正斜杠(用于URL)

+ 95 - 11
backend/main.go

@@ -4,6 +4,7 @@ import (
 	"log"
 	"os"
 	"path/filepath"
+	"time"
 
 	"github.com/2930134478/AI-CS/backend/controller"
 	"github.com/2930134478/AI-CS/backend/infra"
@@ -81,13 +82,15 @@ func main() {
 	}
 
 	//根据结构体定义自动创建更新表
-	if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}); err != nil {
+	if err := db.AutoMigrate(&models.User{}, &models.Conversation{}, &models.Message{}, &models.AIConfig{}, &models.FAQ{}); err != nil {
 		log.Fatalf("自动创建表失败: %v", err)
 	}
 
 	userRepo := repository.NewUserRepository(db)
 	conversationRepo := repository.NewConversationRepository(db)
 	messageRepo := repository.NewMessageRepository(db)
+	aiConfigRepo := repository.NewAIConfigRepository(db)
+	faqRepo := repository.NewFAQRepository(db)
 
 	// 初始化默认管理员账号(如果不存在)
 	initDefaultAdmin(userRepo)
@@ -111,15 +114,19 @@ func main() {
 
 	// 初始化服务层
 	authService := service.NewAuthService(userRepo)
-	conversationService := service.NewConversationService(conversationRepo, messageRepo)
+	conversationService := service.NewConversationService(conversationRepo, messageRepo, aiConfigRepo, userRepo)
 	profileService := service.NewProfileService(userRepo, storageService)
+	aiConfigService := service.NewAIConfigService(aiConfigRepo, userRepo)
+	aiService := service.NewAIService(aiConfigRepo, messageRepo, conversationRepo)
+	userService := service.NewUserService(userRepo) // 用户管理服务
+	faqService := service.NewFAQService(faqRepo)    // FAQ 管理服务
 
 	// 声明 Hub 变量(用于在回调函数中访问)
 	var wsHub *websocket.Hub
 
 	// 创建 WebSocket Hub,设置回调函数来处理客户端连接/断开事件
-	// 使用闭包来访问 conversationService 和 wsHub
-	onConnect := func(conversationID uint, isVisitor bool, visitorCount int) {
+	// 使用闭包来访问 conversationService、messageService、userRepo 和 wsHub
+	onConnect := func(conversationID uint, isVisitor bool, visitorCount int, agentID uint) {
 		if isVisitor {
 			if err := conversationService.UpdateVisitorOnlineStatus(conversationID, true); err != nil {
 				log.Printf("更新访客在线状态失败: %v", err)
@@ -131,6 +138,64 @@ func main() {
 				"is_online":       true,
 				"visitor_count":   visitorCount,
 			})
+		} else if agentID > 0 {
+			// 客服连接:创建系统消息 "{客服名}加入了会话"
+			// 但需要检查是否已经存在该客服的加入消息,避免重复创建
+			// 获取客服信息
+			agent, err := userRepo.GetByID(agentID)
+			if err != nil {
+				log.Printf("获取客服信息失败: %v", err)
+				return
+			}
+			// 确定显示名称:优先使用昵称,如果没有则使用用户名
+			agentName := agent.Nickname
+			if agentName == "" {
+				agentName = agent.Username
+			}
+			// 检查是否已经存在该客服的加入消息
+			hasJoinMessage, err := messageRepo.HasAgentJoinMessage(conversationID, agentID, agentName)
+			if err != nil {
+				log.Printf("检查客服加入消息失败: %v", err)
+				return
+			}
+			// 如果已经存在加入消息,不再创建
+			if hasJoinMessage {
+				log.Printf("客服 %s 已经加入过对话 %d,跳过创建系统消息", agentName, conversationID)
+				return
+			}
+			// 创建系统消息
+			// 需要获取对话信息以确定当前模式
+			conv, err := conversationRepo.GetByID(conversationID)
+			if err != nil {
+				log.Printf("获取对话信息失败: %v", err)
+				return
+			}
+			now := time.Now()
+			chatMode := conv.ChatMode
+			if chatMode == "" {
+				chatMode = "human" // 默认人工模式
+			}
+			systemMessage := &models.Message{
+				ConversationID: conversationID,
+				SenderID:       agentID,
+				SenderIsAgent:  true,
+				Content:        agentName + "加入了会话",
+				MessageType:    "system_message",
+				ChatMode:       chatMode, // 记录系统消息发送时的对话模式
+				IsRead:         true,     // 系统消息默认已读
+				ReadAt:         &now,
+			}
+			if err := messageRepo.Create(systemMessage); err != nil {
+				log.Printf("创建客服加入系统消息失败: %v", err)
+				return
+			}
+			// 延迟一小段时间后广播系统消息,确保客服的 WebSocket 连接已经完全建立
+			// 这样可以确保系统消息能够被客服接收到
+			go func() {
+				time.Sleep(100 * time.Millisecond)
+				wsHub.BroadcastMessage(conversationID, "new_message", systemMessage)
+				log.Printf("✅ 客服加入系统消息已创建并广播: 对话ID=%d, 客服=%s", conversationID, agentName)
+			}()
 		}
 	}
 
@@ -161,14 +226,18 @@ func main() {
 	wsHub = websocket.NewHub(onConnect, onDisconnect)
 	go wsHub.Run() // 启动 Hub(在后台运行)
 
-	messageService := service.NewMessageService(conversationRepo, messageRepo, wsHub)
+	messageService := service.NewMessageService(conversationRepo, messageRepo, wsHub, aiService)
+	visitorService := service.NewVisitorService(userRepo, wsHub)
 
 	// 初始化控制器
 	authController := controller.NewAuthController(authService)
-	conversationController := controller.NewConversationController(conversationService)
-	messageController := controller.NewMessageController(messageService)
-	adminController := controller.NewAdminController(authService)
+	conversationController := controller.NewConversationController(conversationService, aiConfigService)
+	messageController := controller.NewMessageController(messageService, storageService)
+	adminController := controller.NewAdminController(authService, userService)
 	profileController := controller.NewProfileController(profileService)
+	aiConfigController := controller.NewAIConfigController(aiConfigService)
+	faqController := controller.NewFAQController(faqService)
+	visitorController := controller.NewVisitorController(visitorService)
 
 	appRouter.RegisterRoutes(
 		r,
@@ -178,6 +247,9 @@ func main() {
 			Message:      messageController,
 			Admin:        adminController,
 			Profile:      profileController,
+			AIConfig:     aiConfigController,
+			FAQ:          faqController,
+			Visitor:      visitorController,
 		},
 		websocket.HandleWebSocket(wsHub),
 	)
@@ -186,8 +258,20 @@ func main() {
 	// 静态文件路径:/uploads -> backend/uploads
 	r.Static("/uploads", uploadDir)
 
-	//启动服务器)
-	log.Println("🚀 服务器启动成功,监听 :8080")
+	//启动服务器
+	// 监听所有网络接口(0.0.0.0),允许外部设备访问
+	// 如果只想本地访问,可以改为 "127.0.0.1:8080" 或 ":8080"
+	host := os.Getenv("SERVER_HOST")
+	if host == "" {
+		host = "0.0.0.0" // 默认监听所有网络接口,允许外部访问
+	}
+	port := os.Getenv("SERVER_PORT")
+	if port == "" {
+		port = "8080"
+	}
+	addr := host + ":" + port
+	log.Println("🚀 服务器启动成功,监听 " + addr)
 	log.Println("📡 WebSocket 服务已启动,路径: /ws?conversation_id=<对话ID>")
-	r.Run(":8080")
+	log.Println("💡 提示:如需限制为仅本地访问,请设置环境变量 SERVER_HOST=127.0.0.1")
+	r.Run(addr)
 }

+ 26 - 0
backend/models/ai_config.go

@@ -0,0 +1,26 @@
+package models
+
+import (
+	"time"
+)
+
+// AIConfig AI 配置模型
+// 支持多种模型类型(文本、图片、语音、视频)和不同的协议路径
+type AIConfig struct {
+	ID          uint      `json:"id" gorm:"primaryKey"`
+	UserID      uint      `json:"user_id"`                                    // 配置所属的用户(管理员)
+	Provider    string    `json:"provider" gorm:"type:varchar(50)"`           // 服务提供商(如:openai、claude、custom,仅用于标识)
+	APIURL      string    `json:"api_url" gorm:"type:varchar(500)"`           // API 地址(支持不同的协议路径)
+	APIKey      string    `json:"api_key" gorm:"type:varchar(1000)"`          // API Key(加密存储)
+	Model       string    `json:"model" gorm:"type:varchar(100)"`             // 模型名称(如:gpt-3.5-turbo、gpt-4)
+	ModelType   string    `json:"model_type" gorm:"type:varchar(20);default:'text'"` // 模型类型:text、image、audio、video
+	IsActive    bool      `json:"is_active" gorm:"default:true"`             // 是否启用(服务商级别)
+	IsPublic    bool      `json:"is_public" gorm:"default:false"`             // 是否开放给访客使用(模型级别)
+	Description string    `json:"description" gorm:"type:varchar(500)"`       // 配置描述
+	// 可选的适配参数(JSON 格式,用于适配不同服务商的细微差异)
+	// 例如:{"auth_header": "X-API-Key", "response_path": "data.choices[0].message.content"}
+	AdapterConfig string `json:"adapter_config" gorm:"type:text"`            // 适配器配置(JSON 格式)
+	CreatedAt     time.Time `json:"created_at"`
+	UpdatedAt     time.Time `json:"updated_at"`
+}
+

+ 17 - 0
backend/models/faq.go

@@ -0,0 +1,17 @@
+package models
+
+import (
+	"time"
+)
+
+// FAQ 常见问题/事件记录模型
+// 用于存储客服常见问题的问答记录
+type FAQ struct {
+	ID        uint      `json:"id" gorm:"primarykey"`
+	Question  string    `json:"question" gorm:"type:text;not null"`           // 问题
+	Answer    string    `json:"answer" gorm:"type:text;not null"`             // 答案
+	Keywords  string    `json:"keywords" gorm:"type:varchar(500)"`            // 关键词,用逗号或空格分隔,用于搜索
+	CreatedAt time.Time `json:"created_at"`                                   // 创建时间
+	UpdatedAt time.Time `json:"updated_at"`                                   // 更新时间
+}
+

+ 21 - 9
backend/models/user.go

@@ -5,15 +5,17 @@ import (
 )
 
 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"`                             // 更新时间
+	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)"`      // 邮箱
+	// AI 对话接收设置
+	ReceiveAIConversations bool      `json:"receive_ai_conversations" gorm:"default:true"` // 是否接收 AI 对话(默认接收)
+	CreatedAt              time.Time `json:"created_at"`                                   // 创建时间
+	UpdatedAt              time.Time `json:"updated_at"`                                   // 更新时间
 }
 
 type Conversation struct {
@@ -37,6 +39,9 @@ type Conversation struct {
 	Notes string `json:"notes" gorm:"type:text"`         // 备注
 	// 在线状态
 	LastSeenAt *time.Time `json:"last_seen_at"` // 最后活跃时间
+	// AI 客服相关
+	ChatMode   string `json:"chat_mode" gorm:"type:varchar(20);default:'human'"` // 对话模式:human(人工客服)、ai(AI客服)
+	AIConfigID *uint  `json:"ai_config_id"`                                      // AI 配置 ID(访客选择的模型配置)
 }
 
 type Message struct {
@@ -46,7 +51,14 @@ type Message struct {
 	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
+	ChatMode       string     `json:"chat_mode" gorm:"type:varchar(20);default:'human'"`           // 消息发送时的对话模式:human(人工客服)、ai(AI客服)
 	IsRead         bool       `json:"is_read"`
 	ReadAt         *time.Time `json:"read_at"`
 	CreatedAt      time.Time  `json:"created_at"`
+	// 文件相关字段(可选)
+	FileURL  *string `json:"file_url" gorm:"type:varchar(500)"`  // 文件URL(相对路径或完整URL)
+	FileType *string `json:"file_type" gorm:"type:varchar(50)"`  // 文件类型:image, document
+	FileName *string `json:"file_name" gorm:"type:varchar(255)"` // 原始文件名
+	FileSize *int64  `json:"file_size"`                          // 文件大小(字节)
+	MimeType *string `json:"mime_type" gorm:"type:varchar(100)"` // MIME类型(如 image/jpeg)
 }

+ 305 - 0
backend/package-lock.json

@@ -0,0 +1,305 @@
+{
+  "name": "backend",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "@radix-ui/react-checkbox": "^1.3.3",
+        "@radix-ui/react-label": "^2.1.8"
+      }
+    },
+    "node_modules/@radix-ui/primitive": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
+      "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
+    },
+    "node_modules/@radix-ui/react-checkbox": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
+      "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
+      "dependencies": {
+        "@radix-ui/primitive": "1.1.3",
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-context": "1.1.2",
+        "@radix-ui/react-presence": "1.1.5",
+        "@radix-ui/react-primitive": "2.1.3",
+        "@radix-ui/react-use-controllable-state": "1.2.2",
+        "@radix-ui/react-use-previous": "1.1.1",
+        "@radix-ui/react-use-size": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-compose-refs": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
+      "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-context": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
+      "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-label": {
+      "version": "2.1.8",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
+      "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
+      "dependencies": {
+        "@radix-ui/react-primitive": "2.1.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
+      "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.4"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-slot": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
+      "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-presence": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
+      "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-primitive": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
+      "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
+      "dependencies": {
+        "@radix-ui/react-slot": "1.2.3"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "@types/react-dom": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+        "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        },
+        "@types/react-dom": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-slot": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
+      "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
+      "dependencies": {
+        "@radix-ui/react-compose-refs": "1.1.2"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-controllable-state": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz",
+      "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==",
+      "dependencies": {
+        "@radix-ui/react-use-effect-event": "0.0.2",
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-effect-event": {
+      "version": "0.0.2",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz",
+      "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-layout-effect": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz",
+      "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-previous": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
+      "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@radix-ui/react-use-size": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz",
+      "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==",
+      "dependencies": {
+        "@radix-ui/react-use-layout-effect": "1.1.1"
+      },
+      "peerDependencies": {
+        "@types/react": "*",
+        "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
+      "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
+      "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
+      "peer": true,
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.0"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "peer": true
+    }
+  }
+}

+ 6 - 0
backend/package.json

@@ -0,0 +1,6 @@
+{
+  "dependencies": {
+    "@radix-ui/react-checkbox": "^1.3.3",
+    "@radix-ui/react-label": "^2.1.8"
+  }
+}

+ 79 - 0
backend/repository/ai_config_repository.go

@@ -0,0 +1,79 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// AIConfigRepository 封装与 AI 配置相关的数据库操作。
+type AIConfigRepository struct {
+	db *gorm.DB
+}
+
+// NewAIConfigRepository 创建 AI 配置仓库实例。
+func NewAIConfigRepository(db *gorm.DB) *AIConfigRepository {
+	return &AIConfigRepository{db: db}
+}
+
+// Create 创建新的 AI 配置记录。
+func (r *AIConfigRepository) Create(config *models.AIConfig) error {
+	return r.db.Create(config).Error
+}
+
+// GetByID 根据主键查询 AI 配置。
+func (r *AIConfigRepository) GetByID(id uint) (*models.AIConfig, error) {
+	var config models.AIConfig
+	if err := r.db.First(&config, id).Error; err != nil {
+		return nil, err
+	}
+	return &config, nil
+}
+
+// GetActiveByUserID 查询指定用户的活跃 AI 配置(按模型类型筛选)。
+func (r *AIConfigRepository) GetActiveByUserID(userID uint, modelType string) (*models.AIConfig, error) {
+	var config models.AIConfig
+	query := r.db.Where("user_id = ? AND is_active = ?", userID, true)
+	if modelType != "" {
+		query = query.Where("model_type = ?", modelType)
+	}
+	if err := query.Order("created_at desc").First(&config).Error; err != nil {
+		return nil, err
+	}
+	return &config, nil
+}
+
+// ListByUserID 查询指定用户的所有 AI 配置。
+func (r *AIConfigRepository) ListByUserID(userID uint) ([]models.AIConfig, error) {
+	var configs []models.AIConfig
+	if err := r.db.Where("user_id = ?", userID).Order("created_at desc").Find(&configs).Error; err != nil {
+		return nil, err
+	}
+	return configs, nil
+}
+
+// UpdateFields 更新 AI 配置的指定字段。
+func (r *AIConfigRepository) UpdateFields(id uint, values map[string]interface{}) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return r.db.Model(&models.AIConfig{}).Where("id = ?", id).Updates(values).Error
+}
+
+// Delete 删除 AI 配置。
+func (r *AIConfigRepository) Delete(id uint) error {
+	return r.db.Delete(&models.AIConfig{}, id).Error
+}
+
+// ListPublic 查询所有开放的模型配置(供访客选择)。
+func (r *AIConfigRepository) ListPublic(modelType string) ([]models.AIConfig, error) {
+	var configs []models.AIConfig
+	query := r.db.Where("is_active = ? AND is_public = ?", true, true)
+	if modelType != "" {
+		query = query.Where("model_type = ?", modelType)
+	}
+	if err := query.Order("provider, model").Find(&configs).Error; err != nil {
+		return nil, err
+	}
+	return configs, nil
+}
+

+ 74 - 0
backend/repository/faq_repository.go

@@ -0,0 +1,74 @@
+package repository
+
+import (
+	"github.com/2930134478/AI-CS/backend/models"
+	"gorm.io/gorm"
+)
+
+// FAQRepository 封装与 FAQ(常见问题)相关的数据库操作。
+type FAQRepository struct {
+	db *gorm.DB
+}
+
+// NewFAQRepository 创建 FAQ 仓库实例。
+func NewFAQRepository(db *gorm.DB) *FAQRepository {
+	return &FAQRepository{db: db}
+}
+
+// Create 创建新的 FAQ 记录。
+func (r *FAQRepository) Create(faq *models.FAQ) error {
+	return r.db.Create(faq).Error
+}
+
+// GetByID 根据ID查询 FAQ 记录。
+func (r *FAQRepository) GetByID(id uint) (*models.FAQ, error) {
+	var faq models.FAQ
+	if err := r.db.Where("id = ?", id).First(&faq).Error; err != nil {
+		return nil, err
+	}
+	return &faq, nil
+}
+
+// List 获取所有 FAQ 列表,支持关键词搜索。
+// 如果 keywords 不为空,会按关键词搜索(AND 查询,所有关键词都要包含)。
+// 搜索范围:问题、答案、关键词字段。
+func (r *FAQRepository) List(keywords []string) ([]models.FAQ, error) {
+	var faqs []models.FAQ
+	query := r.db.Model(&models.FAQ{})
+
+	// 如果有关键词,进行 AND 查询
+	// 每个关键词都必须在 question、answer、keywords 字段中至少有一个匹配
+	// 但所有关键词都必须被满足(AND 逻辑)
+	if len(keywords) > 0 {
+		for _, keyword := range keywords {
+			if keyword != "" {
+				// 对于每个关键词,要求在问题、答案、关键词字段中至少有一个包含该关键词(OR)
+				// 但所有关键词都必须满足(通过链式 Where 实现 AND)
+				query = query.Where(
+					"(question LIKE ? OR answer LIKE ? OR keywords LIKE ?)",
+					"%"+keyword+"%",
+					"%"+keyword+"%",
+					"%"+keyword+"%",
+				)
+			}
+		}
+	}
+
+	// 按创建时间倒序排列
+	if err := query.Order("created_at DESC").Find(&faqs).Error; err != nil {
+		return nil, err
+	}
+
+	return faqs, nil
+}
+
+// Update 更新 FAQ 记录。
+func (r *FAQRepository) Update(faq *models.FAQ) error {
+	return r.db.Save(faq).Error
+}
+
+// Delete 删除 FAQ 记录。
+func (r *FAQRepository) Delete(id uint) error {
+	return r.db.Delete(&models.FAQ{}, id).Error
+}
+

+ 46 - 1
backend/repository/message_repository.go

@@ -90,7 +90,52 @@ func (r *MessageRepository) MarkMessagesRead(conversationID uint, senderIsAgent
 	}
 	remaining, err := r.CountUnreadBySender(conversationID, senderIsAgent)
 	if err != nil {
-		return nil, 0, time.Time{}, err
+		return nil, 0, time.Time{}, nil
 	}
 	return messageIDs, remaining, now, nil
 }
+
+// HasAgentJoinMessage 检查该对话中是否已经存在该客服的加入消息。
+// 用于避免重复创建"xxx加入了会话"的系统消息。
+func (r *MessageRepository) HasAgentJoinMessage(conversationID uint, agentID uint, agentName string) (bool, error) {
+	var count int64
+	joinMessageContent := agentName + "加入了会话"
+	if err := r.db.Model(&models.Message{}).
+		Where("conversation_id = ? AND sender_id = ? AND sender_is_agent = ? AND message_type = ? AND content = ?",
+			conversationID, agentID, true, "system_message", joinMessageContent).
+		Count(&count).Error; err != nil {
+		return false, err
+	}
+	return count > 0, nil
+}
+
+// HasVisitorMessageInHumanMode 检查对话中是否有访客在人工模式下发送的消息。
+// 用于判断对话是否应该显示在客服列表中。
+// 只有当 ChatMode == "human" 且存在访客发送的消息时,才应该显示。
+func (r *MessageRepository) HasVisitorMessageInHumanMode(conversationID uint) (bool, error) {
+	var count int64
+	// 查询是否有访客发送的消息(sender_is_agent = false)
+	// 注意:这里不检查 ChatMode,因为 ChatMode 在 Conversation 表中
+	// 这个方法只检查消息是否存在,ChatMode 的检查在 Service 层
+	if err := r.db.Model(&models.Message{}).
+		Where("conversation_id = ? AND sender_is_agent = ?", conversationID, false).
+		Count(&count).Error; err != nil {
+		return false, err
+	}
+	return count > 0, nil
+}
+
+// HasAgentParticipated 检查指定客服是否在指定会话中发送过消息。
+// 用于判断该会话是否应该出现在该客服的"My chats"列表中。
+func (r *MessageRepository) HasAgentParticipated(conversationID uint, agentID uint) (bool, error) {
+	var count int64
+	// 查询是否有该客服发送的消息(sender_is_agent = true AND sender_id = agentID)
+	// 注意:系统消息(message_type = 'system_message')也应该算作参与
+	// 所以不限制 message_type,包括所有类型的消息
+	if err := r.db.Model(&models.Message{}).
+		Where("conversation_id = ? AND sender_is_agent = ? AND sender_id = ?", conversationID, true, agentID).
+		Count(&count).Error; err != nil {
+		return false, err
+	}
+	return count > 0, nil
+}

+ 41 - 0
backend/repository/user_repository.go

@@ -42,3 +42,44 @@ func (r *UserRepository) GetByID(id uint) (*models.User, error) {
 func (r *UserRepository) UpdateFields(id uint, updates map[string]interface{}) error {
 	return r.db.Model(&models.User{}).Where("id = ?", id).Updates(updates).Error
 }
+
+// ListUsers 获取所有用户列表。
+func (r *UserRepository) ListUsers() ([]models.User, error) {
+	var users []models.User
+	if err := r.db.Order("created_at DESC").Find(&users).Error; err != nil {
+		return nil, err
+	}
+	return users, nil
+}
+
+// Delete 删除用户。
+func (r *UserRepository) Delete(id uint) error {
+	return r.db.Delete(&models.User{}, id).Error
+}
+
+// CountByRole 统计指定角色的用户数量。
+func (r *UserRepository) CountByRole(role string) (int64, error) {
+	var count int64
+	if err := r.db.Model(&models.User{}).Where("role = ?", role).Count(&count).Error; err != nil {
+		return 0, err
+	}
+	return count, nil
+}
+
+// FindByIDsAndRole 根据ID列表和角色查询用户。
+func (r *UserRepository) FindByIDsAndRole(ids []uint, role string) ([]models.User, error) {
+	var users []models.User
+	if err := r.db.Where("id IN ? AND role = ?", ids, role).Find(&users).Error; err != nil {
+		return nil, err
+	}
+	return users, nil
+}
+
+// FindByIDsAndRoles 根据ID列表和多个角色查询用户(支持查询多个角色,如 admin 和 agent)。
+func (r *UserRepository) FindByIDsAndRoles(ids []uint, roles []string) ([]models.User, error) {
+	var users []models.User
+	if err := r.db.Where("id IN ? AND role IN ?", ids, roles).Find(&users).Error; err != nil {
+		return nil, err
+	}
+	return users, nil
+}

+ 31 - 2
backend/router/router.go

@@ -12,6 +12,9 @@ type ControllerSet struct {
 	Message      *controller.MessageController
 	Admin        *controller.AdminController
 	Profile      *controller.ProfileController
+	AIConfig     *controller.AIConfigController
+	FAQ          *controller.FAQController
+	Visitor      *controller.VisitorController
 }
 
 // RegisterRoutes 注册 HTTP 路由及对应的处理函数。
@@ -26,20 +29,46 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
 	r.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
 	r.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
 	r.GET("/conversations/search", controllers.Conversation.SearchConversations)
+	r.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
 
 	// Message
 	r.POST("/messages", controllers.Message.CreateMessage)
+	r.POST("/messages/upload", controllers.Message.UploadFile) // 文件上传接口
 	r.GET("/messages", controllers.Message.ListMessages)
 	r.PUT("/messages/read", controllers.Message.MarkMessagesRead)
 
-	// Admin
-	r.POST("/admin/users", controllers.Admin.CreateAgent)
+	// Admin(用户管理)
+	r.GET("/admin/users", controllers.Admin.ListUsers)                    // 获取所有用户列表
+	r.GET("/admin/users/:id", controllers.Admin.GetUser)                  // 获取用户详情
+	r.POST("/admin/users", controllers.Admin.CreateUser)                  // 创建新用户
+	r.PUT("/admin/users/:id", controllers.Admin.UpdateUser)               // 更新用户信息
+	r.DELETE("/admin/users/:id", controllers.Admin.DeleteUser)            // 删除用户
+	r.PUT("/admin/users/:id/password", controllers.Admin.UpdateUserPassword) // 更新用户密码
+	// 兼容旧接口
+	r.POST("/admin/agents", 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)
 
+	// AI Config(AI 配置)
+	r.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig)
+	r.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs)
+	r.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig)
+	r.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig)
+	r.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig)
+
+	// FAQ(事件管理/常见问题)
+	r.GET("/faqs", controllers.FAQ.ListFAQs)           // 获取 FAQ 列表(支持关键词搜索)
+	r.GET("/faqs/:id", controllers.FAQ.GetFAQ)         // 获取 FAQ 详情
+	r.POST("/faqs", controllers.FAQ.CreateFAQ)         // 创建 FAQ
+	r.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ)      // 更新 FAQ
+	r.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ)   // 删除 FAQ
+
+	// Visitor(访客相关)
+	r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
+
 	// WebSocket
 	r.GET("/ws", wsHandler)
 }

+ 228 - 0
backend/service/ai_config_service.go

@@ -0,0 +1,228 @@
+package service
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"github.com/2930134478/AI-CS/backend/utils"
+)
+
+// AIConfigService AI 配置服务(负责管理 AI 配置)
+type AIConfigService struct {
+	aiConfigRepo *repository.AIConfigRepository
+	userRepo     *repository.UserRepository
+}
+
+// NewAIConfigService 创建 AI 配置服务实例。
+func NewAIConfigService(
+	aiConfigRepo *repository.AIConfigRepository,
+	userRepo *repository.UserRepository,
+) *AIConfigService {
+	return &AIConfigService{
+		aiConfigRepo: aiConfigRepo,
+		userRepo:     userRepo,
+	}
+}
+
+// CreateAIConfigInput 创建 AI 配置的输入参数。
+type CreateAIConfigInput struct {
+	UserID      uint
+	Provider    string
+	APIURL      string
+	APIKey      string // 明文 API Key(会被加密存储)
+	Model       string
+	ModelType   string
+	IsActive    bool
+	IsPublic    bool   // 是否开放给访客使用
+	Description string
+}
+
+// UpdateAIConfigInput 更新 AI 配置的输入参数。
+type UpdateAIConfigInput struct {
+	ID          uint
+	Provider    *string
+	APIURL      *string
+	APIKey      *string // 明文 API Key(如果提供,会被加密存储)
+	Model       *string
+	ModelType   *string
+	IsActive    *bool
+	IsPublic    *bool   // 是否开放给访客使用
+	Description *string
+}
+
+// AIConfigResult AI 配置返回结果(不包含加密的 API Key)。
+type AIConfigResult struct {
+	ID          uint   `json:"id"`
+	UserID      uint   `json:"user_id"`
+	Provider    string `json:"provider"`
+	APIURL      string `json:"api_url"`
+	Model       string `json:"model"`
+	ModelType   string `json:"model_type"`
+	Protocol    string `json:"protocol"`
+	IsActive    bool   `json:"is_active"`
+	IsPublic    bool   `json:"is_public"`
+	Description string `json:"description"`
+	CreatedAt   string `json:"created_at"`
+	UpdatedAt   string `json:"updated_at"`
+}
+
+// CreateAIConfig 创建 AI 配置。
+func (s *AIConfigService) CreateAIConfig(input CreateAIConfigInput) (*AIConfigResult, error) {
+	// 验证用户是否存在
+	_, err := s.userRepo.GetByID(input.UserID)
+	if err != nil {
+		return nil, errors.New("用户不存在")
+	}
+
+	// 验证 API Key 不能为空
+	if input.APIKey == "" {
+		return nil, errors.New("API Key 不能为空")
+	}
+
+	// 加密 API Key
+	encryptedKey, err := utils.EncryptAPIKey(input.APIKey)
+	if err != nil {
+		return nil, fmt.Errorf("加密 API Key 失败: %v", err)
+	}
+
+	// 设置默认值
+	modelType := input.ModelType
+	if modelType == "" {
+		modelType = "text"
+	}
+
+	// 创建配置
+	config := &models.AIConfig{
+		UserID:      input.UserID,
+		Provider:    input.Provider,
+		APIURL:      input.APIURL,
+		APIKey:      encryptedKey,
+		Model:       input.Model,
+		ModelType:   modelType,
+		IsActive:    input.IsActive,
+		IsPublic:    input.IsPublic,
+		Description: input.Description,
+	}
+
+	if err := s.aiConfigRepo.Create(config); err != nil {
+		return nil, err
+	}
+
+	return s.toResult(config), nil
+}
+
+// GetAIConfig 获取 AI 配置(不返回加密的 API Key)。
+func (s *AIConfigService) GetAIConfig(id uint) (*AIConfigResult, error) {
+	config, err := s.aiConfigRepo.GetByID(id)
+	if err != nil {
+		return nil, err
+	}
+	return s.toResult(config), nil
+}
+
+// ListAIConfigs 获取指定用户的所有 AI 配置。
+func (s *AIConfigService) ListAIConfigs(userID uint) ([]AIConfigResult, error) {
+	configs, err := s.aiConfigRepo.ListByUserID(userID)
+	if err != nil {
+		return nil, err
+	}
+
+	results := make([]AIConfigResult, 0, len(configs))
+	for _, config := range configs {
+		results = append(results, *s.toResult(&config))
+	}
+
+	return results, nil
+}
+
+// UpdateAIConfig 更新 AI 配置。
+func (s *AIConfigService) UpdateAIConfig(input UpdateAIConfigInput) (*AIConfigResult, error) {
+	// 检查配置是否存在
+	_, err := s.aiConfigRepo.GetByID(input.ID)
+	if err != nil {
+		return nil, errors.New("AI 配置不存在")
+	}
+
+	// 构建更新字段
+	updates := make(map[string]interface{})
+	if input.Provider != nil {
+		updates["provider"] = *input.Provider
+	}
+	if input.APIURL != nil {
+		updates["api_url"] = *input.APIURL
+	}
+		if input.APIKey != nil {
+			// 验证 API Key 不能为空
+			if *input.APIKey == "" {
+				return nil, errors.New("API Key 不能为空")
+			}
+			// 如果提供了新的 API Key,需要加密
+			encryptedKey, err := utils.EncryptAPIKey(*input.APIKey)
+			if err != nil {
+				return nil, fmt.Errorf("加密 API Key 失败: %v", err)
+			}
+			updates["api_key"] = encryptedKey
+		}
+	if input.Model != nil {
+		updates["model"] = *input.Model
+	}
+	if input.ModelType != nil {
+		updates["model_type"] = *input.ModelType
+	}
+	if input.IsActive != nil {
+		updates["is_active"] = *input.IsActive
+	}
+	if input.IsPublic != nil {
+		updates["is_public"] = *input.IsPublic
+	}
+	if input.Description != nil {
+		updates["description"] = *input.Description
+	}
+
+	if err := s.aiConfigRepo.UpdateFields(input.ID, updates); err != nil {
+		return nil, err
+	}
+
+	// 返回更新后的配置
+	return s.GetAIConfig(input.ID)
+}
+
+// DeleteAIConfig 删除 AI 配置。
+func (s *AIConfigService) DeleteAIConfig(id uint) error {
+	return s.aiConfigRepo.Delete(id)
+}
+
+// GetPublicModels 获取所有开放的模型配置(供访客选择)。
+func (s *AIConfigService) GetPublicModels(modelType string) ([]AIConfigResult, error) {
+	configs, err := s.aiConfigRepo.ListPublic(modelType)
+	if err != nil {
+		return nil, err
+	}
+	
+	results := make([]AIConfigResult, 0, len(configs))
+	for _, config := range configs {
+		results = append(results, *s.toResult(&config))
+	}
+	
+	return results, nil
+}
+
+// toResult 将模型转换为返回结果(不包含加密的 API Key)。
+func (s *AIConfigService) toResult(config *models.AIConfig) *AIConfigResult {
+	return &AIConfigResult{
+		ID:          config.ID,
+		UserID:      config.UserID,
+		Provider:    config.Provider,
+		APIURL:      config.APIURL,
+		Model:       config.Model,
+		ModelType:   config.ModelType,
+		IsActive:    config.IsActive,
+		IsPublic:    config.IsPublic,
+		Description: config.Description,
+		CreatedAt:   config.CreatedAt.Format("2006-01-02 15:04:05"),
+		UpdatedAt:   config.UpdatedAt.Format("2006-01-02 15:04:05"),
+	}
+}
+

+ 262 - 0
backend/service/ai_provider.go

@@ -0,0 +1,262 @@
+package service
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"net/http"
+	"time"
+)
+
+// AIProvider AI 服务提供商接口(可扩展设计)
+// 不同的 AI 服务提供商需要实现这个接口
+type AIProvider interface {
+	// GenerateResponse 生成 AI 回复
+	// conversationHistory: 对话历史(用于上下文)
+	// userMessage: 用户当前消息
+	// 返回: AI 回复内容
+	GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error)
+}
+
+// AdapterConfig 适配器配置(用于适配不同服务商的 API 格式差异)
+type AdapterConfig struct {
+	// 认证头格式(默认:Bearer)
+	AuthHeader string `json:"auth_header"` // 例如:"Bearer"、"X-API-Key"、"Authorization"
+	// 响应解析路径(默认:choices[0].message.content)
+	ResponsePath string `json:"response_path"` // 例如:"choices[0].message.content"、"data.text"、"result.content"
+	// 请求格式自定义(可选)
+	RequestFormat map[string]interface{} `json:"request_format"` // 用于覆盖默认的请求格式
+}
+
+// MessageHistory 对话历史记录
+type MessageHistory struct {
+	Role    string `json:"role"`    // "user" 或 "assistant"
+	Content string `json:"content"` // 消息内容
+}
+
+// AIConfig 用于 AI 调用的配置信息
+type AIConfig struct {
+	APIURL        string
+	APIKey        string
+	Model         string
+	ModelType     string
+	Provider      string
+	AdapterConfig *AdapterConfig // 适配器配置(用于适配不同服务商的差异)
+}
+
+// UniversalAIProvider 通用 AI 服务提供商(支持所有 OpenAI 兼容格式)
+// 通过适配器配置来适配不同服务商的细微差异
+// 这样 90% 的服务商都可以用同一个 Provider,无需单独实现
+type UniversalAIProvider struct {
+	config AIConfig
+	client *http.Client
+	adapter *AdapterConfig
+}
+
+// NewUniversalAIProvider 创建通用 AI 提供商实例。
+func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider {
+	// 设置默认适配器配置
+	adapter := config.AdapterConfig
+	if adapter == nil {
+		adapter = &AdapterConfig{
+			AuthHeader:  "Bearer",                          // 默认使用 Bearer Token
+			ResponsePath: "choices[0].message.content",     // 默认 OpenAI 格式
+		}
+	} else {
+		// 设置默认值
+		if adapter.AuthHeader == "" {
+			adapter.AuthHeader = "Bearer"
+		}
+		if adapter.ResponsePath == "" {
+			adapter.ResponsePath = "choices[0].message.content"
+		}
+	}
+
+	return &UniversalAIProvider{
+		config:  config,
+		client: &http.Client{
+			Timeout: 30 * time.Second, // 30 秒超时
+		},
+		adapter: adapter,
+	}
+}
+
+// GenerateResponse 生成 AI 回复(支持 OpenAI 兼容格式,通过适配器适配不同服务商)。
+func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
+	// 根据模型类型选择不同的处理逻辑
+	switch p.config.ModelType {
+	case "text":
+		return p.generateTextResponse(conversationHistory, userMessage)
+	case "image":
+		// 图片生成(未来扩展)
+		return "", fmt.Errorf("图片模型暂未支持")
+	case "audio":
+		// 语音识别/合成(未来扩展)
+		return "", fmt.Errorf("语音模型暂未支持")
+	case "video":
+		// 视频生成(未来扩展)
+		return "", fmt.Errorf("视频模型暂未支持")
+	default:
+		return "", fmt.Errorf("不支持的模型类型: %s", p.config.ModelType)
+	}
+}
+
+// generateTextResponse 生成文本回复(通用实现,支持所有 OpenAI 兼容格式)。
+func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
+	// 构建消息列表(包含历史对话和当前消息)
+	messages := make([]map[string]string, 0)
+
+	// 添加历史对话
+	for _, history := range conversationHistory {
+		messages = append(messages, map[string]string{
+			"role":    history.Role,
+			"content": history.Content,
+		})
+	}
+
+	// 添加当前用户消息
+	messages = append(messages, map[string]string{
+		"role":    "user",
+		"content": userMessage,
+	})
+
+	// 构建请求体(OpenAI 兼容格式)
+	requestBody := map[string]interface{}{
+		"model":    p.config.Model,
+		"messages": messages,
+	}
+
+	jsonData, err := json.Marshal(requestBody)
+	if err != nil {
+		return "", fmt.Errorf("序列化请求失败: %v", err)
+	}
+
+	// 创建 HTTP 请求
+	req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData))
+	if err != nil {
+		return "", fmt.Errorf("创建请求失败: %v", err)
+	}
+
+	// 设置请求头
+	req.Header.Set("Content-Type", "application/json")
+	
+	// 根据适配器配置设置认证头
+	authValue := p.config.APIKey
+	if p.adapter.AuthHeader == "Bearer" {
+		authValue = "Bearer " + p.config.APIKey
+		req.Header.Set("Authorization", authValue)
+	} else if p.adapter.AuthHeader == "X-API-Key" {
+		req.Header.Set("X-API-Key", p.config.APIKey)
+	} else {
+		// 默认使用 Authorization: Bearer
+		req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
+	}
+
+	// 发送请求
+	resp, err := p.client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("请求失败: %v", err)
+	}
+	defer resp.Body.Close()
+
+	// 读取响应
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("读取响应失败: %v", err)
+	}
+
+	// 检查 HTTP 状态码
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf("API 返回错误: %s (状态码: %d)", string(body), resp.StatusCode)
+	}
+
+	// 解析响应(支持灵活的响应路径)
+	var responseData map[string]interface{}
+	if err := json.Unmarshal(body, &responseData); err != nil {
+		return "", fmt.Errorf("解析响应失败: %v", err)
+	}
+
+	// 检查是否有错误字段
+	if errorMsg, ok := responseData["error"].(map[string]interface{}); ok {
+		if msg, ok := errorMsg["message"].(string); ok {
+			return "", fmt.Errorf("API 错误: %s", msg)
+		}
+	}
+
+	// 根据适配器配置的响应路径提取内容
+	content, err := p.extractResponseContent(responseData, p.adapter.ResponsePath)
+	if err != nil {
+		return "", err
+	}
+
+	if content == "" {
+		return "", errors.New("API 返回空内容")
+	}
+
+	return content, nil
+}
+
+// extractResponseContent 根据响应路径提取内容(支持灵活的路径配置)。
+// 例如:"choices[0].message.content" 或 "data.text" 或 "result.content"
+func (p *UniversalAIProvider) extractResponseContent(data map[string]interface{}, path string) (string, error) {
+	// 默认路径:choices[0].message.content(OpenAI 格式)
+	if path == "" || path == "choices[0].message.content" {
+		// 尝试 OpenAI 格式
+		if choices, ok := data["choices"].([]interface{}); ok && len(choices) > 0 {
+			if choice, ok := choices[0].(map[string]interface{}); ok {
+				if message, ok := choice["message"].(map[string]interface{}); ok {
+					if content, ok := message["content"].(string); ok {
+						return content, nil
+					}
+				}
+			}
+		}
+	}
+
+	// 尝试其他常见格式
+	// 格式1: data.text
+	if dataObj, ok := data["data"].(map[string]interface{}); ok {
+		if text, ok := dataObj["text"].(string); ok {
+			return text, nil
+		}
+	}
+
+	// 格式2: result.content
+	if result, ok := data["result"].(map[string]interface{}); ok {
+		if content, ok := result["content"].(string); ok {
+			return content, nil
+		}
+	}
+
+	// 格式3: content(直接字段)
+	if content, ok := data["content"].(string); ok {
+		return content, nil
+	}
+
+	// 格式4: text(直接字段)
+	if text, ok := data["text"].(string); ok {
+		return text, nil
+	}
+
+	return "", errors.New("无法从响应中提取内容,请检查响应格式或配置适配器")
+}
+
+// AIProviderFactory AI 提供商工厂(用于创建不同类型的提供商)
+type AIProviderFactory struct{}
+
+// NewAIProviderFactory 创建 AI 提供商工厂实例。
+func NewAIProviderFactory() *AIProviderFactory {
+	return &AIProviderFactory{}
+}
+
+// CreateProvider 根据配置创建对应的 AI 提供商。
+// 设计理念:
+// 所有主流 AI 服务商都使用 REST API(HTTP/HTTPS),统一使用 UniversalAIProvider 处理
+// 通过 AdapterConfig 适配不同服务商的细微差异(认证头、响应路径等)
+func (f *AIProviderFactory) CreateProvider(config AIConfig) (AIProvider, error) {
+	// 所有服务商都使用 REST API,统一处理
+	return NewUniversalAIProvider(config), nil
+}
+

+ 154 - 0
backend/service/ai_service.go

@@ -0,0 +1,154 @@
+package service
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"github.com/2930134478/AI-CS/backend/utils"
+	"gorm.io/gorm"
+)
+
+// AIService AI 服务(负责调用 AI 生成回复)
+type AIService struct {
+	aiConfigRepo     *repository.AIConfigRepository
+	messageRepo      *repository.MessageRepository
+	conversationRepo *repository.ConversationRepository
+	providerFactory  *AIProviderFactory
+}
+
+// NewAIService 创建 AI 服务实例。
+func NewAIService(
+	aiConfigRepo *repository.AIConfigRepository,
+	messageRepo *repository.MessageRepository,
+	conversationRepo *repository.ConversationRepository,
+) *AIService {
+	return &AIService{
+		aiConfigRepo:     aiConfigRepo,
+		messageRepo:      messageRepo,
+		conversationRepo: conversationRepo,
+		providerFactory:  NewAIProviderFactory(),
+	}
+}
+
+// GenerateAIResponse 为对话生成 AI 回复。
+// conversationID: 对话ID
+// userMessage: 用户消息
+// userID: 用户ID(用于回退查找 AI 配置)
+// 返回: AI 回复内容,如果失败返回错误
+func (s *AIService) GenerateAIResponse(conversationID uint, userMessage string, userID uint) (string, error) {
+	// 1. 获取对话信息,优先使用对话绑定的 AI 配置
+	conversation, err := s.conversationRepo.GetByID(conversationID)
+	if err != nil {
+		return "", fmt.Errorf("获取对话失败: %v", err)
+	}
+	
+	var config *models.AIConfig
+	if conversation.AIConfigID != nil {
+		// 使用对话绑定的配置(多厂商支持)
+		config, err = s.aiConfigRepo.GetByID(*conversation.AIConfigID)
+		if err != nil {
+			return "", fmt.Errorf("获取 AI 配置失败: %v", err)
+		}
+		// 验证配置是否启用
+		if !config.IsActive {
+			return "", errors.New("该模型配置已禁用")
+		}
+	} else {
+		// 回退:使用用户默认配置(向后兼容)
+		config, err = s.aiConfigRepo.GetActiveByUserID(userID, "text")
+		if err != nil {
+			if errors.Is(err, gorm.ErrRecordNotFound) {
+				return "", errors.New("未找到 AI 配置,请先在设置中配置 AI 服务")
+			}
+			return "", fmt.Errorf("获取 AI 配置失败: %v", err)
+		}
+	}
+
+	// 2. 解密 API Key
+	apiKey, err := utils.DecryptAPIKey(config.APIKey)
+	if err != nil {
+		return "", fmt.Errorf("解密 API Key 失败: %v", err)
+	}
+
+	// 3. 获取对话历史(用于上下文)
+	history, err := s.buildConversationHistory(conversationID)
+	if err != nil {
+		log.Printf("⚠️ 获取对话历史失败: %v", err)
+		// 即使获取历史失败,也继续处理(使用空历史)
+		history = []MessageHistory{}
+	}
+
+	// 4. 解析适配器配置(如果有)
+	var adapterConfig *AdapterConfig
+	if config.AdapterConfig != "" {
+		if err := json.Unmarshal([]byte(config.AdapterConfig), &adapterConfig); err != nil {
+			log.Printf("⚠️ 解析适配器配置失败: %v,使用默认配置", err)
+		}
+	}
+
+	// 5. 创建 AI 提供商
+	aiConfig := AIConfig{
+		APIURL:        config.APIURL,
+		APIKey:        apiKey,
+		Model:         config.Model,
+		ModelType:     config.ModelType,
+		Provider:      config.Provider,
+		AdapterConfig: adapterConfig,
+	}
+
+	provider, err := s.providerFactory.CreateProvider(aiConfig)
+	if err != nil {
+		return "", fmt.Errorf("创建 AI 提供商失败: %v", err)
+	}
+
+	// 6. 调用 AI 生成回复
+	response, err := provider.GenerateResponse(history, userMessage)
+	if err != nil {
+		// AI 调用失败,返回友好的错误消息
+		log.Printf("❌ AI 调用失败: %v", err)
+		return "AI客服好像出了点差错,请联系人工客服解决", nil
+	}
+
+	return response, nil
+}
+
+// buildConversationHistory 构建对话历史(用于 AI 上下文)。
+func (s *AIService) buildConversationHistory(conversationID uint) ([]MessageHistory, error) {
+	// 获取最近的对话消息(最多 10 条,避免上下文过长)
+	messages, err := s.messageRepo.ListByConversationID(conversationID)
+	if err != nil {
+		return nil, err
+	}
+
+	// 只取最近 10 条消息
+	startIdx := 0
+	if len(messages) > 10 {
+		startIdx = len(messages) - 10
+	}
+
+	history := make([]MessageHistory, 0)
+	for i := startIdx; i < len(messages); i++ {
+		msg := messages[i]
+		// 跳过系统消息
+		if msg.MessageType == "system_message" {
+			continue
+		}
+
+		role := "user"
+		if msg.SenderIsAgent {
+			role = "assistant"
+		}
+
+		history = append(history, MessageHistory{
+			Role:    role,
+			Content: msg.Content,
+		})
+	}
+
+	return history, nil
+}
+

+ 137 - 16
backend/service/conversation_service.go

@@ -14,16 +14,22 @@ import (
 type ConversationService struct {
 	conversations *repository.ConversationRepository
 	messages      *repository.MessageRepository
+	aiConfigRepo  *repository.AIConfigRepository // 用于验证 AI 配置
+	userRepo      *repository.UserRepository     // 用于查询用户设置
 }
 
 // NewConversationService 创建 ConversationService 实例。
 func NewConversationService(
 	conversations *repository.ConversationRepository,
 	messages *repository.MessageRepository,
+	aiConfigRepo *repository.AIConfigRepository,
+	userRepo *repository.UserRepository,
 ) *ConversationService {
 	return &ConversationService{
 		conversations: conversations,
 		messages:      messages,
+		aiConfigRepo:  aiConfigRepo,
+		userRepo:      userRepo,
 	}
 }
 
@@ -40,6 +46,31 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 	if err != nil {
 		if errors.Is(err, gorm.ErrRecordNotFound) {
 			now := time.Now()
+			chatMode := input.ChatMode
+			if chatMode == "" {
+				chatMode = "human" // 默认人工客服
+			}
+
+			// 如果是 AI 模式,验证 AI 配置
+			var aiConfigID *uint
+			if chatMode == "ai" {
+				if input.AIConfigID == nil || *input.AIConfigID == 0 {
+					return nil, errors.New("AI 模式需要选择模型配置")
+				}
+				// 验证配置是否存在且开放
+				config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
+				if err != nil {
+					return nil, errors.New("模型配置不存在")
+				}
+				if !config.IsPublic {
+					return nil, errors.New("该模型未开放给访客使用")
+				}
+				if !config.IsActive {
+					return nil, errors.New("该模型配置已禁用")
+				}
+				aiConfigID = input.AIConfigID
+			}
+
 			conv = &models.Conversation{
 				VisitorID:  input.VisitorID,
 				Status:     "open",
@@ -50,6 +81,8 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 				Language:   input.Language,
 				IPAddress:  input.IPAddress,
 				LastSeenAt: &now,
+				ChatMode:   chatMode,
+				AIConfigID: aiConfigID,
 			}
 			if err := s.conversations.Create(conv); err != nil {
 				return nil, err
@@ -59,10 +92,13 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 			return nil, err
 		}
 	} else {
+		// 恢复已存在的对话
 		now := time.Now()
 		updates := map[string]interface{}{
 			"last_seen_at": &now,
 		}
+
+		// 更新访客信息(如果之前没有)
 		if input.Website != "" && conv.Website == "" {
 			updates["website"] = input.Website
 		}
@@ -81,19 +117,60 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 		if input.IPAddress != "" && conv.IPAddress == "" {
 			updates["ip_address"] = input.IPAddress
 		}
+
+		// 重要:如果用户选择了新的 ChatMode,更新对话模式
+		// 这样访客可以在人工客服和 AI 客服之间切换
+		if input.ChatMode != "" && input.ChatMode != conv.ChatMode {
+			chatMode := input.ChatMode
+			updates["chat_mode"] = chatMode
+
+			// 如果是 AI 模式,验证并更新 AI 配置
+			if chatMode == "ai" {
+				if input.AIConfigID == nil || *input.AIConfigID == 0 {
+					return nil, errors.New("AI 模式需要选择模型配置")
+				}
+				// 验证配置是否存在且开放
+				config, err := s.aiConfigRepo.GetByID(*input.AIConfigID)
+				if err != nil {
+					return nil, errors.New("模型配置不存在")
+				}
+				if !config.IsPublic {
+					return nil, errors.New("该模型未开放给访客使用")
+				}
+				if !config.IsActive {
+					return nil, errors.New("该模型配置已禁用")
+				}
+				updates["ai_config_id"] = input.AIConfigID
+			} else {
+				// 切换到人工客服模式,清除 AI 配置
+				updates["ai_config_id"] = nil
+			}
+		}
+
 		if err := s.conversations.UpdateFields(conv.ID, updates); err != nil {
 			return nil, err
 		}
+
+		// 重新获取更新后的对话信息
+		conv, err = s.conversations.GetByID(conv.ID)
+		if err != nil {
+			return nil, err
+		}
 	}
 
 	if isNewConversation {
 		now := time.Now()
+		chatMode := input.ChatMode
+		if chatMode == "" {
+			chatMode = "human" // 默认人工模式
+		}
 		message := &models.Message{
 			ConversationID: conv.ID,
 			SenderID:       0,
 			SenderIsAgent:  false,
 			Content:        "Visitor opened the page",
 			MessageType:    "system_message",
+			ChatMode:       chatMode, // 记录系统消息发送时的对话模式
 			IsRead:         true,
 			ReadAt:         &now,
 		}
@@ -106,12 +183,17 @@ func (s *ConversationService) InitConversation(input InitConversationInput) (*In
 
 		if input.Referrer != "" {
 			readTime := time.Now()
+			chatMode := input.ChatMode
+			if chatMode == "" {
+				chatMode = "human" // 默认人工模式
+			}
 			referrerMsg := &models.Message{
 				ConversationID: conv.ID,
 				SenderID:       0,
 				SenderIsAgent:  false,
 				Content:        "Visitor came from [" + input.Referrer + "]",
 				MessageType:    "system_message",
+				ChatMode:       chatMode, // 记录系统消息发送时的对话模式
 				IsRead:         true,
 				ReadAt:         &readTime,
 			}
@@ -152,22 +234,34 @@ func (s *ConversationService) UpdateConversationContact(input UpdateConversation
 		return nil, err
 	}
 
-	return s.GetConversationDetail(input.ConversationID)
+	// UpdateConversationContact 不传递 userID,因为更新联系信息时不需要检查参与状态
+	return s.GetConversationDetail(input.ConversationID, 0)
 }
 
-func (s *ConversationService) buildSummary(conv models.Conversation) (ConversationSummary, error) {
+func (s *ConversationService) buildSummary(conv models.Conversation, userID uint) (ConversationSummary, error) {
 	var lastSeen *time.Time
 	if conv.LastSeenAt != nil {
 		lastSeen = conv.LastSeenAt
 	}
+
+	// 检查当前用户是否参与过该会话(是否发送过消息)
+	hasParticipated := false
+	if userID > 0 {
+		if participated, err := s.messages.HasAgentParticipated(conv.ID, userID); err == nil {
+			hasParticipated = participated
+		}
+		// 错误时静默处理,不影响流程
+	}
+
 	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 字段
+		ID:              conv.ID,
+		VisitorID:       conv.VisitorID,
+		AgentID:         conv.AgentID,
+		Status:          conv.Status,
+		CreatedAt:       conv.CreatedAt,
+		UpdatedAt:       conv.UpdatedAt,
+		LastSeenAt:      lastSeen,        // 添加 last_seen_at 字段
+		HasParticipated: hasParticipated, // 当前用户是否参与过该会话
 	}
 
 	if message, err := s.messages.LatestByConversationID(conv.ID); err == nil && message != nil {
@@ -194,7 +288,12 @@ func (s *ConversationService) buildSummary(conv models.Conversation) (Conversati
 }
 
 // ListConversations 返回当前活跃会话的摘要信息。
-func (s *ConversationService) ListConversations() ([]ConversationSummary, error) {
+// userID: 当前登录的客服ID(可选,如果为0则使用默认过滤规则)
+// 过滤规则:
+// 1. 默认不显示 ChatMode == "ai" 的对话
+// 2. 如果 userID > 0 且该用户的 ReceiveAIConversations == false,则不显示 AI 对话
+// 3. 只显示 ChatMode == "human" 且存在访客消息的对话(访客切换到人工并发送消息后)
+func (s *ConversationService) ListConversations(userID uint) ([]ConversationSummary, error) {
 	conversations, err := s.conversations.ListActive()
 	if err != nil {
 		return nil, err
@@ -202,9 +301,30 @@ func (s *ConversationService) ListConversations() ([]ConversationSummary, error)
 
 	result := make([]ConversationSummary, 0, len(conversations))
 	for _, conv := range conversations {
-		summary, err := s.buildSummary(conv)
+		// 过滤规则 1: 默认不显示 AI 对话
+		// 只有在会话页面手动开启"显示 AI 对话"时才显示
+		if conv.ChatMode == "ai" {
+			continue
+		}
+
+		// 过滤规则 2: 如果是人工对话,检查是否有访客发送的消息
+		// 只有当访客切换到人工并发送消息后,才显示在列表中
+		if conv.ChatMode == "human" {
+			hasVisitorMessage, err := s.messages.HasVisitorMessageInHumanMode(conv.ID)
+			if err != nil {
+				// 如果查询失败,为了安全起见,不显示该对话
+				continue
+			}
+			if !hasVisitorMessage {
+				// 没有访客消息,不显示(访客只是切换了模式,但还没发送消息)
+				continue
+			}
+		}
+
+		// 通过过滤,添加到结果列表
+		summary, err := s.buildSummary(conv, userID)
 		if err != nil {
-			return nil, err
+			continue // 如果构建摘要失败,跳过该对话
 		}
 		result = append(result, summary)
 	}
@@ -212,13 +332,13 @@ func (s *ConversationService) ListConversations() ([]ConversationSummary, error)
 }
 
 // GetConversationDetail 获取指定会话的详细信息。
-func (s *ConversationService) GetConversationDetail(id uint) (*ConversationDetail, error) {
+func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*ConversationDetail, error) {
 	conv, err := s.conversations.GetByID(id)
 	if err != nil {
 		return nil, err
 	}
 
-	summary, err := s.buildSummary(*conv)
+	summary, err := s.buildSummary(*conv, userID)
 	if err != nil {
 		return nil, err
 	}
@@ -245,7 +365,8 @@ func (s *ConversationService) GetConversationDetail(id uint) (*ConversationDetai
 }
 
 // SearchConversations 根据关键字检索会话摘要。
-func (s *ConversationService) SearchConversations(query string) ([]ConversationSummary, error) {
+// userID: 当前登录的客服ID(可选,用于检查参与状态)
+func (s *ConversationService) SearchConversations(query string, userID uint) ([]ConversationSummary, error) {
 	pattern := "%" + query + "%"
 
 	idSet := map[uint]struct{}{}
@@ -282,7 +403,7 @@ func (s *ConversationService) SearchConversations(query string) ([]ConversationS
 
 	result := make([]ConversationSummary, 0, len(conversations))
 	for _, conv := range conversations {
-		summary, err := s.buildSummary(conv)
+		summary, err := s.buildSummary(conv, userID)
 		if err != nil {
 			return nil, err
 		}

+ 168 - 0
backend/service/faq_service.go

@@ -0,0 +1,168 @@
+package service
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"gorm.io/gorm"
+)
+
+// FAQService 负责 FAQ(常见问题)管理领域的业务编排。
+type FAQService struct {
+	faqs *repository.FAQRepository
+}
+
+// NewFAQService 创建 FAQService 实例。
+func NewFAQService(faqs *repository.FAQRepository) *FAQService {
+	return &FAQService{faqs: faqs}
+}
+
+// CreateFAQ 创建新的 FAQ 记录。
+func (s *FAQService) CreateFAQ(input CreateFAQInput) (*FAQSummary, error) {
+	// 验证必填字段
+	if input.Question == "" {
+		return nil, errors.New("问题不能为空")
+	}
+	if input.Answer == "" {
+		return nil, errors.New("答案不能为空")
+	}
+
+	// 创建 FAQ 记录
+	faq := &models.FAQ{
+		Question: input.Question,
+		Answer:   input.Answer,
+		Keywords: input.Keywords,
+	}
+
+	if err := s.faqs.Create(faq); err != nil {
+		return nil, err
+	}
+
+	return s.toSummary(faq), nil
+}
+
+// GetFAQ 获取 FAQ 详情。
+func (s *FAQService) GetFAQ(id uint) (*FAQSummary, error) {
+	faq, err := s.faqs.GetByID(id)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("FAQ 不存在")
+		}
+		return nil, err
+	}
+
+	return s.toSummary(faq), nil
+}
+
+// ListFAQs 获取 FAQ 列表,支持关键词搜索。
+// query 格式:关键词之间用 % 分隔,例如 "openai%api%调用"
+// 搜索逻辑:所有关键词都要包含(AND 查询)
+func (s *FAQService) ListFAQs(query string) ([]FAQSummary, error) {
+	// 解析关键词
+	keywords := s.parseKeywords(query)
+
+	// 查询 FAQ 列表
+	faqs, err := s.faqs.List(keywords)
+	if err != nil {
+		return nil, err
+	}
+
+	// 转换为 Summary
+	summaries := make([]FAQSummary, 0, len(faqs))
+	for _, faq := range faqs {
+		summaries = append(summaries, *s.toSummary(&faq))
+	}
+
+	return summaries, nil
+}
+
+// UpdateFAQ 更新 FAQ 记录。
+func (s *FAQService) UpdateFAQ(id uint, input UpdateFAQInput) (*FAQSummary, error) {
+	// 获取现有记录
+	faq, err := s.faqs.GetByID(id)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("FAQ 不存在")
+		}
+		return nil, err
+	}
+
+	// 验证必填字段
+	if input.Question != nil && *input.Question == "" {
+		return nil, errors.New("问题不能为空")
+	}
+	if input.Answer != nil && *input.Answer == "" {
+		return nil, errors.New("答案不能为空")
+	}
+
+	// 更新字段
+	if input.Question != nil {
+		faq.Question = *input.Question
+	}
+	if input.Answer != nil {
+		faq.Answer = *input.Answer
+	}
+	if input.Keywords != nil {
+		faq.Keywords = *input.Keywords
+	}
+
+	// 保存更新
+	if err := s.faqs.Update(faq); err != nil {
+		return nil, err
+	}
+
+	return s.toSummary(faq), nil
+}
+
+// DeleteFAQ 删除 FAQ 记录。
+func (s *FAQService) DeleteFAQ(id uint) error {
+	// 检查记录是否存在
+	_, err := s.faqs.GetByID(id)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return errors.New("FAQ 不存在")
+		}
+		return err
+	}
+
+	// 删除记录
+	return s.faqs.Delete(id)
+}
+
+// parseKeywords 解析关键词查询字符串。
+// 输入格式:关键词之间用 % 分隔,例如 "openai%api%调用"
+// 返回:关键词数组
+func (s *FAQService) parseKeywords(query string) []string {
+	if query == "" {
+		return nil
+	}
+
+	// 按 % 分隔
+	parts := strings.Split(query, "%")
+	keywords := make([]string, 0, len(parts))
+
+	for _, part := range parts {
+		// 去除首尾空格
+		keyword := strings.TrimSpace(part)
+		if keyword != "" {
+			keywords = append(keywords, keyword)
+		}
+	}
+
+	return keywords
+}
+
+// toSummary 将 FAQ 模型转换为 Summary。
+func (s *FAQService) toSummary(faq *models.FAQ) *FAQSummary {
+	return &FAQSummary{
+		ID:        faq.ID,
+		Question:  faq.Question,
+		Answer:    faq.Answer,
+		Keywords:  faq.Keywords,
+		CreatedAt: faq.CreatedAt,
+		UpdatedAt: faq.UpdatedAt,
+	}
+}
+

+ 112 - 5
backend/service/message_service.go

@@ -22,6 +22,7 @@ type MessageService struct {
 	conversations *repository.ConversationRepository
 	messages      *repository.MessageRepository
 	hub           BroadcastHub
+	aiService     *AIService // AI 服务(用于 AI 自动回复)
 }
 
 // NewMessageService 创建 MessageService 实例。
@@ -29,11 +30,13 @@ func NewMessageService(
 	conversations *repository.ConversationRepository,
 	messages *repository.MessageRepository,
 	hub BroadcastHub,
+	aiService *AIService,
 ) *MessageService {
 	return &MessageService{
 		conversations: conversations,
 		messages:      messages,
 		hub:           hub,
+		aiService:     aiService,
 	}
 }
 
@@ -58,31 +61,135 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag
 		SenderIsAgent:  input.SenderIsAgent,
 		Content:        input.Content,
 		MessageType:    "user_message",
+		ChatMode:       conv.ChatMode, // 记录消息发送时的对话模式
 		IsRead:         false,
+		// 文件相关字段(可选)
+		FileURL:  input.FileURL,
+		FileType: input.FileType,
+		FileName: input.FileName,
+		FileSize: input.FileSize,
+		MimeType: input.MimeType,
 	}
 
 	if err := s.messages.Create(message); err != nil {
 		return nil, err
 	}
 
-	if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
+	// 如果客服发送消息,且会话的 agent_id 为 0,则更新为当前客服的 ID
+	updateFields := map[string]interface{}{
 		"updated_at": message.CreatedAt,
-	}); err != nil {
+	}
+	if input.SenderIsAgent && input.SenderID > 0 && conv.AgentID == 0 {
+		updateFields["agent_id"] = input.SenderID
+	}
+
+	if err := s.conversations.UpdateFields(conv.ID, updateFields); err != nil {
 		return nil, err
 	}
 
 	if s.hub != nil {
+		// 1. 先广播到该对话的所有客户端(访客和已连接该对话的客服)
 		s.hub.BroadcastMessage(message.ConversationID, "new_message", message)
+		// 2. 如果是访客发送的消息,且对话模式是人工客服,才广播到所有客服
+		//    这样即使客服没有连接到这个对话,也能收到新消息的通知
+		//    注意:AI 模式下的访客消息不广播给客服(避免干扰)
+		if !input.SenderIsAgent && conv.ChatMode == "human" {
+			s.hub.BroadcastToAllAgents("new_message", message)
+		}
 	} else {
 		log.Printf("⚠️ WebSocket Hub 为空,无法广播消息: 消息ID=%d, 对话ID=%d", message.ID, message.ConversationID)
 	}
 
+	// 3. 如果是 AI 客服模式,且是访客发送的消息,自动调用 AI 生成回复
+	if conv.ChatMode == "ai" && !input.SenderIsAgent && s.aiService != nil {
+		// 异步调用 AI 生成回复(避免阻塞)
+		go func() {
+			// 获取对话的 AgentID(用于查找 AI 配置)
+			// 如果 AgentID 为 0,使用默认管理员 ID(1)
+			userID := conv.AgentID
+			if userID == 0 {
+				userID = 1 // 默认使用管理员 ID
+			}
+
+			aiResponse, err := s.aiService.GenerateAIResponse(message.ConversationID, input.Content, userID)
+			if err != nil {
+				log.Printf("❌ AI 生成回复失败: %v", err)
+				// 使用友好的错误消息
+				aiResponse = "AI客服好像出了点差错,请联系人工客服解决"
+			}
+
+			// 创建 AI 回复消息
+			aiMessage := &models.Message{
+				ConversationID: message.ConversationID,
+				SenderID:       0, // AI 消息的 SenderID 为 0
+				SenderIsAgent:  true, // AI 回复视为客服消息
+				Content:        aiResponse,
+				MessageType:    "user_message",
+				ChatMode:       "ai", // AI 回复消息的模式为 "ai"
+				IsRead:         false,
+			}
+
+			if err := s.messages.Create(aiMessage); err != nil {
+				log.Printf("❌ 创建 AI 回复消息失败: %v", err)
+				return
+			}
+
+			// 更新对话的更新时间
+			if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
+				"updated_at": aiMessage.CreatedAt,
+			}); err != nil {
+				log.Printf("⚠️ 更新对话时间失败: %v", err)
+			}
+
+			// 广播 AI 回复消息
+			if s.hub != nil {
+				// AI 回复只广播给访客,不广播给客服(避免干扰)
+				// 客服可以在会话页面手动开启"显示 AI 消息"来查看
+				s.hub.BroadcastMessage(aiMessage.ConversationID, "new_message", aiMessage)
+				// 不再广播到所有客服
+				// s.hub.BroadcastToAllAgents("new_message", aiMessage)
+			}
+		}()
+	}
+
 	return message, nil
 }
 
-// ListMessages 返回会话内的全部消息。
-func (s *MessageService) ListMessages(conversationID uint) ([]models.Message, error) {
-	return s.messages.ListByConversationID(conversationID)
+// ListMessages 返回会话内的消息列表。
+// includeAIMessages: 是否包含 AI 消息(默认 false,不包含)
+// 如果 includeAIMessages == false,过滤掉所有 chat_mode == "ai" 的消息
+// 这样就能准确区分 AI 模式下的消息和人工模式下的消息,即使对话模式切换了也能正确过滤
+func (s *MessageService) ListMessages(conversationID uint, includeAIMessages bool) ([]models.Message, error) {
+	messages, err := s.messages.ListByConversationID(conversationID)
+	if err != nil {
+		return nil, err
+	}
+
+	// 如果不包含 AI 消息,过滤掉所有 chat_mode == "ai" 的消息
+	// 这样,无论对话当前是什么模式,都能准确过滤掉 AI 模式下的所有消息
+	// 包括:访客在 AI 模式下发送的消息、AI 回复消息
+	if !includeAIMessages {
+		filtered := make([]models.Message, 0, len(messages))
+		for _, msg := range messages {
+			// 只显示 chat_mode != "ai" 的消息(人工模式下的消息)
+			// 如果 chat_mode 为空(兼容历史数据),则根据 SenderID 和 SenderIsAgent 判断
+			if msg.ChatMode != "" {
+				// 有 chat_mode 字段,直接根据字段过滤
+				if msg.ChatMode != "ai" {
+					filtered = append(filtered, msg)
+				}
+			} else {
+				// 兼容历史数据:chat_mode 为空时,使用旧逻辑
+				// 过滤掉 AI 回复消息(SenderID == 0 && SenderIsAgent == true)
+				if msg.SenderID != 0 || !msg.SenderIsAgent {
+					filtered = append(filtered, msg)
+				}
+			}
+		}
+		return filtered, nil
+	}
+
+	return messages, nil
 }
 
 // MarkMessagesRead 将消息标记为已读并通知监听方。

+ 10 - 6
backend/service/profile_service.go

@@ -34,12 +34,13 @@ func (s *ProfileService) GetProfile(userID uint) (*ProfileResult, error) {
 	}
 
 	return &ProfileResult{
-		ID:        user.ID,
-		Username:  user.Username,
-		Role:      user.Role,
-		AvatarURL: user.AvatarURL,
-		Nickname:  user.Nickname,
-		Email:     user.Email,
+		ID:                     user.ID,
+		Username:               user.Username,
+		Role:                   user.Role,
+		AvatarURL:              user.AvatarURL,
+		Nickname:               user.Nickname,
+		Email:                  user.Email,
+		ReceiveAIConversations: user.ReceiveAIConversations,
 	}, nil
 }
 
@@ -60,6 +61,9 @@ func (s *ProfileService) UpdateProfile(input UpdateProfileInput) (*ProfileResult
 	if input.Email != nil {
 		updates["email"] = *input.Email
 	}
+	if input.ReceiveAIConversations != nil {
+		updates["receive_ai_conversations"] = *input.ReceiveAIConversations
+	}
 
 	if len(updates) > 0 {
 		if err := s.users.UpdateFields(input.UserID, updates); err != nil {

+ 100 - 18
backend/service/types.go

@@ -5,6 +5,7 @@ import "time"
 // BroadcastHub 描述 WebSocket Hub 的广播能力。
 type BroadcastHub interface {
 	BroadcastMessage(conversationID uint, messageType string, data interface{})
+	BroadcastToAllAgents(messageType string, data interface{})
 }
 
 // InitConversationInput 对话初始化需要的输入数据。
@@ -16,6 +17,8 @@ type InitConversationInput struct {
 	OS        string
 	Language  string
 	IPAddress string
+	ChatMode   string // 对话模式:human(人工客服)、ai(AI客服)
+	AIConfigID *uint  // AI 配置 ID(访客选择的模型配置,AI 模式时必需)
 }
 
 // InitConversationResult 对话初始化后的返回结果。
@@ -34,15 +37,16 @@ type UpdateConversationContactInput struct {
 
 // 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 // 最后活跃时间,用于判断在线状态
+	ID             uint
+	VisitorID      uint
+	AgentID        uint
+	Status         string
+	CreatedAt      time.Time
+	UpdatedAt      time.Time
+	LastMessage    *LastMessageSummary
+	UnreadCount    int64
+	LastSeenAt     *time.Time // 最后活跃时间,用于判断在线状态
+	HasParticipated bool      // 当前用户是否参与过该会话(是否发送过消息)
 }
 
 // LastMessageSummary 会话最后一条消息的摘要信息。
@@ -78,6 +82,12 @@ type CreateMessageInput struct {
 	Content        string
 	SenderID       uint
 	SenderIsAgent  bool
+	// 文件相关字段(可选)
+	FileURL  *string // 文件URL
+	FileType *string // 文件类型:image, document
+	FileName *string // 原始文件名
+	FileSize *int64  // 文件大小(字节)
+	MimeType *string // MIME类型
 }
 
 // CreateAgentInput 创建客服或管理员账号需要的参数。
@@ -97,17 +107,89 @@ type MarkMessagesReadResult struct {
 
 // UpdateProfileInput 更新个人资料时需要的参数。
 type UpdateProfileInput struct {
-	UserID   uint
-	Nickname *string
-	Email    *string
+	UserID                 uint
+	Nickname               *string
+	Email                  *string
+	ReceiveAIConversations *bool // 是否接收 AI 对话(可选)
 }
 
 // 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"`
+	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"`
+	ReceiveAIConversations bool   `json:"receive_ai_conversations"` // 是否接收 AI 对话
+}
+
+// UserSummary 用户列表摘要信息(不包含密码)。
+type UserSummary struct {
+	ID                     uint      `json:"id"`
+	Username               string    `json:"username"`
+	Role                   string    `json:"role"`
+	Nickname               string    `json:"nickname"`
+	Email                  string    `json:"email"`
+	AvatarURL              string    `json:"avatar_url"`
+	ReceiveAIConversations bool      `json:"receive_ai_conversations"`
+	CreatedAt              time.Time `json:"created_at"`
+	UpdatedAt              time.Time `json:"updated_at"`
+}
+
+// CreateUserInput 创建用户输入。
+type CreateUserInput struct {
+	Username string  // 用户名(必需)
+	Password string  // 密码(必需)
+	Role     string  // 角色:"admin" 或 "agent"(必需)
+	Nickname *string // 昵称(可选)
+	Email    *string // 邮箱(可选)
+}
+
+// UpdateUserInput 更新用户输入。
+type UpdateUserInput struct {
+	UserID                 uint    // 用户ID(必需)
+	Role                   *string // 角色(可选)
+	Nickname               *string // 昵称(可选)
+	Email                  *string // 邮箱(可选)
+	ReceiveAIConversations *bool   // 是否接收 AI 对话(可选)
+}
+
+// UpdatePasswordInput 更新密码输入。
+type UpdatePasswordInput struct {
+	UserID      uint    // 用户ID(必需)
+	OldPassword *string // 旧密码(可选,管理员修改其他用户密码时不需要)
+	NewPassword string  // 新密码(必需)
+	IsAdmin     bool    // 是否是管理员操作(必需)
+}
+
+// FAQSummary FAQ(常见问题)摘要信息。
+type FAQSummary struct {
+	ID        uint      `json:"id"`
+	Question  string    `json:"question"`  // 问题
+	Answer    string    `json:"answer"`    // 答案
+	Keywords  string    `json:"keywords"`  // 关键词(用于搜索)
+	CreatedAt time.Time `json:"created_at"` // 创建时间
+	UpdatedAt time.Time `json:"updated_at"` // 更新时间
+}
+
+// CreateFAQInput 创建 FAQ 输入。
+type CreateFAQInput struct {
+	Question string // 问题(必需)
+	Answer   string // 答案(必需)
+	Keywords string // 关键词(可选,用逗号或空格分隔)
+}
+
+// UpdateFAQInput 更新 FAQ 输入。
+type UpdateFAQInput struct {
+	Question *string // 问题(可选)
+	Answer   *string // 答案(可选)
+	Keywords *string // 关键词(可选)
+}
+
+// OnlineAgent 在线客服信息(供访客查看)。
+type OnlineAgent struct {
+	ID        uint   `json:"id"`         // 客服ID
+	Nickname  string `json:"nickname"`   // 昵称
+	AvatarURL string `json:"avatar_url"` // 头像URL
 }

+ 257 - 0
backend/service/user_service.go

@@ -0,0 +1,257 @@
+package service
+
+import (
+	"errors"
+	"strings"
+
+	"github.com/2930134478/AI-CS/backend/models"
+	"github.com/2930134478/AI-CS/backend/repository"
+	"golang.org/x/crypto/bcrypt"
+	"gorm.io/gorm"
+)
+
+// UserService 负责用户管理领域的业务编排。
+type UserService struct {
+	users *repository.UserRepository
+}
+
+// NewUserService 创建 UserService 实例。
+func NewUserService(users *repository.UserRepository) *UserService {
+	return &UserService{users: users}
+}
+
+// ListUsers 获取所有用户列表。
+func (s *UserService) ListUsers() ([]UserSummary, error) {
+	users, err := s.users.ListUsers()
+	if err != nil {
+		return nil, err
+	}
+
+	summaries := make([]UserSummary, 0, len(users))
+	for _, user := range users {
+		summaries = append(summaries, UserSummary{
+			ID:                     user.ID,
+			Username:               user.Username,
+			Role:                   user.Role,
+			Nickname:               user.Nickname,
+			Email:                  user.Email,
+			AvatarURL:              user.AvatarURL,
+			ReceiveAIConversations: user.ReceiveAIConversations,
+			CreatedAt:              user.CreatedAt,
+			UpdatedAt:              user.UpdatedAt,
+		})
+	}
+
+	return summaries, nil
+}
+
+// GetUser 获取用户详情。
+func (s *UserService) GetUser(id uint) (*UserSummary, error) {
+	user, err := s.users.GetByID(id)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("用户不存在")
+		}
+		return nil, err
+	}
+
+	return &UserSummary{
+		ID:                     user.ID,
+		Username:               user.Username,
+		Role:                   user.Role,
+		Nickname:               user.Nickname,
+		Email:                  user.Email,
+		AvatarURL:              user.AvatarURL,
+		ReceiveAIConversations: user.ReceiveAIConversations,
+		CreatedAt:              user.CreatedAt,
+		UpdatedAt:              user.UpdatedAt,
+	}, nil
+}
+
+// CreateUser 创建新用户。
+func (s *UserService) CreateUser(input CreateUserInput) (*UserSummary, error) {
+	// 验证必填字段
+	if input.Username == "" || input.Password == "" {
+		return nil, errors.New("用户名和密码不能为空")
+	}
+
+	// 验证角色
+	if input.Role != "admin" && input.Role != "agent" {
+		return nil, errors.New("角色只能是 admin 或 agent")
+	}
+
+	// 检查用户名是否已存在
+	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, errors.New("密码加密失败")
+	}
+
+	// 创建用户
+	user := &models.User{
+		Username:               input.Username,
+		Password:               string(hash),
+		Role:                   input.Role,
+		ReceiveAIConversations: true, // 默认接收 AI 对话
+	}
+
+	// 设置可选字段
+	if input.Nickname != nil {
+		user.Nickname = strings.TrimSpace(*input.Nickname)
+	}
+	if input.Email != nil {
+		user.Email = strings.TrimSpace(*input.Email)
+	}
+
+	if err := s.users.Create(user); err != nil {
+		return nil, err
+	}
+
+	return &UserSummary{
+		ID:                     user.ID,
+		Username:               user.Username,
+		Role:                   user.Role,
+		Nickname:               user.Nickname,
+		Email:                  user.Email,
+		AvatarURL:              user.AvatarURL,
+		ReceiveAIConversations: user.ReceiveAIConversations,
+		CreatedAt:              user.CreatedAt,
+		UpdatedAt:              user.UpdatedAt,
+	}, nil
+}
+
+// UpdateUser 更新用户信息。
+func (s *UserService) UpdateUser(input UpdateUserInput) (*UserSummary, error) {
+	// 检查用户是否存在
+	_, err := s.users.GetByID(input.UserID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return nil, errors.New("用户不存在")
+		}
+		return nil, err
+	}
+
+	// 构建更新字段
+	updates := make(map[string]interface{})
+
+	// 更新角色
+	if input.Role != nil {
+		role := strings.TrimSpace(*input.Role)
+		if role != "admin" && role != "agent" {
+			return nil, errors.New("角色只能是 admin 或 agent")
+		}
+		updates["role"] = role
+	}
+
+	// 更新昵称
+	if input.Nickname != nil {
+		updates["nickname"] = strings.TrimSpace(*input.Nickname)
+	}
+
+	// 更新邮箱
+	if input.Email != nil {
+		updates["email"] = strings.TrimSpace(*input.Email)
+	}
+
+	// 更新 AI 对话接收设置
+	if input.ReceiveAIConversations != nil {
+		updates["receive_ai_conversations"] = *input.ReceiveAIConversations
+	}
+
+	// 如果没有需要更新的字段,直接返回
+	if len(updates) == 0 {
+		return s.GetUser(input.UserID)
+	}
+
+	// 执行更新
+	if err := s.users.UpdateFields(input.UserID, updates); err != nil {
+		return nil, err
+	}
+
+	// 返回更新后的用户信息
+	return s.GetUser(input.UserID)
+}
+
+// DeleteUser 删除用户。
+func (s *UserService) DeleteUser(id uint, currentUserID uint) error {
+	// 防止删除当前登录用户
+	if id == currentUserID {
+		return errors.New("不能删除当前登录用户")
+	}
+
+	// 检查用户是否存在并获取用户信息
+	user, err := s.users.GetByID(id)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return errors.New("用户不存在")
+		}
+		return err
+	}
+
+	// 防止删除最后一个管理员
+	if user.Role == "admin" {
+		count, err := s.users.CountByRole("admin")
+		if err != nil {
+			return err
+		}
+		if count <= 1 {
+			return errors.New("不能删除最后一个管理员")
+		}
+	}
+
+	// 执行删除
+	if err := s.users.Delete(id); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UpdateUserPassword 更新用户密码。
+func (s *UserService) UpdateUserPassword(input UpdatePasswordInput) error {
+	// 检查用户是否存在
+	user, err := s.users.GetByID(input.UserID)
+	if err != nil {
+		if errors.Is(err, gorm.ErrRecordNotFound) {
+			return errors.New("用户不存在")
+		}
+		return err
+	}
+
+	// 验证新密码
+	if input.NewPassword == "" {
+		return errors.New("新密码不能为空")
+	}
+
+	// 如果不是管理员操作,需要验证旧密码
+	if !input.IsAdmin {
+		if input.OldPassword == nil || *input.OldPassword == "" {
+			return errors.New("需要提供旧密码")
+		}
+		if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(*input.OldPassword)); err != nil {
+			return errors.New("旧密码不正确")
+		}
+	}
+
+	// 加密新密码
+	hash, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost)
+	if err != nil {
+		return errors.New("密码加密失败")
+	}
+
+	// 更新密码
+	if err := s.users.UpdateFields(input.UserID, map[string]interface{}{
+		"password": string(hash),
+	}); err != nil {
+		return err
+	}
+
+	return nil
+}
+

+ 60 - 0
backend/service/visitor_service.go

@@ -0,0 +1,60 @@
+package service
+
+import (
+	_ "github.com/2930134478/AI-CS/backend/models" // 用于访问 models.User 类型(通过 repository 返回)
+	"github.com/2930134478/AI-CS/backend/repository"
+)
+
+// OnlineAgentHub 描述获取在线客服ID列表的能力。
+type OnlineAgentHub interface {
+	GetOnlineAgentIDs() map[uint]bool
+}
+
+// VisitorService 负责访客相关的业务逻辑。
+type VisitorService struct {
+	userRepo *repository.UserRepository
+	hub      OnlineAgentHub
+}
+
+// NewVisitorService 创建 VisitorService 实例。
+func NewVisitorService(userRepo *repository.UserRepository, hub OnlineAgentHub) *VisitorService {
+	return &VisitorService{
+		userRepo: userRepo,
+		hub:      hub,
+	}
+}
+
+// GetOnlineAgents 获取所有在线客服列表。
+// 返回在线客服的基本信息(ID、昵称、头像)。
+func (s *VisitorService) GetOnlineAgents() ([]OnlineAgent, error) {
+	// 从 WebSocket Hub 获取在线客服ID列表
+	onlineAgentIDs := s.hub.GetOnlineAgentIDs()
+
+	if len(onlineAgentIDs) == 0 {
+		return []OnlineAgent{}, nil
+	}
+
+	// 将 map 转换为 ID 列表
+	ids := make([]uint, 0, len(onlineAgentIDs))
+	for id := range onlineAgentIDs {
+		ids = append(ids, id)
+	}
+
+	// 从数据库查询这些客服的详细信息(包含 admin 和 agent 角色)
+	users, err := s.userRepo.FindByIDsAndRoles(ids, []string{"admin", "agent"})
+	if err != nil {
+		return nil, err
+	}
+
+	// 转换为 OnlineAgent 列表
+	agents := make([]OnlineAgent, 0, len(users))
+	for _, user := range users {
+		agents = append(agents, OnlineAgent{
+			ID:        user.ID,
+			Nickname:  user.Nickname,
+			AvatarURL: user.AvatarURL,
+		})
+	}
+
+	return agents, nil
+}

+ 0 - 0
backend/uploads/avatars/user_4_1763359157.png → backend/uploads/avatars/user_4_1764663058.png


BIN
backend/uploads/messages/23/1763991925_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763991942_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763991961_委托服务合同-5000.pdf


BIN
backend/uploads/messages/23/1763992488_委托服务合同-5000.pdf


BIN
backend/uploads/messages/23/1763992539_微信图片_20251120123549_93_228.png


BIN
backend/uploads/messages/23/1763992817_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763992836_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763992845_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763993203_112048.png


BIN
backend/uploads/messages/23/1763993225_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763993238_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1763993260_112048.png


BIN
backend/uploads/messages/23/1764039045_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1764039057_c248590134d469ed61a3d722a2e733a.png


BIN
backend/uploads/messages/23/1764039076_16e6a139-4afa-47d8-9736-ff3e5ed7636e.png


BIN
backend/uploads/messages/23/1764039089_c248590134d469ed61a3d722a2e733a.png


BIN
backend/uploads/messages/23/1764039200_c248590134d469ed61a3d722a2e733a.png


BIN
backend/uploads/messages/23/1764039214_DALL·E 2024-06-29 16.49.18 - A minimalist avatar for a social media account featuring the correctly


BIN
backend/uploads/messages/23/1764039231_c248590134d469ed61a3d722a2e733a.png


BIN
backend/uploads/messages/23/1764577446_image.png


+ 118 - 0
backend/utils/crypto.go

@@ -0,0 +1,118 @@
+package utils
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io"
+	"os"
+)
+
+// 获取加密密钥(优先从环境变量读取,否则使用默认值)
+// 重要说明:
+// 1. 这个密钥是固定的,用于加密/解密所有 API Key
+// 2. API Key 的长度可以不同,但都用同一个加密密钥加密
+// 3. 如果改变这个密钥,之前加密的 API Key 将无法解密
+// 4. 生产环境必须从环境变量 ENCRYPTION_KEY 读取
+// AES-256 需要 32 字节的密钥
+func getEncryptionKey() []byte {
+	key := os.Getenv("ENCRYPTION_KEY")
+	if key != "" && len(key) == 32 {
+		return []byte(key)
+	}
+	// 默认密钥(仅用于开发环境,生产环境必须设置 ENCRYPTION_KEY)
+	return []byte("abcdefghijklmnopqrstuvwxyz123456") // 32 bytes for AES-256
+}
+
+// EncryptAPIKey 加密 API Key
+// 参数:
+//   - plaintext: 用户输入的 API Key(长度可变,不同服务商不同)
+//
+// 返回:
+//   - 加密后的字符串(base64 编码)
+//
+// 说明:
+//   - 无论 API Key 多长,都用同一个固定的 encryptionKey 加密
+//   - 加密密钥(encryptionKey)是固定的,不会因为 API Key 长度变化而改变
+func EncryptAPIKey(plaintext string) (string, error) {
+	// 验证输入
+	if plaintext == "" {
+		return "", errors.New("API Key 不能为空")
+	}
+
+	// 获取加密密钥(优先从环境变量读取)
+	key := getEncryptionKey()
+	if len(key) != 32 {
+		return "", errors.New("加密密钥长度必须为 32 字节")
+	}
+
+	// 创建 AES cipher
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return "", fmt.Errorf("创建加密器失败: %v", err)
+	}
+
+	// 创建 GCM(Galois/Counter Mode)
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return "", fmt.Errorf("创建 GCM 失败: %v", err)
+	}
+
+	// 生成随机 nonce
+	nonce := make([]byte, gcm.NonceSize())
+	if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+		return "", fmt.Errorf("生成随机数失败: %v", err)
+	}
+
+	// 加密数据
+	ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
+
+	// 返回 base64 编码的密文
+	return base64.StdEncoding.EncodeToString(ciphertext), nil
+}
+
+// DecryptAPIKey 解密 API Key
+func DecryptAPIKey(ciphertext string) (string, error) {
+	// 获取加密密钥(优先从环境变量读取)
+	key := getEncryptionKey()
+	if len(key) != 32 {
+		return "", errors.New("加密密钥长度必须为 32 字节")
+	}
+
+	// 解码 base64
+	data, err := base64.StdEncoding.DecodeString(ciphertext)
+	if err != nil {
+		return "", err
+	}
+
+	// 创建 AES cipher
+	block, err := aes.NewCipher(key)
+	if err != nil {
+		return "", err
+	}
+
+	// 创建 GCM
+	gcm, err := cipher.NewGCM(block)
+	if err != nil {
+		return "", err
+	}
+
+	// 检查数据长度
+	if len(data) < gcm.NonceSize() {
+		return "", errors.New("ciphertext too short")
+	}
+
+	// 提取 nonce 和密文
+	nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():]
+
+	// 解密数据
+	plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
+	if err != nil {
+		return "", err
+	}
+
+	return string(plaintext), nil
+}

+ 5 - 1
backend/websocket/client.go

@@ -37,16 +37,20 @@ type Client struct {
 
 	// 是否是访客(true 表示访客,false 表示客服)
 	isVisitor bool
+
+	// 客服ID(如果是客服连接,存储客服的用户ID)
+	agentID uint
 }
 
 // NewClient 创建一个新的客户端
-func NewClient(hub *Hub, conn *websocket.Conn, conversationID uint, isVisitor bool) *Client {
+func NewClient(hub *Hub, conn *websocket.Conn, conversationID uint, isVisitor bool, agentID uint) *Client {
 	return &Client{
 		hub:            hub,
 		conn:           conn,
 		send:           make(chan *Message, 256),
 		conversationID: conversationID,
 		isVisitor:      isVisitor,
+		agentID:        agentID,
 	}
 }
 

+ 12 - 1
backend/websocket/handler.go

@@ -38,6 +38,17 @@ func HandleWebSocket(hub *Hub) gin.HandlerFunc {
 		isVisitorStr := c.DefaultQuery("is_visitor", "true")
 		isVisitor := isVisitorStr == "true" || isVisitorStr == "1"
 
+		// 从查询参数获取客服ID(如果是客服连接,需要传递 agent_id)
+		var agentID uint
+		if !isVisitor {
+			agentIDStr := c.Query("agent_id")
+			if agentIDStr != "" {
+				if parsed, err := strconv.ParseUint(agentIDStr, 10, 32); err == nil {
+					agentID = uint(parsed)
+				}
+			}
+		}
+
 		// 升级 HTTP 连接为 WebSocket 连接
 		conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
 		if err != nil {
@@ -46,7 +57,7 @@ func HandleWebSocket(hub *Hub) gin.HandlerFunc {
 		}
 
 		// 创建客户端
-		client := NewClient(hub, conn, uint(conversationID), isVisitor)
+		client := NewClient(hub, conn, uint(conversationID), isVisitor, agentID)
 
 		// 注册客户端到 Hub
 		client.hub.register <- client

+ 39 - 3
backend/websocket/hub.go

@@ -3,13 +3,16 @@ package websocket
 import (
 	"log"
 	"sync"
+
+	"github.com/2930134478/AI-CS/backend/models"
 )
 
 // OnClientConnectCallback 客户端连接时的回调函数。
 // conversationID: 对话ID
 // isVisitor: 是否是访客
 // visitorCount: 该对话当前的访客连接数
-type OnClientConnectCallback func(conversationID uint, isVisitor bool, visitorCount int)
+// agentID: 客服ID(如果是客服连接)
+type OnClientConnectCallback func(conversationID uint, isVisitor bool, visitorCount int, agentID uint)
 
 // OnClientDisconnectCallback 客户端断开连接时的回调函数。
 // conversationID: 对话ID
@@ -85,7 +88,7 @@ func (h *Hub) Run() {
 
 			// 调用连接回调函数
 			if h.onConnect != nil {
-				h.onConnect(client.conversationID, client.isVisitor, visitorCount)
+				h.onConnect(client.conversationID, client.isVisitor, visitorCount, client.agentID)
 			}
 
 		// 客户端断开连接
@@ -194,8 +197,24 @@ func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
 
 	// 为每个客服客户端创建消息并发送
 	for _, client := range allAgents {
+		// 如果 data 是 Message 对象,使用消息的 conversation_id
+		// 否则使用客户端连接的对话ID
+		var conversationID uint
+		if msg, ok := data.(*models.Message); ok {
+			conversationID = msg.ConversationID
+		} else if convID, ok := data.(map[string]interface{})["conversation_id"]; ok {
+			if id, ok := convID.(uint); ok {
+				conversationID = id
+			} else if id, ok := convID.(float64); ok {
+				conversationID = uint(id)
+			} else {
+				conversationID = client.conversationID
+			}
+		} else {
+			conversationID = client.conversationID
+		}
 		message := &Message{
-			ConversationID: client.conversationID, // 使用客户端连接的对话ID
+			ConversationID: conversationID,
 			Type:           messageType,
 			Data:           data,
 		}
@@ -216,3 +235,20 @@ func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
 		}
 	}
 }
+
+// GetOnlineAgentIDs 获取所有在线客服的用户ID列表(去重)
+// 返回一个 map,key 是 agentID,value 是 true(用于快速查找)
+func (h *Hub) GetOnlineAgentIDs() map[uint]bool {
+	h.mu.RLock()
+	defer h.mu.RUnlock()
+
+	agentIDs := make(map[uint]bool)
+	for _, clients := range h.conversations {
+		for client := range clients {
+			if !client.isVisitor && client.agentID > 0 {
+				agentIDs[client.agentID] = true
+			}
+		}
+	}
+	return agentIDs
+}

+ 65 - 0
frontend/README-配置说明.md

@@ -0,0 +1,65 @@
+# 前端配置说明
+
+## 真实设备测试配置
+
+### 步骤 1:创建 `.env.local` 文件
+
+在 `frontend` 目录下创建 `.env.local` 文件(如果不存在)。
+
+### 步骤 2:配置 IP 地址
+
+在 `.env.local` 文件中添加以下内容:
+
+```env
+# 将 192.168.124.9 替换为你的电脑 IP 地址
+# 获取 IP 地址:在 PowerShell 中运行 ipconfig
+NEXT_PUBLIC_API_BASE_URL=http://192.168.124.9:8080
+```
+
+**你的 IP 地址**:根据 `ipconfig` 结果,你的 IP 地址是 **192.168.124.9**
+
+### 步骤 3:重启前端服务
+
+```bash
+# 停止当前服务(Ctrl+C)
+# 重新启动
+npm run dev
+```
+
+### 步骤 4:在手机/平板上访问
+
+1. 确保手机/平板连接**同一 WiFi**
+2. 在手机浏览器输入:`http://192.168.124.9:3000`
+
+---
+
+## 本地开发配置(默认)
+
+如果只想在本地开发,不需要配置 `.env.local` 文件,使用默认配置即可:
+
+- 前端:`http://localhost:3000`
+- 后端:`http://127.0.0.1:8080`
+
+---
+
+## 注意事项
+
+1. ✅ **防火墙**:确保 Windows 防火墙允许端口 3000 和 8080
+2. ✅ **同一网络**:手机和电脑必须在同一 WiFi 网络
+3. ✅ **后端配置**:后端已配置为监听 `0.0.0.0:8080`,允许外部访问
+
+---
+
+## 快速配置
+
+**Windows PowerShell**:
+
+```powershell
+# 在 frontend 目录下运行
+@"
+NEXT_PUBLIC_API_BASE_URL=http://192.168.124.9:8080
+"@ | Out-File -FilePath ".env.local" -Encoding utf8
+```
+
+(记得将 `192.168.124.9` 替换为你的实际 IP 地址)
+

+ 11 - 5
frontend/app/agent/chat/[conversationId]/page.tsx

@@ -6,6 +6,7 @@ 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 { Button } from "@/components/ui/button";
 import { useAuth } from "@/features/agent/hooks/useAuth";
 import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
 import {
@@ -330,8 +331,12 @@ export default function AgentChatPage() {
     conversationId,
     enabled: Boolean(conversationId),
     onMessage: handleWebSocketMessage,
-    onError: (error) => console.error("WebSocket 连接错误:", error),
-    onClose: () => console.log("WebSocket 连接已关闭"),
+    onError: (error) => {
+      // 静默处理错误,避免影响用户体验
+    },
+    onClose: () => {
+      // 静默处理关闭,避免影响用户体验
+    },
   });
 
   const handleBack = useCallback(() => {
@@ -357,12 +362,13 @@ export default function AgentChatPage() {
     <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
+          <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"
+            variant="outline"
+            size="sm"
           >
             ← 返回
-          </button>
+          </Button>
           <div className="flex-1">
             <ChatHeader
               conversationId={conversationId}

+ 8 - 6
frontend/app/agent/conversations/page.tsx

@@ -2,6 +2,7 @@
 import { useEffect, useState } from "react";
 import { useRouter } from "next/navigation";
 import { API_BASE_URL } from "@/lib/config";
+import { Button } from "@/components/ui/button";
 
 // 对话类型定义
 interface Conversation {
@@ -119,20 +120,21 @@ export default function ConversationsPage() {
   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="bg-card border-b p-4 shadow-sm">
         <div className="flex justify-between items-center">
           <div>
-            <h1 className="text-xl font-bold">对话列表</h1>
-            <div className="text-sm opacity-90 mt-1">
+            <h1 className="text-xl font-bold text-foreground">对话列表</h1>
+            <div className="text-sm text-muted-foreground mt-1">
               {username} ({role === "admin" ? "管理员" : "客服"})
             </div>
           </div>
-          <button
+          <Button
             onClick={handleLogout}
-            className="px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-lg transition-colors text-sm"
+            variant="outline"
+            size="sm"
           >
             退出登录
-          </button>
+          </Button>
         </div>
       </div>
 

+ 476 - 0
frontend/app/agent/faqs/page.tsx

@@ -0,0 +1,476 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/features/agent/hooks/useAuth";
+import { ResponsiveLayout } from "@/components/layout";
+import {
+  fetchFAQs,
+  createFAQ,
+  updateFAQ,
+  deleteFAQ,
+  type FAQSummary,
+  type CreateFAQRequest,
+  type UpdateFAQRequest,
+} from "@/features/agent/services/faqApi";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+  DialogDescription,
+} from "@/components/ui/dialog";
+import { Card } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import {
+  Plus,
+  Edit,
+  Trash2,
+  Search,
+  FileText,
+  Save,
+  X,
+} from "lucide-react";
+import { Textarea } from "@/components/ui/textarea";
+
+interface FAQsPageProps {
+  embedded?: boolean; // 是否嵌入模式(不使用 ResponsiveLayout)
+}
+
+export default function FAQsPage({ embedded = false }: FAQsPageProps = {}) {
+  const router = useRouter();
+  const { agent } = useAuth();
+  const [faqs, setFaqs] = useState<FAQSummary[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [searchQuery, setSearchQuery] = useState("");
+  const [createDialogOpen, setCreateDialogOpen] = useState(false);
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [selectedFAQ, setSelectedFAQ] = useState<FAQSummary | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+
+  // 创建 FAQ 表单
+  const [createForm, setCreateForm] = useState<CreateFAQRequest>({
+    question: "",
+    answer: "",
+    keywords: "",
+  });
+
+  // 编辑 FAQ 表单
+  const [editForm, setEditForm] = useState<UpdateFAQRequest>({
+    question: "",
+    answer: "",
+    keywords: "",
+  });
+
+  // 加载 FAQ 列表
+  const loadFAQs = useCallback(async () => {
+    setLoading(true);
+    try {
+      // 如果搜索框有内容,使用关键词搜索;否则加载全部
+      const query = searchQuery.trim() || undefined;
+      const data = await fetchFAQs(query);
+      setFaqs(data);
+    } catch (error) {
+      console.error("加载 FAQ 列表失败:", error);
+      alert((error as Error).message || "加载 FAQ 列表失败");
+    } finally {
+      setLoading(false);
+    }
+  }, [searchQuery]);
+
+  // 初始加载和搜索
+  useEffect(() => {
+    // 延迟搜索,避免频繁请求
+    const timer = setTimeout(() => {
+      loadFAQs();
+    }, 500);
+
+    return () => clearTimeout(timer);
+  }, [loadFAQs]);
+
+  // 打开创建对话框
+  const handleOpenCreate = () => {
+    setCreateForm({
+      question: "",
+      answer: "",
+      keywords: "",
+    });
+    setCreateDialogOpen(true);
+  };
+
+  // 创建 FAQ
+  const handleCreate = async () => {
+    if (!createForm.question.trim() || !createForm.answer.trim()) {
+      alert("问题和答案不能为空");
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await createFAQ(createForm);
+      setCreateDialogOpen(false);
+      setCreateForm({ question: "", answer: "", keywords: "" });
+      await loadFAQs();
+      alert("创建成功");
+    } catch (error) {
+      alert((error as Error).message || "创建 FAQ 失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 打开编辑对话框
+  const handleOpenEdit = (faq: FAQSummary) => {
+    setSelectedFAQ(faq);
+    setEditForm({
+      question: faq.question,
+      answer: faq.answer,
+      keywords: faq.keywords || "",
+    });
+    setEditDialogOpen(true);
+  };
+
+  // 更新 FAQ
+  const handleUpdate = async () => {
+    if (!selectedFAQ) {
+      return;
+    }
+    if (!editForm.question?.trim() || !editForm.answer?.trim()) {
+      alert("问题和答案不能为空");
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await updateFAQ(selectedFAQ.id, editForm);
+      setEditDialogOpen(false);
+      setSelectedFAQ(null);
+      await loadFAQs();
+      alert("更新成功");
+    } catch (error) {
+      alert((error as Error).message || "更新 FAQ 失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 打开删除对话框
+  const handleOpenDelete = (faq: FAQSummary) => {
+    setSelectedFAQ(faq);
+    setDeleteDialogOpen(true);
+  };
+
+  // 删除 FAQ
+  const handleDelete = async () => {
+    if (!selectedFAQ) {
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await deleteFAQ(selectedFAQ.id);
+      setDeleteDialogOpen(false);
+      setSelectedFAQ(null);
+      await loadFAQs();
+      alert("删除成功");
+    } catch (error) {
+      alert((error as Error).message || "删除 FAQ 失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 格式化时间
+  const formatTime = (dateStr: string) => {
+    const date = new Date(dateStr);
+    return date.toLocaleString("zh-CN", {
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  };
+
+  // 构建头部内容
+  const headerContent = (
+    <div className="bg-card border-b p-4 shadow-sm">
+      <div className="flex items-center justify-between mb-4">
+        <h1 className="text-xl font-bold text-foreground">事件管理(FAQ)</h1>
+        {!embedded && (
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={() => router.push("/agent/dashboard")}
+          >
+            返回
+          </Button>
+        )}
+      </div>
+
+      {/* 搜索和操作栏 */}
+      <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
+        <div className="flex-1 relative">
+          <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+          <Input
+            type="text"
+            placeholder="关键词搜索(用 % 分隔,例如:openai%api%调用)..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            className="pl-10"
+          />
+        </div>
+        <Button
+          onClick={handleOpenCreate}
+          className="w-full sm:w-auto"
+        >
+          <Plus className="w-4 h-4 mr-2" />
+          创建事件
+        </Button>
+      </div>
+    </div>
+  );
+
+  // 构建主内容区
+  const mainContent = (
+    <div className="flex-1 overflow-y-auto p-4 scrollbar-auto">
+      {loading ? (
+        <div className="flex items-center justify-center h-full">
+          <span className="text-muted-foreground">加载中...</span>
+        </div>
+      ) : faqs.length === 0 ? (
+        <div className="flex items-center justify-center h-full">
+          <span className="text-muted-foreground">
+            {searchQuery ? "没有找到匹配的事件" : "暂无事件"}
+          </span>
+        </div>
+      ) : (
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+          {faqs.map((faq) => (
+            <Card key={faq.id} className="p-4 flex flex-col">
+              <div className="flex-1 mb-3">
+                <div className="flex items-start justify-between mb-2">
+                  <FileText className="w-5 h-5 text-blue-600 mt-0.5 mr-2 flex-shrink-0" />
+                  <h3 className="font-medium text-foreground flex-1 line-clamp-2">
+                    {faq.question}
+                  </h3>
+                </div>
+                <div className="text-sm text-muted-foreground mb-2 line-clamp-3">
+                  {faq.answer}
+                </div>
+                {faq.keywords && (
+                  <div className="text-xs text-muted-foreground mb-2">
+                    关键词: {faq.keywords}
+                  </div>
+                )}
+                <div className="text-xs text-muted-foreground">
+                  创建时间: {formatTime(faq.created_at)}
+                </div>
+              </div>
+
+              <div className="flex items-center gap-2 mt-3 pt-3 border-t border-border">
+                <Button
+                  variant="outline"
+                  size="sm"
+                  onClick={() => handleOpenEdit(faq)}
+                  className="flex-1"
+                >
+                  <Edit className="w-4 h-4 mr-1" />
+                  编辑
+                </Button>
+                <Button
+                  variant="destructive"
+                  size="sm"
+                  onClick={() => handleOpenDelete(faq)}
+                >
+                  <Trash2 className="w-4 h-4" />
+                </Button>
+              </div>
+            </Card>
+          ))}
+        </div>
+      )}
+    </div>
+  );
+
+  // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
+  if (embedded) {
+    return (
+      <>
+        <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+          {headerContent}
+          {mainContent}
+        </div>
+        {/* 对话框 */}
+        {/* 创建 FAQ 对话框 */}
+      <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
+        <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>创建新事件</DialogTitle>
+            <DialogDescription>
+              填写问题和答案,可以添加关键词以便搜索
+            </DialogDescription>
+          </DialogHeader>
+          <div className="space-y-4">
+            <div>
+              <Label htmlFor="create-question">问题 *</Label>
+              <Textarea
+                id="create-question"
+                value={createForm.question}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, question: e.target.value })
+                }
+                placeholder="请输入问题"
+                rows={2}
+                className="resize-none"
+              />
+            </div>
+            <div>
+              <Label htmlFor="create-answer">答案 *</Label>
+              <Textarea
+                id="create-answer"
+                value={createForm.answer}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, answer: e.target.value })
+                }
+                placeholder="请输入答案"
+                rows={6}
+                className="resize-none"
+              />
+            </div>
+            <div>
+              <Label htmlFor="create-keywords">关键词(可选)</Label>
+              <Input
+                id="create-keywords"
+                value={createForm.keywords}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, keywords: e.target.value })
+                }
+                placeholder="例如:API、错误、配置(用逗号或空格分隔)"
+              />
+              <p className="text-xs text-muted-foreground mt-1">
+                提示:即使不填写关键词,系统也会自动搜索问题和答案中的内容。关键词字段用于添加额外的搜索索引,帮助用户更快找到相关内容。
+              </p>
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button
+                variant="outline"
+                onClick={() => setCreateDialogOpen(false)}
+                disabled={submitting}
+              >
+                取消
+              </Button>
+              <Button onClick={handleCreate} disabled={submitting}>
+                {submitting ? "创建中..." : "创建"}
+              </Button>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
+
+      {/* 编辑 FAQ 对话框 */}
+      <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
+        <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>编辑事件</DialogTitle>
+            <DialogDescription>
+              修改问题和答案,可以更新关键词以便搜索
+            </DialogDescription>
+          </DialogHeader>
+          {selectedFAQ && (
+            <div className="space-y-4">
+              <div>
+                <Label htmlFor="edit-question">问题 *</Label>
+                <Textarea
+                  id="edit-question"
+                  value={editForm.question || ""}
+                  onChange={(e) =>
+                    setEditForm({ ...editForm, question: e.target.value })
+                  }
+                  placeholder="请输入问题"
+                  rows={2}
+                  className="resize-none"
+                />
+              </div>
+              <div>
+                <Label htmlFor="edit-answer">答案 *</Label>
+                <Textarea
+                  id="edit-answer"
+                  value={editForm.answer || ""}
+                  onChange={(e) =>
+                    setEditForm({ ...editForm, answer: e.target.value })
+                  }
+                  placeholder="请输入答案"
+                  rows={6}
+                  className="resize-none"
+                />
+              </div>
+              <div>
+                <Label htmlFor="edit-keywords">关键词(可选)</Label>
+                <Input
+                  id="edit-keywords"
+                  value={editForm.keywords || ""}
+                  onChange={(e) =>
+                    setEditForm({ ...editForm, keywords: e.target.value })
+                  }
+                  placeholder="例如:API、错误、配置(用逗号或空格分隔)"
+                />
+                <p className="text-xs text-muted-foreground mt-1">
+                  提示:即使不填写关键词,系统也会自动搜索问题和答案中的内容。关键词字段用于添加额外的搜索索引,帮助用户更快找到相关内容。
+                </p>
+              </div>
+              <div className="flex justify-end gap-2">
+                <Button
+                  variant="outline"
+                  onClick={() => setEditDialogOpen(false)}
+                  disabled={submitting}
+                >
+                  取消
+                </Button>
+                <Button onClick={handleUpdate} disabled={submitting}>
+                  {submitting ? "更新中..." : "更新"}
+                </Button>
+              </div>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 删除确认对话框 */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>删除事件</DialogTitle>
+          </DialogHeader>
+          {selectedFAQ && (
+            <div className="space-y-4">
+              <p className="text-foreground">
+                确定要删除事件 <strong>"{selectedFAQ.question}"</strong> 吗?
+              </p>
+              <p className="text-sm text-muted-foreground">
+                此操作不可恢复,请谨慎操作。
+              </p>
+              <div className="flex justify-end gap-2">
+                <Button
+                  variant="outline"
+                  onClick={() => setDeleteDialogOpen(false)}
+                  disabled={submitting}
+                >
+                  取消
+                </Button>
+                <Button
+                  variant="destructive"
+                  onClick={handleDelete}
+                  disabled={submitting}
+                >
+                  {submitting ? "删除中..." : "删除"}
+                </Button>
+              </div>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+}
+

+ 108 - 0
frontend/app/agent/login/page.tsx

@@ -0,0 +1,108 @@
+"use client";
+import { useState, type FormEvent } from "react";
+import { useRouter } from "next/navigation";
+import { API_BASE_URL } from "@/lib/config";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+
+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);
+    }
+  }
+
+  return (
+    <div className="flex justify-center items-center min-h-screen bg-background">
+      <div className="bg-card p-8 rounded-lg border 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>
+
+        <form onSubmit={handleLogin}>
+          <Input
+            type="text"
+            placeholder="用户名"
+            value={username}
+            onChange={(e) => setUsername(e.target.value)}
+            className="w-full mb-4"
+            disabled={loading}
+          />
+          <Input
+            type="password"
+            placeholder="密码"
+            value={password}
+            onChange={(e) => setPassword(e.target.value)}
+            className="w-full mb-4"
+            disabled={loading}
+          />
+
+          {error && (
+            <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
+              {error}
+            </div>
+          )}
+
+          <Button
+            type="submit"
+            disabled={loading}
+            variant="default"
+            size="default"
+            className="w-full"
+          >
+            {loading ? "登录中..." : "登录"}
+          </Button>
+        </form>
+
+        <div className="mt-4 text-center text-xs text-gray-400">
+          <p>默认管理员账号:admin / admin123</p>
+        </div>
+      </div>
+    </div>
+  );
+}
+

+ 513 - 0
frontend/app/agent/settings/page.tsx

@@ -0,0 +1,513 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { ResponsiveLayout } from "@/components/layout";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+  fetchAIConfigs,
+  createAIConfig,
+  updateAIConfig,
+  deleteAIConfig,
+  type AIConfig,
+  type CreateAIConfigRequest,
+  type UpdateAIConfigRequest,
+} from "@/features/agent/services/aiConfigApi";
+import { useProfile } from "@/features/agent/hooks/useProfile";
+import { API_BASE_URL } from "@/lib/config";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Label } from "@/components/ui/label";
+
+interface SettingsPageProps {
+  embedded?: boolean; // 是否嵌入模式(不使用 ResponsiveLayout)
+}
+
+export default function SettingsPage({ embedded = false }: SettingsPageProps = {}) {
+  const router = useRouter();
+  const [userId, setUserId] = useState<number | null>(null);
+  const [configs, setConfigs] = useState<AIConfig[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [editingId, setEditingId] = useState<number | null>(null);
+  const [formData, setFormData] = useState<CreateAIConfigRequest>({
+    provider: "",
+    api_url: "",
+    api_key: "",
+    model: "",
+    model_type: "text",
+    is_active: true,
+    is_public: false,
+    description: "",
+  });
+  const [submitting, setSubmitting] = useState(false);
+  const [error, setError] = useState("");
+
+  // 检查登录状态
+  useEffect(() => {
+    const storedUserId = localStorage.getItem("agent_user_id");
+    if (!storedUserId) {
+      router.push("/");
+      return;
+    }
+    setUserId(Number.parseInt(storedUserId, 10));
+  }, [router]);
+
+  // 加载个人资料(用于获取和更新 AI 对话接收设置)
+  const {
+    profile,
+    loading: profileLoading,
+    update: updateProfile,
+  } = useProfile({
+    userId: userId ?? null,
+    enabled: Boolean(userId),
+  });
+
+  // 加载配置列表
+  const loadConfigs = async () => {
+    if (!userId) return;
+    try {
+      setLoading(true);
+      const data = await fetchAIConfigs(userId);
+      setConfigs(data);
+    } catch (error) {
+      console.error("加载配置失败:", error);
+      setError("加载配置失败");
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  useEffect(() => {
+    if (userId) {
+      loadConfigs();
+    }
+  }, [userId]);
+
+  // 重置表单
+  const resetForm = () => {
+    setFormData({
+      provider: "",
+      api_url: "",
+      api_key: "",
+      model: "",
+      model_type: "text",
+      is_active: true,
+      is_public: false,
+      description: "",
+    });
+    setEditingId(null);
+    setError("");
+  };
+
+  // 开始编辑
+  const handleEdit = (config: AIConfig) => {
+    setFormData({
+      provider: config.provider,
+      api_url: config.api_url,
+      api_key: "", // 不显示 API Key(已加密)
+      model: config.model,
+      model_type: config.model_type,
+      is_active: config.is_active,
+      is_public: config.is_public,
+      description: config.description,
+    });
+    setEditingId(config.id);
+  };
+
+  // 提交表单
+  const handleSubmit = async (e: React.FormEvent) => {
+    e.preventDefault();
+    if (!userId) return;
+
+    setSubmitting(true);
+    setError("");
+
+    try {
+      if (editingId) {
+        // 更新配置
+        const updateData: UpdateAIConfigRequest = {
+          provider: formData.provider,
+          api_url: formData.api_url,
+          model: formData.model,
+          model_type: formData.model_type,
+          is_active: formData.is_active,
+          is_public: formData.is_public,
+          description: formData.description,
+        };
+        // 如果提供了新的 API Key,才更新
+        if (formData.api_key) {
+          updateData.api_key = formData.api_key;
+        }
+        await updateAIConfig(userId, editingId, updateData);
+      } else {
+        // 创建配置
+        await createAIConfig(userId, formData);
+      }
+      resetForm();
+      await loadConfigs();
+    } catch (error) {
+      setError((error as Error).message || "操作失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 删除配置
+  const handleDelete = async (id: number) => {
+    if (!userId) return;
+    if (!confirm("确定要删除这个配置吗?")) return;
+
+    try {
+      await deleteAIConfig(userId, id);
+      await loadConfigs();
+    } catch (error) {
+      setError((error as Error).message || "删除失败");
+    }
+  };
+
+  // 退出登录
+  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("/");
+    }
+  };
+
+  if (!userId) {
+    return null;
+  }
+
+  // 构建头部内容
+  const headerContent = (
+    <div className="bg-card border-b p-4 shadow-sm">
+      <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
+        <div>
+          <h1 className="text-xl font-bold text-foreground">AI 配置管理</h1>
+          <div className="text-sm text-muted-foreground mt-1">管理 AI 服务商配置</div>
+        </div>
+        {!embedded && (
+          <div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
+            <Button
+              onClick={() => router.push("/agent/dashboard")}
+              variant="outline"
+              size="sm"
+              className="w-full sm:w-auto"
+            >
+              返回工作台
+            </Button>
+            <Button
+              onClick={handleLogout}
+              variant="outline"
+              size="sm"
+              className="w-full sm:w-auto"
+            >
+              退出登录
+            </Button>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+
+  // 构建主内容区
+  const mainContent = (
+    <div className="flex-1 overflow-auto p-4 md:p-6">
+        <div className="max-w-6xl mx-auto space-y-6">
+          {/* 全局设置 */}
+          <Card>
+            <CardHeader>
+              <CardTitle>全局设置</CardTitle>
+            </CardHeader>
+            <CardContent>
+              <div className="flex items-center space-x-2">
+                <Checkbox
+                  id="receive_ai_conversations"
+                  checked={!profile?.receive_ai_conversations ?? false}
+                  onCheckedChange={async (checked) => {
+                    if (userId) {
+                      try {
+                        await updateProfile({
+                          receive_ai_conversations: !checked,
+                        });
+                      } catch (error) {
+                        console.error("更新设置失败:", error);
+                        alert("更新设置失败,请重试");
+                      }
+                    }
+                  }}
+                  disabled={profileLoading}
+                />
+                <Label
+                  htmlFor="receive_ai_conversations"
+                  className="text-sm font-medium cursor-pointer"
+                >
+                  客服不接收 AI 对话
+                </Label>
+              </div>
+              <p className="text-xs text-muted-foreground mt-2">
+                开启后,AI 对话将不会显示在对话列表中,也不会收到 AI 消息通知。
+                但您仍可以在会话页面手动开启"显示 AI 消息"来查看 AI 对话历史。
+              </p>
+            </CardContent>
+          </Card>
+
+          {/* 配置表单 */}
+          <Card>
+            <CardHeader>
+              <CardTitle>
+                {editingId ? "编辑 AI 配置" : "添加 AI 配置"}
+              </CardTitle>
+            </CardHeader>
+            <CardContent>
+              <form onSubmit={handleSubmit} className="space-y-4">
+                {error && (
+                  <div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
+                    {error}
+                  </div>
+                )}
+
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div>
+                    <label className="block text-sm font-medium mb-1">
+                      服务商名称 <span className="text-red-500">*</span>
+                    </label>
+                    <Input
+                      value={formData.provider}
+                      onChange={(e) =>
+                        setFormData({ ...formData, provider: e.target.value })
+                      }
+                      placeholder="例如:OpenAI、Claude、自定义"
+                      required
+                    />
+                  </div>
+
+                  <div>
+                    <label className="block text-sm font-medium mb-1">
+                      API 地址 <span className="text-red-500">*</span>
+                    </label>
+                    <Input
+                      value={formData.api_url}
+                      onChange={(e) =>
+                        setFormData({ ...formData, api_url: e.target.value })
+                      }
+                      placeholder="https://api.openai.com/v1/chat/completions"
+                      required
+                    />
+                  </div>
+
+                  <div>
+                    <label className="block text-sm font-medium mb-1">
+                      API Key <span className="text-red-500">*</span>
+                    </label>
+                    <Input
+                      type="password"
+                      value={formData.api_key}
+                      onChange={(e) =>
+                        setFormData({ ...formData, api_key: e.target.value })
+                      }
+                      placeholder={editingId ? "留空则不更新" : "输入 API Key"}
+                      required={!editingId}
+                    />
+                  </div>
+
+                  <div>
+                    <label className="block text-sm font-medium mb-1">
+                      模型名称 <span className="text-red-500">*</span>
+                    </label>
+                    <Input
+                      value={formData.model}
+                      onChange={(e) =>
+                        setFormData({ ...formData, model: e.target.value })
+                      }
+                      placeholder="例如:gpt-3.5-turbo、gpt-4"
+                      required
+                    />
+                  </div>
+
+                  <div>
+                    <label className="block text-sm font-medium mb-1">
+                      模型类型
+                    </label>
+                    <select
+                      value={formData.model_type}
+                      onChange={(e) =>
+                        setFormData({ ...formData, model_type: e.target.value })
+                      }
+                      className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
+                    >
+                      <option value="text">文本</option>
+                      <option value="image">图片</option>
+                      <option value="audio">语音</option>
+                      <option value="video">视频</option>
+                    </select>
+                  </div>
+                </div>
+
+                <div>
+                  <label className="block text-sm font-medium mb-1">
+                    配置描述
+                  </label>
+                  <Input
+                    value={formData.description}
+                    onChange={(e) =>
+                      setFormData({ ...formData, description: e.target.value })
+                    }
+                    placeholder="例如:OpenAI GPT-3.5 Turbo 模型"
+                  />
+                </div>
+
+                <div className="flex items-center gap-4">
+                  <label className="flex items-center gap-2">
+                    <input
+                      type="checkbox"
+                      checked={formData.is_active}
+                      onChange={(e) =>
+                        setFormData({ ...formData, is_active: e.target.checked })
+                      }
+                      className="w-4 h-4"
+                    />
+                    <span className="text-sm">启用配置</span>
+                  </label>
+
+                  <label className="flex items-center gap-2">
+                    <input
+                      type="checkbox"
+                      checked={formData.is_public}
+                      onChange={(e) =>
+                        setFormData({ ...formData, is_public: e.target.checked })
+                      }
+                      className="w-4 h-4"
+                    />
+                    <span className="text-sm">开放给访客使用</span>
+                  </label>
+                </div>
+
+                <div className="flex gap-2">
+                  <Button type="submit" disabled={submitting}>
+                    {submitting
+                      ? "提交中..."
+                      : editingId
+                        ? "更新配置"
+                        : "创建配置"}
+                  </Button>
+                  {editingId && (
+                    <Button
+                      type="button"
+                      variant="outline"
+                      onClick={resetForm}
+                    >
+                      取消
+                    </Button>
+                  )}
+                </div>
+              </form>
+            </CardContent>
+          </Card>
+
+          {/* 配置列表 */}
+          <Card>
+            <CardHeader>
+              <CardTitle>已配置的 AI 服务</CardTitle>
+            </CardHeader>
+            <CardContent>
+              {loading ? (
+                <div className="text-center py-8 text-gray-500">
+                  加载中...
+                </div>
+              ) : configs.length === 0 ? (
+                <div className="text-center py-8 text-gray-500">
+                  暂无配置,请添加
+                </div>
+              ) : (
+                <div className="space-y-4">
+                  {configs.map((config) => (
+                    <div
+                      key={config.id}
+                      className="p-4 border rounded-lg hover:shadow-md transition-shadow"
+                    >
+                      <div className="flex justify-between items-start">
+                        <div className="flex-1">
+                          <div className="flex items-center gap-2 mb-2">
+                            <h3 className="font-semibold">
+                              {config.provider} - {config.model}
+                            </h3>
+                            {config.is_active && (
+                              <span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded">
+                                启用
+                              </span>
+                            )}
+                            {config.is_public && (
+                              <span className="px-2 py-1 text-xs bg-blue-100 text-blue-800 rounded">
+                                开放
+                              </span>
+                            )}
+                          </div>
+                          <div className="text-sm text-gray-600 space-y-1">
+                            <p>
+                              <span className="font-medium">API 地址:</span>
+                              {config.api_url}
+                            </p>
+                            <p>
+                              <span className="font-medium">模型类型:</span>
+                              {config.model_type}
+                            </p>
+                            {config.description && (
+                              <p>
+                                <span className="font-medium">描述:</span>
+                                {config.description}
+                              </p>
+                            )}
+                          </div>
+                        </div>
+                        <div className="flex gap-2">
+                          <Button
+                            size="sm"
+                            variant="outline"
+                            onClick={() => handleEdit(config)}
+                          >
+                            编辑
+                          </Button>
+                          <Button
+                            size="sm"
+                            variant="destructive"
+                            onClick={() => handleDelete(config.id)}
+                          >
+                            删除
+                          </Button>
+                        </div>
+                      </div>
+                    </div>
+                  ))}
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </div>
+    </div>
+  );
+
+  // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
+  if (embedded) {
+    return (
+      <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+        {headerContent}
+        {mainContent}
+      </div>
+    );
+  }
+
+  return (
+    <ResponsiveLayout
+      main={mainContent}
+      header={headerContent}
+    />
+  );
+}
+

+ 667 - 0
frontend/app/agent/users/page.tsx

@@ -0,0 +1,667 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/features/agent/hooks/useAuth";
+import { ResponsiveLayout } from "@/components/layout";
+import {
+  fetchUsers,
+  createUser,
+  updateUser,
+  deleteUser,
+  updateUserPassword,
+  type UserSummary,
+  type CreateUserRequest,
+  type UpdateUserRequest,
+  type UpdatePasswordRequest,
+} from "@/features/agent/services/userApi";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
+import { Badge } from "@/components/ui/badge";
+import { Card } from "@/components/ui/card";
+import { Label } from "@/components/ui/label";
+import {
+  Plus,
+  Edit,
+  Trash2,
+  Lock,
+  Search,
+  UserPlus,
+  Save,
+  X,
+} from "lucide-react";
+
+interface UsersPageProps {
+  embedded?: boolean; // 是否嵌入模式(不使用 ResponsiveLayout)
+}
+
+export default function UsersPage({ embedded = false }: UsersPageProps = {}) {
+  const router = useRouter();
+  const { agent } = useAuth();
+  const [users, setUsers] = useState<UserSummary[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [searchQuery, setSearchQuery] = useState("");
+  const [createDialogOpen, setCreateDialogOpen] = useState(false);
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+  const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
+  const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+  const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
+  const [submitting, setSubmitting] = useState(false);
+
+  // 创建用户表单
+  const [createForm, setCreateForm] = useState<CreateUserRequest>({
+    username: "",
+    password: "",
+    role: "agent",
+    nickname: "",
+    email: "",
+  });
+
+  // 编辑用户表单
+  const [editForm, setEditForm] = useState<UpdateUserRequest>({
+    role: "agent",
+    nickname: "",
+    email: "",
+    receive_ai_conversations: true,
+  });
+
+  // 修改密码表单
+  const [passwordForm, setPasswordForm] = useState<UpdatePasswordRequest>({
+    old_password: "",
+    new_password: "",
+  });
+
+  // 检查权限
+  useEffect(() => {
+    if (agent && agent.role !== "admin") {
+      router.push("/agent/dashboard");
+    }
+  }, [agent, router]);
+
+  // 加载用户列表
+  const loadUsers = useCallback(async () => {
+    if (!agent?.id) {
+      return;
+    }
+    setLoading(true);
+    try {
+      const data = await fetchUsers(agent.id);
+      setUsers(data);
+    } catch (error) {
+      console.error("加载用户列表失败:", error);
+      alert((error as Error).message || "加载用户列表失败");
+    } finally {
+      setLoading(false);
+    }
+  }, [agent?.id]);
+
+  // 初始加载
+  useEffect(() => {
+    loadUsers();
+  }, [loadUsers]);
+
+  // 过滤用户列表
+  const filteredUsers = users.filter((user) => {
+    if (!searchQuery.trim()) {
+      return true;
+    }
+    const query = searchQuery.toLowerCase();
+    return (
+      user.username.toLowerCase().includes(query) ||
+      (user.nickname && user.nickname.toLowerCase().includes(query)) ||
+      (user.email && user.email.toLowerCase().includes(query))
+    );
+  });
+
+  // 打开创建对话框
+  const handleOpenCreate = () => {
+    setCreateForm({
+      username: "",
+      password: "",
+      role: "agent",
+      nickname: "",
+      email: "",
+    });
+    setCreateDialogOpen(true);
+  };
+
+  // 创建用户
+  const handleCreate = async () => {
+    if (!agent?.id) {
+      return;
+    }
+    if (!createForm.username.trim() || !createForm.password.trim()) {
+      alert("用户名和密码不能为空");
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await createUser(createForm, agent.id);
+      setCreateDialogOpen(false);
+      await loadUsers();
+      alert("创建成功");
+    } catch (error) {
+      alert((error as Error).message || "创建用户失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 打开编辑对话框
+  const handleOpenEdit = (user: UserSummary) => {
+    setSelectedUser(user);
+    setEditForm({
+      role: user.role as "admin" | "agent",
+      nickname: user.nickname || "",
+      email: user.email || "",
+      receive_ai_conversations: user.receive_ai_conversations,
+    });
+    setEditDialogOpen(true);
+  };
+
+  // 更新用户
+  const handleUpdate = async () => {
+    if (!agent?.id || !selectedUser) {
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await updateUser(selectedUser.id, editForm, agent.id);
+      setEditDialogOpen(false);
+      setSelectedUser(null);
+      await loadUsers();
+      alert("更新成功");
+    } catch (error) {
+      alert((error as Error).message || "更新用户失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 打开修改密码对话框
+  const handleOpenPassword = (user: UserSummary) => {
+    setSelectedUser(user);
+    setPasswordForm({
+      old_password: "",
+      new_password: "",
+    });
+    setPasswordDialogOpen(true);
+  };
+
+  // 更新密码
+  const handleUpdatePassword = async () => {
+    if (!agent?.id || !selectedUser) {
+      return;
+    }
+    if (!passwordForm.new_password.trim()) {
+      alert("新密码不能为空");
+      return;
+    }
+    // 如果修改的是当前用户,需要旧密码;如果是其他用户,不需要旧密码
+    const isCurrentUser = selectedUser.id === agent.id;
+    if (isCurrentUser && !passwordForm.old_password?.trim()) {
+      alert("修改自己的密码需要提供旧密码");
+      return;
+    }
+
+    setSubmitting(true);
+    try {
+      await updateUserPassword(
+        selectedUser.id,
+        isCurrentUser ? passwordForm : { new_password: passwordForm.new_password },
+        agent.id
+      );
+      setPasswordDialogOpen(false);
+      setSelectedUser(null);
+      setPasswordForm({ old_password: "", new_password: "" });
+      alert("密码更新成功");
+    } catch (error) {
+      alert((error as Error).message || "更新密码失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 打开删除对话框
+  const handleOpenDelete = (user: UserSummary) => {
+    setSelectedUser(user);
+    setDeleteDialogOpen(true);
+  };
+
+  // 删除用户
+  const handleDelete = async () => {
+    if (!agent?.id || !selectedUser) {
+      return;
+    }
+    setSubmitting(true);
+    try {
+      await deleteUser(selectedUser.id, agent.id);
+      setDeleteDialogOpen(false);
+      setSelectedUser(null);
+      await loadUsers();
+      alert("删除成功");
+    } catch (error) {
+      alert((error as Error).message || "删除用户失败");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  // 格式化时间
+  const formatTime = (dateStr: string) => {
+    const date = new Date(dateStr);
+    return date.toLocaleString("zh-CN", {
+      year: "numeric",
+      month: "2-digit",
+      day: "2-digit",
+      hour: "2-digit",
+      minute: "2-digit",
+    });
+  };
+
+  if (!agent || agent.role !== "admin") {
+    return null; // 或者显示"权限不足"页面
+  }
+
+  // 构建头部内容
+  const headerContent = (
+    <div className="bg-card border-b p-4 shadow-sm">
+      <div className="flex items-center justify-between mb-4">
+        <h1 className="text-xl font-bold text-foreground">用户管理</h1>
+        {!embedded && (
+          <Button
+            variant="ghost"
+            size="sm"
+            onClick={() => router.push("/agent/dashboard")}
+          >
+            返回
+          </Button>
+        )}
+      </div>
+
+      {/* 搜索和操作栏 */}
+      <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
+        <div className="flex-1 relative">
+          <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
+          <Input
+            type="text"
+            placeholder="搜索用户(用户名、昵称、邮箱)..."
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            className="pl-10"
+          />
+        </div>
+        <Button
+          onClick={handleOpenCreate}
+          className="w-full sm:w-auto"
+        >
+          <UserPlus className="w-4 h-4 mr-2" />
+          创建用户
+        </Button>
+      </div>
+    </div>
+  );
+
+  // 构建主内容区
+  const mainContent = (
+    <div className="flex-1 overflow-y-auto p-4 scrollbar-auto">
+        {loading ? (
+          <div className="flex items-center justify-center h-full">
+            <span className="text-muted-foreground">加载中...</span>
+          </div>
+        ) : filteredUsers.length === 0 ? (
+          <div className="flex items-center justify-center h-full">
+            <span className="text-muted-foreground">
+              {searchQuery ? "没有找到匹配的用户" : "暂无用户"}
+            </span>
+          </div>
+        ) : (
+          <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+            {filteredUsers.map((user) => (
+              <Card key={user.id} className="p-4 flex flex-col">
+                <div className="mb-3 flex-1">
+                  <div className="flex items-center gap-2 mb-2">
+                    <span className="font-medium text-foreground">
+                      {user.nickname || user.username}
+                    </span>
+                    <Badge
+                      variant={user.role === "admin" ? "default" : "secondary"}
+                    >
+                      {user.role === "admin" ? "管理员" : "客服"}
+                    </Badge>
+                  </div>
+                  <div className="text-sm text-muted-foreground space-y-1 mb-2">
+                    <div>用户名: {user.username}</div>
+                    {user.email && <div>邮箱: {user.email}</div>}
+                  </div>
+                  <div className="text-xs text-muted-foreground">
+                    创建时间: {formatTime(user.created_at)}
+                  </div>
+                </div>
+
+                <div className="flex items-center gap-2 mt-auto pt-3 border-t">
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => handleOpenEdit(user)}
+                    className="flex-1"
+                  >
+                    <Edit className="w-4 h-4 mr-1" />
+                    编辑
+                  </Button>
+                  <Button
+                    variant="outline"
+                    size="sm"
+                    onClick={() => handleOpenPassword(user)}
+                    className="flex-1"
+                  >
+                    <Lock className="w-4 h-4 mr-1" />
+                    密码
+                  </Button>
+                  <Button
+                    variant="destructive"
+                    size="sm"
+                    onClick={() => handleOpenDelete(user)}
+                    disabled={user.id === agent.id}
+                    title={user.id === agent.id ? "不能删除当前登录用户" : ""}
+                  >
+                    <Trash2 className="w-4 h-4" />
+                  </Button>
+                </div>
+              </Card>
+            ))}
+          </div>
+        )}
+    </div>
+  );
+
+  // 如果是嵌入模式,只返回内容,不包含 ResponsiveLayout
+  if (embedded) {
+    return (
+      <>
+        <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+          {headerContent}
+          {mainContent}
+        </div>
+        {/* 对话框 */}
+
+      {/* 创建用户对话框 */}
+      <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>创建新用户</DialogTitle>
+          </DialogHeader>
+          <div className="space-y-4">
+            <div>
+              <Label htmlFor="create-username">用户名 *</Label>
+              <Input
+                id="create-username"
+                value={createForm.username}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, username: e.target.value })
+                }
+                placeholder="请输入用户名"
+              />
+            </div>
+            <div>
+              <Label htmlFor="create-password">密码 *</Label>
+              <Input
+                id="create-password"
+                type="password"
+                value={createForm.password}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, password: e.target.value })
+                }
+                placeholder="请输入密码"
+              />
+            </div>
+            <div>
+              <Label htmlFor="create-role">角色 *</Label>
+              <select
+                id="create-role"
+                value={createForm.role}
+                onChange={(e) =>
+                  setCreateForm({
+                    ...createForm,
+                    role: e.target.value as "admin" | "agent",
+                  })
+                }
+                className="w-full px-3 py-2 border border-border rounded-md bg-background"
+              >
+                <option value="agent">客服</option>
+                <option value="admin">管理员</option>
+              </select>
+            </div>
+            <div>
+              <Label htmlFor="create-nickname">昵称</Label>
+              <Input
+                id="create-nickname"
+                value={createForm.nickname}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, nickname: e.target.value })
+                }
+                placeholder="请输入昵称(可选)"
+              />
+            </div>
+            <div>
+              <Label htmlFor="create-email">邮箱</Label>
+              <Input
+                id="create-email"
+                type="email"
+                value={createForm.email}
+                onChange={(e) =>
+                  setCreateForm({ ...createForm, email: e.target.value })
+                }
+                placeholder="请输入邮箱(可选)"
+              />
+            </div>
+            <div className="flex justify-end gap-2">
+              <Button
+                variant="outline"
+                onClick={() => setCreateDialogOpen(false)}
+                disabled={submitting}
+              >
+                取消
+              </Button>
+              <Button onClick={handleCreate} disabled={submitting}>
+                {submitting ? "创建中..." : "创建"}
+              </Button>
+            </div>
+          </div>
+        </DialogContent>
+      </Dialog>
+
+      {/* 编辑用户对话框 */}
+      <Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>编辑用户</DialogTitle>
+          </DialogHeader>
+          {selectedUser && (
+            <div className="space-y-4">
+              <div>
+                <Label>用户名</Label>
+                <Input value={selectedUser.username} disabled />
+                <p className="text-xs text-muted-foreground mt-1">
+                  用户名不能修改
+                </p>
+              </div>
+              <div>
+                <Label htmlFor="edit-role">角色 *</Label>
+                <select
+                  id="edit-role"
+                  value={editForm.role}
+                  onChange={(e) =>
+                    setEditForm({
+                      ...editForm,
+                      role: e.target.value as "admin" | "agent",
+                    })
+                  }
+                  className="w-full px-3 py-2 border border-border rounded-md bg-background"
+                >
+                  <option value="agent">客服</option>
+                  <option value="admin">管理员</option>
+                </select>
+              </div>
+              <div>
+                <Label htmlFor="edit-nickname">昵称</Label>
+                <Input
+                  id="edit-nickname"
+                  value={editForm.nickname || ""}
+                  onChange={(e) =>
+                    setEditForm({ ...editForm, nickname: e.target.value })
+                  }
+                  placeholder="请输入昵称"
+                />
+              </div>
+              <div>
+                <Label htmlFor="edit-email">邮箱</Label>
+                <Input
+                  id="edit-email"
+                  type="email"
+                  value={editForm.email || ""}
+                  onChange={(e) =>
+                    setEditForm({ ...editForm, email: e.target.value })
+                  }
+                  placeholder="请输入邮箱"
+                />
+              </div>
+              <div className="flex items-center gap-2">
+                <input
+                  type="checkbox"
+                  id="edit-receive-ai"
+                  checked={editForm.receive_ai_conversations ?? true}
+                  onChange={(e) =>
+                    setEditForm({
+                      ...editForm,
+                      receive_ai_conversations: e.target.checked,
+                    })
+                  }
+                  className="w-4 h-4"
+                />
+                <Label htmlFor="edit-receive-ai" className="cursor-pointer">
+                  接收 AI 对话
+                </Label>
+              </div>
+              <div className="flex justify-end gap-2">
+                <Button
+                  variant="outline"
+                  onClick={() => setEditDialogOpen(false)}
+                  disabled={submitting}
+                >
+                  取消
+                </Button>
+                <Button onClick={handleUpdate} disabled={submitting}>
+                  {submitting ? "更新中..." : "更新"}
+                </Button>
+              </div>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 修改密码对话框 */}
+      <Dialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>修改密码</DialogTitle>
+          </DialogHeader>
+          {selectedUser && (
+            <div className="space-y-4">
+              <div>
+                <Label>用户名</Label>
+                <Input value={selectedUser.username} disabled />
+              </div>
+              {selectedUser.id === agent?.id && (
+                <div>
+                  <Label htmlFor="password-old">旧密码 *</Label>
+                  <Input
+                    id="password-old"
+                    type="password"
+                    value={passwordForm.old_password || ""}
+                    onChange={(e) =>
+                      setPasswordForm({
+                        ...passwordForm,
+                        old_password: e.target.value,
+                      })
+                    }
+                    placeholder="请输入旧密码"
+                  />
+                </div>
+              )}
+              <div>
+                <Label htmlFor="password-new">新密码 *</Label>
+                <Input
+                  id="password-new"
+                  type="password"
+                  value={passwordForm.new_password}
+                  onChange={(e) =>
+                    setPasswordForm({
+                      ...passwordForm,
+                      new_password: e.target.value,
+                    })
+                  }
+                  placeholder="请输入新密码"
+                />
+              </div>
+              <div className="flex justify-end gap-2">
+                <Button
+                  variant="outline"
+                  onClick={() => setPasswordDialogOpen(false)}
+                  disabled={submitting}
+                >
+                  取消
+                </Button>
+                <Button onClick={handleUpdatePassword} disabled={submitting}>
+                  {submitting ? "更新中..." : "更新"}
+                </Button>
+              </div>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      {/* 删除确认对话框 */}
+      <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
+        <DialogContent>
+          <DialogHeader>
+            <DialogTitle>删除用户</DialogTitle>
+          </DialogHeader>
+          {selectedUser && (
+            <div className="space-y-4">
+              <p className="text-foreground">
+                确定要删除用户 <strong>{selectedUser.username}</strong> 吗?
+              </p>
+              <p className="text-sm text-muted-foreground">
+                此操作不可恢复,请谨慎操作。
+              </p>
+              <div className="flex justify-end gap-2">
+                <Button
+                  variant="outline"
+                  onClick={() => setDeleteDialogOpen(false)}
+                  disabled={submitting}
+                >
+                  取消
+                </Button>
+                <Button
+                  variant="destructive"
+                  onClick={handleDelete}
+                  disabled={submitting}
+                >
+                  {submitting ? "删除中..." : "删除"}
+                </Button>
+              </div>
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+    </>
+  );
+}
+

+ 22 - 276
frontend/app/chat/page.tsx

@@ -1,64 +1,16 @@
 "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 };
-}
-
+import { useEffect, useState } from "react";
+import { ChatWidget } from "@/components/visitor/ChatWidget";
+import { FloatingButton } from "@/components/visitor/FloatingButton";
+
+/**
+ * 访客聊天页面
+ * 使用小窗插件形式,显示浮动按钮和聊天小窗
+ */
 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(() => {}, []);
+  const [isOpen, setIsOpen] = useState(false);
 
   // 初始化访客 ID(使用 localStorage 保持连续性)
   useEffect(() => {
@@ -71,232 +23,26 @@ export default function ChatPage() {
     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]);
+  const handleToggle = () => {
+    setIsOpen((prev) => !prev);
+  };
 
   if (visitorId === null) {
     return (
-      <div className="flex items-center justify-center min-h-screen bg-gray-50 text-gray-500">
-        正在初始化对话...
+      <div className="flex items-center justify-center min-h-screen bg-muted/30 text-muted-foreground">
+        正在初始化...
       </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>
+    <>
+      {/* 浮动按钮 */}
+      <FloatingButton onClick={handleToggle} isOpen={isOpen} />
+      {/* 聊天小窗 */}
+      {isOpen && (
+        <ChatWidget visitorId={visitorId} isOpen={isOpen} onToggle={handleToggle} />
+      )}
+    </>
   );
 }
-

+ 136 - 11
frontend/app/globals.css

@@ -1,26 +1,151 @@
 @import "tailwindcss";
 
 :root {
-  --background: #ffffff;
-  --foreground: #171717;
+  --background: 0 0% 100%;
+  --foreground: 222.2 84% 4.9%;
+  --card: 0 0% 100%;
+  --card-foreground: 222.2 84% 4.9%;
+  --popover: 0 0% 100%;
+  --popover-foreground: 222.2 84% 4.9%;
+  --primary: 217.2 91.2% 59.8%;
+  --primary-foreground: 0 0% 100%;
+  --secondary: 210 40% 96.1%;
+  --secondary-foreground: 222.2 47.4% 11.2%;
+  --muted: 210 40% 96.1%;
+  --muted-foreground: 215.4 16.3% 46.9%;
+  --accent: 210 40% 96.1%;
+  --accent-foreground: 222.2 47.4% 11.2%;
+  --destructive: 0 84.2% 60.2%;
+  --destructive-foreground: 210 40% 98%;
+  --border: 214.3 31.8% 91.4%;
+  --input: 214.3 31.8% 91.4%;
+  --ring: 217.2 91.2% 59.8%; /* 与 primary 颜色一致,使用蓝色而不是黑色 */
+  --radius: 0.5rem;
+  
+  /* 增强视觉细节,让 Shadcn UI 迁移更明显 */
+  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
+  --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
+  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
+  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
+}
+
+.dark {
+  --background: 222.2 84% 4.9%;
+  --foreground: 210 40% 98%;
+  --card: 222.2 84% 4.9%;
+  --card-foreground: 210 40% 98%;
+  --popover: 222.2 84% 4.9%;
+  --popover-foreground: 210 40% 98%;
+  --primary: 210 40% 98%;
+  --primary-foreground: 222.2 47.4% 11.2%;
+  --secondary: 217.2 32.6% 17.5%;
+  --secondary-foreground: 210 40% 98%;
+  --muted: 217.2 32.6% 17.5%;
+  --muted-foreground: 215 20.2% 65.1%;
+  --accent: 217.2 32.6% 17.5%;
+  --accent-foreground: 210 40% 98%;
+  --destructive: 0 62.8% 30.6%;
+  --destructive-foreground: 210 40% 98%;
+  --border: 217.2 32.6% 17.5%;
+  --input: 217.2 32.6% 17.5%;
+  --ring: 212.7 26.8% 83.9%;
 }
 
 @theme inline {
-  --color-background: var(--background);
-  --color-foreground: var(--foreground);
+  --color-background: hsl(var(--background));
+  --color-foreground: hsl(var(--foreground));
+  --color-card: hsl(var(--card));
+  --color-card-foreground: hsl(var(--card-foreground));
+  --color-popover: hsl(var(--popover));
+  --color-popover-foreground: hsl(var(--popover-foreground));
+  --color-primary: hsl(var(--primary));
+  --color-primary-foreground: hsl(var(--primary-foreground));
+  --color-secondary: hsl(var(--secondary));
+  --color-secondary-foreground: hsl(var(--secondary-foreground));
+  --color-muted: hsl(var(--muted));
+  --color-muted-foreground: hsl(var(--muted-foreground));
+  --color-accent: hsl(var(--accent));
+  --color-accent-foreground: hsl(var(--accent-foreground));
+  --color-destructive: hsl(var(--destructive));
+  --color-destructive-foreground: hsl(var(--destructive-foreground));
+  --color-border: hsl(var(--border));
+  --color-input: hsl(var(--input));
+  --color-ring: hsl(var(--ring));
   --font-sans: var(--font-geist-sans);
   --font-mono: var(--font-geist-mono);
 }
 
-@media (prefers-color-scheme: dark) {
-  :root {
-    --background: #0a0a0a;
-    --foreground: #ededed;
-  }
+* {
+  border-color: hsl(var(--border));
+  /* Firefox 滚动条 */
+  scrollbar-width: thin;
+  scrollbar-color: hsl(var(--border)) transparent;
 }
 
 body {
-  background: var(--background);
-  color: var(--foreground);
+  background-color: hsl(var(--background));
+  color: hsl(var(--foreground));
   font-family: Arial, Helvetica, sans-serif;
 }
+
+/* 自定义滚动条样式 - 更隐蔽、更现代 */
+
+/* Webkit (Chrome, Safari, Edge) */
+*::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+*::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+*::-webkit-scrollbar-thumb {
+  background-color: hsl(var(--border));
+  border-radius: 4px;
+  border: 2px solid transparent;
+  background-clip: padding-box;
+  transition: background-color 0.2s ease;
+}
+
+*::-webkit-scrollbar-thumb:hover {
+  background-color: hsl(var(--muted-foreground) / 0.3);
+}
+
+/* 可选:只在hover容器时显示滚动条 */
+.scrollbar-hide {
+  scrollbar-width: none; /* Firefox */
+  -ms-overflow-style: none; /* IE and Edge */
+}
+
+.scrollbar-hide::-webkit-scrollbar {
+  display: none; /* Chrome, Safari, Opera */
+}
+
+.scrollbar-auto {
+  scrollbar-width: thin;
+  scrollbar-color: transparent transparent;
+}
+
+.scrollbar-auto:hover {
+  scrollbar-color: hsl(var(--border)) transparent;
+}
+
+.scrollbar-auto::-webkit-scrollbar {
+  width: 8px;
+  height: 8px;
+}
+
+.scrollbar-auto::-webkit-scrollbar-track {
+  background: transparent;
+}
+
+.scrollbar-auto::-webkit-scrollbar-thumb {
+  background-color: transparent;
+  border-radius: 4px;
+  transition: background-color 0.2s ease;
+}
+
+.scrollbar-auto:hover::-webkit-scrollbar-thumb {
+  background-color: hsl(var(--border));
+}

+ 2 - 2
frontend/app/layout.tsx

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
 });
 
 export const metadata: Metadata = {
-  title: "Create Next App",
-  description: "Generated by create next app",
+  title: "AI-CS 智能客服系统",
+  description: "融合 AI 技术与人工客服,为企业提供高效、智能的客户服务解决方案",
 };
 
 export default function RootLayout({

+ 472 - 93
frontend/app/page.tsx

@@ -1,103 +1,482 @@
 "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;
+
+import { useEffect, useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { 
+  MessageSquare,
+  Bot, 
+  Users, 
+  Zap, 
+  Shield, 
+  BarChart3,
+  CheckCircle2,
+  ArrowRight,
+  Star,
+  HelpCircle,
+  LayoutDashboard,
+  FileText,
+  Globe
+} from "lucide-react";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import Link from "next/link";
+import { ScreenshotDisplay } from "@/components/ScreenshotDisplay";
+import { ChatWidget } from "@/components/visitor/ChatWidget";
+import { FloatingButton } from "@/components/visitor/FloatingButton";
+import { Header } from "@/components/layout/Header";
+import { Footer } from "@/components/layout/Footer";
+
+/**
+ * AI-CS 智能客服系统 - 产品官网首页
+ * 
+ * 包含:
+ * - Hero 区域(主标题、副标题、CTA按钮)
+ * - 核心功能介绍
+ * - 产品特性
+ * - 案例研究/客户评价
+ * - 底部 CTA
+ */
+export default function HomePage() {
+  const [visitorId, setVisitorId] = useState<number | null>(null);
+  const [isChatOpen, setIsChatOpen] = useState(false);
+
+  // 初始化访客 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 handleToggleChat = () => {
+    setIsChatOpen((prev) => !prev);
+  };
 
-    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);
+  const handleOpenChat = () => {
+    // 如果访客ID还没初始化,等待一下
+    if (visitorId === null) {
+      // 等待访客ID初始化后再打开
+      setTimeout(() => {
+        setIsChatOpen(true);
+      }, 500);
+    } else {
+      // 直接打开聊天窗口
+      setIsChatOpen(true);
     }
-  }
+  };
 
   return (
-    <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>
-
-        <form onSubmit={handleLogin}>
-          <input
-            type="text"
-            placeholder="用户名"
-            value={username}
-            onChange={(e) => setUsername(e.target.value)}
-            className="w-full p-3 mb-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400"
-            disabled={loading}
-          />
-          <input
-            type="password"
-            placeholder="密码"
-            value={password}
-            onChange={(e) => setPassword(e.target.value)}
-            className="w-full p-3 mb-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400"
-            disabled={loading}
-          />
-
-          {error && (
-            <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
-              {error}
-            </div>
-          )}
+    <div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
+      {/* 顶部导航栏 */}
+      <Header />
+
+      {/* Hero 区域 */}
+      <section className="container mx-auto px-4 py-20 md:py-32">
+        <div className="max-w-4xl mx-auto text-center">
+          <Badge className="mb-4" variant="secondary">
+            AI 驱动的智能客服解决方案
+          </Badge>
+          <h1 className="text-4xl md:text-6xl font-bold mb-6 bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
+            AI-CS 智能客服系统
+          </h1>
+          <p className="text-xl md:text-2xl text-muted-foreground mb-8 max-w-2xl mx-auto">
+            融合 AI 技术与人工客服,为企业提供高效、智能的客户服务解决方案
+          </p>
+          <div className="flex flex-col sm:flex-row gap-4 justify-center">
+            <Button size="lg" className="text-lg px-8" onClick={handleOpenChat}>
+              立即体验
+              <ArrowRight className="ml-2 w-5 h-5" />
+            </Button>
+            <Button asChild size="lg" variant="outline" className="text-lg px-8">
+              <Link href="/agent/login">
+                客服登录
+              </Link>
+            </Button>
+          </div>
+        </div>
+      </section>
+
+      {/* 核心功能 */}
+      <section id="features" className="container mx-auto px-4 py-16 md:py-24">
+        <div className="text-center mb-12">
+          <h2 className="text-3xl md:text-4xl font-bold mb-4">核心功能</h2>
+          <p className="text-muted-foreground text-lg max-w-2xl mx-auto">
+            强大的功能组合,满足企业客户服务的各种需求
+          </p>
+        </div>
+        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto">
+          <Card className="border-2 hover:border-primary/50 transition-colors">
+            <CardHeader>
+              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
+                <Bot className="w-6 h-6 text-primary" />
+              </div>
+              <CardTitle>AI 智能客服</CardTitle>
+              <CardDescription>
+                支持多种 AI 模型,7x24 小时自动回复客户咨询
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <ul className="space-y-2 text-sm text-muted-foreground">
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  多厂商 AI 模型支持
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  智能对话理解
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  自动上下文记忆
+                </li>
+              </ul>
+            </CardContent>
+          </Card>
+
+          <Card className="border-2 hover:border-primary/50 transition-colors">
+            <CardHeader>
+              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
+                <Users className="w-6 h-6 text-primary" />
+              </div>
+              <CardTitle>人工客服协作</CardTitle>
+              <CardDescription>
+                无缝切换 AI 与人工模式,提供最佳服务体验
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <ul className="space-y-2 text-sm text-muted-foreground">
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  实时消息同步
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  在线状态显示
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  多客服协作支持
+                </li>
+              </ul>
+            </CardContent>
+          </Card>
+
+          <Card className="border-2 hover:border-primary/50 transition-colors">
+            <CardHeader>
+              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
+                <MessageSquare className="w-6 h-6 text-primary" />
+              </div>
+              <CardTitle>实时通信</CardTitle>
+              <CardDescription>
+                WebSocket 实时消息推送,确保消息即时到达
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <ul className="space-y-2 text-sm text-muted-foreground">
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  双向实时通信
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  消息已读状态
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  文件/图片传输
+                </li>
+              </ul>
+            </CardContent>
+          </Card>
+
+          <Card className="border-2 hover:border-primary/50 transition-colors">
+            <CardHeader>
+              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
+                <Zap className="w-6 h-6 text-primary" />
+              </div>
+              <CardTitle>快速响应</CardTitle>
+              <CardDescription>
+                优化的系统架构,确保低延迟、高并发
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <ul className="space-y-2 text-sm text-muted-foreground">
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  毫秒级响应
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  高并发支持
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  自动重连机制
+                </li>
+              </ul>
+            </CardContent>
+          </Card>
+
+          <Card className="border-2 hover:border-primary/50 transition-colors">
+            <CardHeader>
+              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
+                <Shield className="w-6 h-6 text-primary" />
+              </div>
+              <CardTitle>安全可靠</CardTitle>
+              <CardDescription>
+                企业级安全保障,数据加密存储
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <ul className="space-y-2 text-sm text-muted-foreground">
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  API 密钥加密
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  权限管理
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  数据备份
+                </li>
+              </ul>
+            </CardContent>
+          </Card>
 
-          <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>
+          <Card className="border-2 hover:border-primary/50 transition-colors">
+            <CardHeader>
+              <div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
+                <BarChart3 className="w-6 h-6 text-primary" />
+              </div>
+              <CardTitle>数据统计</CardTitle>
+              <CardDescription>
+                全面的数据分析和报表功能
+              </CardDescription>
+            </CardHeader>
+            <CardContent>
+              <ul className="space-y-2 text-sm text-muted-foreground">
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  会话统计
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  客服工作量
+                </li>
+                <li className="flex items-center gap-2">
+                  <CheckCircle2 className="w-4 h-4 text-primary" />
+                  响应时间分析
+                </li>
+              </ul>
+            </CardContent>
+          </Card>
         </div>
-      </div>
+      </section>
+
+      {/* 界面展示 */}
+      <section id="screenshots" className="container mx-auto px-4 py-16 md:py-24">
+        <div className="text-center mb-12">
+          <h2 className="text-3xl md:text-4xl font-bold mb-4">界面展示</h2>
+          <p className="text-muted-foreground text-lg">
+            直观易用的界面设计,提升工作效率
+          </p>
+        </div>
+        <div className="max-w-6xl mx-auto">
+          <Tabs defaultValue="dashboard" className="w-full">
+            <TabsList className="grid w-full grid-cols-3 md:grid-cols-5 mb-8">
+              <TabsTrigger value="dashboard">工作台</TabsTrigger>
+              <TabsTrigger value="visitor">访客端</TabsTrigger>
+              <TabsTrigger value="ai-config">AI配置</TabsTrigger>
+              <TabsTrigger value="users">用户管理</TabsTrigger>
+              <TabsTrigger value="faq">FAQ管理</TabsTrigger>
+            </TabsList>
+            <TabsContent value="dashboard" className="mt-0">
+              <div className="border rounded-lg overflow-hidden">
+                <ScreenshotDisplay
+                  imageName="dashboard.png"
+                  placeholderIcon={LayoutDashboard}
+                  placeholderText="工作台界面"
+                  alt="AI-CS 工作台界面"
+                />
+              </div>
+            </TabsContent>
+            <TabsContent value="visitor" className="mt-0">
+              <div className="border rounded-lg overflow-hidden">
+                <ScreenshotDisplay
+                  imageName="visitor.png"
+                  placeholderIcon={Globe}
+                  placeholderText="访客端界面"
+                  alt="AI-CS 访客端界面"
+                />
+              </div>
+            </TabsContent>
+            <TabsContent value="ai-config" className="mt-0">
+              <div className="border rounded-lg overflow-hidden">
+                <ScreenshotDisplay
+                  imageName="ai-config.png"
+                  placeholderIcon={Bot}
+                  placeholderText="AI配置界面"
+                  alt="AI-CS AI配置界面"
+                />
+              </div>
+            </TabsContent>
+            <TabsContent value="users" className="mt-0">
+              <div className="border rounded-lg overflow-hidden">
+                <ScreenshotDisplay
+                  imageName="users.png"
+                  placeholderIcon={Users}
+                  placeholderText="用户管理界面"
+                  alt="AI-CS 用户管理界面"
+                />
+              </div>
+            </TabsContent>
+            <TabsContent value="faq" className="mt-0">
+              <div className="border rounded-lg overflow-hidden">
+                <ScreenshotDisplay
+                  imageName="faq.png"
+                  placeholderIcon={FileText}
+                  placeholderText="FAQ管理界面"
+                  alt="AI-CS FAQ管理界面"
+                />
+              </div>
+            </TabsContent>
+          </Tabs>
+        </div>
+      </section>
+
+      {/* 常见问题 */}
+      <section id="faq" className="container mx-auto px-4 py-16 md:py-24 bg-muted/30 rounded-3xl my-16">
+        <div className="max-w-4xl mx-auto">
+          <div className="text-center mb-12">
+            <h2 className="text-3xl md:text-4xl font-bold mb-4">常见问题</h2>
+            <p className="text-muted-foreground text-lg">
+              快速了解 AI-CS 智能客服系统
+            </p>
+          </div>
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <Card className="cursor-pointer hover:border-primary/50 transition-colors">
+              <CardContent className="p-6">
+                <div className="flex items-start gap-4">
+                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
+                    <HelpCircle className="w-5 h-5 text-primary" />
+                  </div>
+                  <div className="flex-1">
+                    <h3 className="font-semibold text-lg mb-2">AI-CS 支持哪些 AI 模型?</h3>
+                    <p className="text-muted-foreground text-sm">
+                      AI-CS 支持多种 AI 大模型,包括 OpenAI、DeepSeek、百智云等主流平台,支持自定义 API 配置。
+                    </p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
+            <Card className="cursor-pointer hover:border-primary/50 transition-colors">
+              <CardContent className="p-6">
+                <div className="flex items-start gap-4">
+                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
+                    <HelpCircle className="w-5 h-5 text-primary" />
+                  </div>
+                  <div className="flex-1">
+                    <h3 className="font-semibold text-lg mb-2">如何实现 AI 和人工客服的切换?</h3>
+                    <p className="text-muted-foreground text-sm">
+                      访客可以在聊天界面一键切换 AI 客服和人工客服模式,系统会自动处理会话转移。
+                    </p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
+            <Card className="cursor-pointer hover:border-primary/50 transition-colors">
+              <CardContent className="p-6">
+                <div className="flex items-start gap-4">
+                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
+                    <HelpCircle className="w-5 h-5 text-primary" />
+                  </div>
+                  <div className="flex-1">
+                    <h3 className="font-semibold text-lg mb-2">系统支持文件上传吗?</h3>
+                    <p className="text-muted-foreground text-sm">
+                      支持,访客和客服都可以上传图片和文件,支持图片预览和文件下载功能。
+                    </p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
+            <Card className="cursor-pointer hover:border-primary/50 transition-colors">
+              <CardContent className="p-6">
+                <div className="flex items-start gap-4">
+                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
+                    <HelpCircle className="w-5 h-5 text-primary" />
+                  </div>
+                  <div className="flex-1">
+                    <h3 className="font-semibold text-lg mb-2">如何集成到现有网站?</h3>
+                    <p className="text-muted-foreground text-sm">
+                      提供网页挂件(Widget)功能,只需在网站中嵌入一段代码即可,支持自定义样式和位置。
+                    </p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
+            <Card className="cursor-pointer hover:border-primary/50 transition-colors">
+              <CardContent className="p-6">
+                <div className="flex items-start gap-4">
+                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
+                    <HelpCircle className="w-5 h-5 text-primary" />
+                  </div>
+                  <div className="flex-1">
+                    <h3 className="font-semibold text-lg mb-2">系统支持多客服协作吗?</h3>
+                    <p className="text-muted-foreground text-sm">
+                      支持,多个客服可以同时在线,系统支持会话分配、转接和协作功能。
+                    </p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+
+            <Card className="cursor-pointer hover:border-primary/50 transition-colors">
+              <CardContent className="p-6">
+                <div className="flex items-start gap-4">
+                  <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
+                    <HelpCircle className="w-5 h-5 text-primary" />
+                  </div>
+                  <div className="flex-1">
+                    <h3 className="font-semibold text-lg mb-2">数据安全如何保障?</h3>
+                    <p className="text-muted-foreground text-sm">
+                      系统采用 API 密钥加密存储,支持权限管理,所有数据都经过安全加密处理。
+                    </p>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+          </div>
+        </div>
+      </section>
+
+      {/* 页脚 */}
+      <Footer />
+
+      {/* 客服插件 */}
+      {visitorId !== null && (
+        <>
+          {/* 浮动按钮 */}
+          <FloatingButton onClick={handleToggleChat} isOpen={isChatOpen} />
+          {/* 聊天小窗 */}
+          {isChatOpen && (
+            <ChatWidget
+              visitorId={visitorId}
+              isOpen={isChatOpen}
+              onToggle={handleToggleChat}
+            />
+          )}
+        </>
+      )}
     </div>
   );
-} 
+}

+ 21 - 0
frontend/components.json

@@ -0,0 +1,21 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "default",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "app/globals.css",
+    "baseColor": "slate",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  }
+}
+

+ 64 - 0
frontend/components/ScreenshotDisplay.tsx

@@ -0,0 +1,64 @@
+"use client";
+
+import Image from "next/image";
+import { useState } from "react";
+import { LucideIcon } from "lucide-react";
+
+interface ScreenshotDisplayProps {
+  imageName: string; // 图片文件名,如 "dashboard.png"
+  placeholderIcon: LucideIcon;
+  placeholderText: string;
+  alt: string;
+}
+
+/**
+ * 截图显示组件
+ * 如果图片存在则显示图片,否则显示占位符
+ */
+export function ScreenshotDisplay({
+  imageName,
+  placeholderIcon: PlaceholderIcon,
+  placeholderText,
+  alt,
+}: ScreenshotDisplayProps) {
+  const [imageError, setImageError] = useState(false);
+  const [imageLoaded, setImageLoaded] = useState(false);
+  const imagePath = `/images/screenshots/${imageName}`;
+
+  // 如果图片加载失败,显示占位符
+  if (imageError) {
+    return (
+      <div className="aspect-video flex items-center justify-center bg-gradient-to-br from-primary/10 to-primary/5">
+        <div className="text-center">
+          <PlaceholderIcon className="w-16 h-16 text-primary/50 mx-auto mb-4" />
+          <p className="text-muted-foreground">{placeholderText}</p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="relative aspect-video w-full overflow-hidden bg-muted/30">
+      <Image
+        src={imagePath}
+        alt={alt}
+        fill
+        className="object-contain"
+        onError={() => setImageError(true)}
+        onLoad={() => setImageLoaded(true)}
+        sizes="(max-width: 768px) 100vw, (max-width: 1200px) 80vw, 1200px"
+        priority={false}
+        unoptimized={false}
+      />
+      {!imageLoaded && !imageError && (
+        <div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary/10 to-primary/5">
+          <div className="text-center">
+            <PlaceholderIcon className="w-16 h-16 text-primary/50 mx-auto mb-4" />
+            <p className="text-muted-foreground">加载中...</p>
+          </div>
+        </div>
+      )}
+    </div>
+  );
+}
+

+ 32 - 33
frontend/components/dashboard/ChatHeader.tsx

@@ -1,6 +1,8 @@
 "use client";
 
 import { formatConversationTime } from "@/utils/format";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
 
 interface ChatHeaderProps {
   conversationId: number;
@@ -8,6 +10,8 @@ interface ChatHeaderProps {
   unreadCount: number;
   onMarkAllRead: () => void;
   onRefresh: () => void;
+  includeAIMessages?: boolean; // 是否包含 AI 消息
+  onToggleAIMessages?: () => void; // 切换 AI 消息显示/隐藏
 }
 
 export function ChatHeader({
@@ -16,24 +20,35 @@ export function ChatHeader({
   unreadCount,
   onMarkAllRead,
   onRefresh,
+  includeAIMessages = false,
+  onToggleAIMessages,
 }: 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">
+    <div className="h-16 flex items-center justify-between px-4 bg-background flex-shrink-0 relative">
+      <div className="z-10">
+        <div className="font-semibold text-foreground">对话 #{conversationId}</div>
+        <div className="text-xs text-muted-foreground 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"
-          }`}
+        {/* 显示/隐藏 AI 消息切换按钮 */}
+        {onToggleAIMessages && (
+          <Button
+            variant={includeAIMessages ? "default" : "outline"}
+            size="sm"
+            onClick={onToggleAIMessages}
+            title={includeAIMessages ? "隐藏 AI 消息" : "显示 AI 消息"}
+            className="text-xs"
+          >
+            {includeAIMessages ? "隐藏 AI 消息" : "显示 AI 消息"}
+          </Button>
+        )}
+        <Button
+          variant="ghost"
+          size="icon"
           title="标记全部已读"
           onClick={onMarkAllRead}
           disabled={unreadCount === 0}
@@ -51,14 +66,15 @@ export function ChatHeader({
               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"
+        </Button>
+        <Button
+          variant="ghost"
+          size="icon"
           title="刷新"
           onClick={onRefresh}
         >
           <svg
-            className="w-5 h-5 text-gray-600"
+            className="w-5 h-5 text-muted-foreground"
             fill="none"
             stroke="currentColor"
             viewBox="0 0 24 24"
@@ -70,26 +86,9 @@ export function ChatHeader({
               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>
+        </Button>
       </div>
+      <Separator className="absolute bottom-0 left-0 right-0" />
     </div>
   );
 }

+ 38 - 20
frontend/components/dashboard/ConversationHeader.tsx

@@ -1,28 +1,45 @@
 "use client";
 
-export function ConversationHeader() {
+import { Separator } from "@/components/ui/separator";
+
+export type ConversationFilter = "all" | "mine" | "others";
+
+interface ConversationHeaderProps {
+  filter: ConversationFilter;
+  onFilterChange: (filter: ConversationFilter) => void;
+}
+
+export function ConversationHeader({
+  filter,
+  onFilterChange,
+}: ConversationHeaderProps) {
+  const getFilterLabel = (f: ConversationFilter) => {
+    switch (f) {
+      case "all":
+        return "全部";
+      case "mine":
+        return "自己的";
+      case "others":
+        return "其他的";
+      default:
+        return "全部";
+    }
+  };
+
   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"
+    <div className="h-16 flex items-center justify-between px-4 bg-background flex-shrink-0 relative">
+      <div className="flex items-center gap-1 relative">
+        <select
+          value={filter}
+          onChange={(e) => onFilterChange(e.target.value as ConversationFilter)}
+          className="font-semibold text-foreground bg-transparent border-none outline-none cursor-pointer appearance-none pr-6 min-w-fit"
         >
-          <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>
+          <option value="all">All chats</option>
+          <option value="mine">My chats</option>
+          <option value="others">Others chats</option>
+        </select>
         <svg
-          className="w-4 h-4 text-gray-400"
+          className="w-4 h-4 text-muted-foreground pointer-events-none flex-shrink-0"
           fill="none"
           stroke="currentColor"
           viewBox="0 0 24 24"
@@ -35,6 +52,7 @@ export function ConversationHeader() {
           />
         </svg>
       </div>
+      <Separator className="absolute bottom-0 left-0 right-0" />
     </div>
   );
 }

+ 3 - 3
frontend/components/dashboard/ConversationList.tsx

@@ -18,8 +18,8 @@ export function ConversationList({
 }: ConversationListProps) {
   if (conversations.length === 0) {
     return (
-      <div className="flex-1 overflow-y-auto">
-        <div className="text-center text-gray-400 mt-8 text-sm">
+      <div className="flex-1 overflow-y-auto scrollbar-auto">
+        <div className="text-center text-muted-foreground mt-8 text-sm">
           {searchQuery ? "未找到匹配的对话" : "暂无对话"}
         </div>
       </div>
@@ -27,7 +27,7 @@ export function ConversationList({
   }
 
   return (
-    <div className="flex-1 overflow-y-auto">
+    <div className="flex-1 overflow-y-auto px-2 py-2 scrollbar-auto">
       {conversations.map((conversation) => (
         <ConversationListItem
           key={conversation.id}

+ 18 - 17
frontend/components/dashboard/ConversationListItem.tsx

@@ -6,6 +6,8 @@ import {
   formatConversationTime,
   isVisitorOnline,
 } from "@/utils/format";
+import { Badge } from "@/components/ui/badge";
+import { Card } from "@/components/ui/card";
 
 interface ConversationListItemProps {
   conversation: ConversationSummary;
@@ -28,7 +30,7 @@ export function ConversationListItem({
   const isOnline = isVisitorOnline(conversation.last_seen_at);
 
   return (
-    <div
+    <Card
       onClick={(event) => {
         event.preventDefault();
         event.stopPropagation();
@@ -39,8 +41,10 @@ export function ConversationListItem({
           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"
+      className={`p-4 mb-2 cursor-pointer transition-all select-none border-0 shadow-sm hover:shadow-md ${
+        selected
+          ? "bg-primary/5 border-l-4 border-l-primary shadow-md"
+          : "hover:bg-accent/50"
       }`}
     >
       <div className="flex items-start gap-3">
@@ -52,7 +56,7 @@ export function ConversationListItem({
         </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">
+            <span className="font-medium text-foreground text-sm truncate">
               对话 #{conversation.id}
             </span>
             {/* 在线/离线状态图标 */}
@@ -64,25 +68,22 @@ export function ConversationListItem({
               />
             )}
             {unreadCount > 0 && (
-              <span className="px-1.5 py-0.5 rounded-full text-[10px] bg-blue-500 text-white flex-shrink-0">
+              <Badge variant="destructive" className="flex-shrink-0">
                 {unreadCount > 99 ? "99+" : unreadCount}
-              </span>
+              </Badge>
             )}
-            <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"
-              }`}
+            <Badge
+              variant={conversation.status === "open" ? "default" : "secondary"}
+              className="flex-shrink-0"
             >
               {conversation.status === "open" ? "进行中" : "已关闭"}
-            </span>
+            </Badge>
           </div>
-          <div className="text-xs text-gray-600 mb-1 flex items-center gap-1">
+          <div className="text-xs text-muted-foreground 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 ? "text-primary/70" : "text-muted-foreground"
                 }`}
               >
                 {lastMessage.is_read ? "✓✓" : "✓"}
@@ -90,13 +91,13 @@ export function ConversationListItem({
             )}
             <span className="truncate">{lastMessagePreview}</span>
           </div>
-          <div className="flex items-center justify-between text-xs text-gray-400">
+          <div className="flex items-center justify-between text-xs text-muted-foreground">
             <span>访客 #{conversation.visitor_id}</span>
             <span>{formatConversationTime(conversation.updated_at)}</span>
           </div>
         </div>
       </div>
-    </div>
+    </Card>
   );
 }
 

+ 6 - 4
frontend/components/dashboard/ConversationSearch.tsx

@@ -1,5 +1,7 @@
 "use client";
 
+import { Input } from "@/components/ui/input";
+
 interface ConversationSearchProps {
   value: string;
   onChange: (value: string) => void;
@@ -10,17 +12,17 @@ export function ConversationSearch({
   onChange,
 }: ConversationSearchProps) {
   return (
-    <div className="p-4 border-b border-gray-200">
+    <div className="p-4">
       <div className="relative">
-        <input
+        <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"
+          className="w-full pl-9"
         />
         <svg
-          className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400"
+          className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground"
           fill="none"
           stroke="currentColor"
           viewBox="0 0 24 24"

+ 6 - 2
frontend/components/dashboard/ConversationSidebar.tsx

@@ -1,7 +1,7 @@
 "use client";
 
 import { ConversationSummary } from "@/features/agent/types";
-import { ConversationHeader } from "./ConversationHeader";
+import { ConversationHeader, type ConversationFilter } from "./ConversationHeader";
 import { ConversationSearch } from "./ConversationSearch";
 import { ConversationList } from "./ConversationList";
 
@@ -11,6 +11,8 @@ interface ConversationSidebarProps {
   searchQuery: string;
   onSearchChange: (value: string) => void;
   onSelectConversation: (id: number) => void;
+  filter: ConversationFilter;
+  onFilterChange: (filter: ConversationFilter) => void;
 }
 
 export function ConversationSidebar({
@@ -19,10 +21,12 @@ export function ConversationSidebar({
   searchQuery,
   onSearchChange,
   onSelectConversation,
+  filter,
+  onFilterChange,
 }: ConversationSidebarProps) {
   return (
     <div className="w-80 bg-white border-r border-gray-200 flex flex-col min-h-0">
-      <ConversationHeader />
+      <ConversationHeader filter={filter} onFilterChange={onFilterChange} />
       <ConversationSearch value={searchQuery} onChange={onSearchChange} />
       <ConversationList
         conversations={conversations}

+ 7 - 40
frontend/components/dashboard/DashboardHeader.tsx

@@ -1,6 +1,8 @@
 "use client";
 
 import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
+import { Button } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
 
 interface DashboardHeaderProps {
   username: string;
@@ -23,52 +25,17 @@ export function DashboardHeader({
   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 className="h-16 flex items-center justify-between px-6 bg-background flex-shrink-0 relative">
+      <div className="flex items-center gap-3 z-10">
         <div>
-          <div className="text-sm text-gray-500">当前账号</div>
-          <div className="text-base font-semibold text-gray-800">
+          <div className="text-sm text-muted-foreground">当前账号</div>
+          <div className="text-base font-semibold text-foreground">
             {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>
+      <Separator className="absolute bottom-0 left-0 right-0" />
     </div>
   );
 }

+ 146 - 80
frontend/components/dashboard/DashboardShell.tsx

@@ -1,24 +1,34 @@
 "use client";
 
 import { useCallback, useMemo, useState } from "react";
+import dynamic from "next/dynamic";
 
 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 { ResponsiveLayout } from "@/components/layout";
+import { LAYOUT } from "@/lib/constants/breakpoints";
 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 { NavigationSidebar, type NavigationPage } from "./NavigationSidebar";
 import { ProfileModal } from "./ProfileModal";
 import { VisitorDetailPanel } from "./VisitorDetailPanel";
 
+// 动态导入其他页面组件
+const FAQsPage = dynamic(() => import("@/app/agent/faqs/page").then(mod => ({ default: mod.default })), { ssr: false });
+const UsersPage = dynamic(() => import("@/app/agent/users/page").then(mod => ({ default: mod.default })), { ssr: false });
+const SettingsPage = dynamic(() => import("@/app/agent/settings/page").then(mod => ({ default: mod.default })), { ssr: false });
+
 export function DashboardShell() {
   // 登录状态:负责从本地存储读取客服信息,并提供登出方法
   const { agent, loading: authLoading, logout } = useAuth();
+  
+  // 页面状态管理(必须在所有其他 Hooks 之前声明,确保 Hooks 调用顺序一致)
+  const [currentPage, setCurrentPage] = useState<NavigationPage>("dashboard");
 
   // 个人资料状态
   const [profileModalOpen, setProfileModalOpen] = useState(false);
@@ -32,18 +42,27 @@ export function DashboardShell() {
     userId: agent?.id ?? null,
     enabled: Boolean(agent?.id),
   });
+
+  // 会话过滤状态
+  const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
+
   // 会话状态:包含会话列表、搜索关键字、选中的会话等
   const {
-    conversations,
-    filteredConversations,
-    selectedConversationId,
-    searchQuery,
-    loading,
-    isInitialLoad,
-    setSearchQuery,
-    selectConversation,
-    updateConversation,
-  } = useConversations();
+            conversations,
+            filteredConversations,
+            selectedConversationId,
+            searchQuery,
+            loading,
+            isInitialLoad,
+            setSearchQuery,
+            selectConversation,
+            updateConversation,
+            refresh: refreshConversations,
+            hasConversation,
+          } = useConversations({
+            agentId: agent?.id ?? null, // 传递客服ID,用于建立全局 WebSocket 连接
+            filter: conversationFilter, // 传递过滤类型
+          });
 
   // 输入框内容与搜索高亮关键字
   const [messageInput, setMessageInput] = useState("");
@@ -69,10 +88,14 @@ export function DashboardShell() {
     sendMessage,
     markMessagesAsRead,
     updateContactInfo,
+    includeAIMessages,
+    toggleAIMessages,
   } = useMessages({
     conversationId: selectedConversationId,
     agentId: agent?.id ?? null,
     updateConversation,
+    refreshConversations,
+    hasConversation,
   });
 
   // 左侧选择会话时,记录关键字用于消息高亮
@@ -89,13 +112,10 @@ export function DashboardShell() {
   );
 
   // 发送消息:调用 service 后清空输入框
-  const handleSendMessage = useCallback(async () => {
+  const handleSendMessage = useCallback(async (fileInfo?: { file_url: string; file_type: string; file_name: string; file_size: number; mime_type: string }) => {
     const content = messageInput.trim();
-    if (!content) {
-      return;
-    }
     try {
-      await sendMessage(content);
+      await sendMessage(content, fileInfo);
       setMessageInput("");
     } catch (error) {
       alert((error as Error).message);
@@ -142,10 +162,19 @@ export function DashboardShell() {
     [refreshProfile]
   );
 
+  // 处理导航切换(必须在所有条件返回之前声明)
+  const handleNavigate = useCallback((page: NavigationPage) => {
+    setCurrentPage(page);
+    // 如果切换到非 dashboard 页面,清空选中的对话
+    if (page !== "dashboard") {
+      selectConversation(null);
+    }
+  }, [selectConversation]);
+
   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 className="flex justify-center items-center min-h-screen bg-background">
+        <div className="text-lg text-muted-foreground">加载中...</div>
       </div>
     );
   }
@@ -154,67 +183,105 @@ export function DashboardShell() {
     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>
+  // 构建侧边栏内容(包含导航栏和对话列表)
+  // 在 dashboard 页面时,显示导航栏 + 对话列表
+  // 在其他页面时,只显示导航栏
+  const sidebarContent = currentPage === "dashboard" ? (
+    <div className="flex h-full">
+      <NavigationSidebar 
+        currentPage={currentPage}
+        onNavigate={handleNavigate}
+        onProfileClick={() => setProfileModalOpen(true)}
+        onLogout={logout}
+        avatarUrl={profile?.avatar_url}
+      />
+      <ConversationSidebar
+        conversations={filteredConversations}
+        selectedConversationId={selectedConversationId}
+        searchQuery={searchQuery}
+        onSearchChange={setSearchQuery}
+        onSelectConversation={handleConversationSelect}
+        filter={conversationFilter}
+        onFilterChange={setConversationFilter}
+      />
+    </div>
+  ) : (
+    <div className="flex h-full">
+      <NavigationSidebar 
+        currentPage={currentPage}
+        onNavigate={handleNavigate}
+        onProfileClick={() => setProfileModalOpen(true)}
+        onLogout={logout}
+        avatarUrl={profile?.avatar_url}
+      />
+    </div>
+  );
 
-          <VisitorDetailPanel
-            conversation={selectedConversation}
-            detail={conversationDetail}
-            onRefresh={handleRefreshVisitor}
-            onUpdateContact={updateContactInfo}
-          />
+  // 构建主内容区
+  const mainContent = (
+    <div className="flex-1 flex flex-col bg-background min-h-0">
+      {currentPage === "dashboard" ? (
+        selectedConversationId ? (
+          <>
+            <ChatHeader
+              conversationId={selectedConversationId}
+              lastSeenAt={conversationDetail?.last_seen_at}
+              unreadCount={selectedUnreadCount}
+              onMarkAllRead={handleMarkAllRead}
+              onRefresh={handleRefreshChat}
+              includeAIMessages={includeAIMessages}
+              onToggleAIMessages={toggleAIMessages}
+            />
+            <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}
+              conversationId={selectedConversationId ?? undefined}
+            />
+          </>
+        ) : (
+          <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
+            选择一个对话开始聊天
+          </div>
+        )
+      ) : (
+        <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+          {currentPage === "faqs" && <FAQsPage embedded={true} />}
+          {currentPage === "users" && <UsersPage embedded={true} />}
+          {currentPage === "settings" && <SettingsPage embedded={true} />}
         </div>
-      </div>
+      )}
+    </div>
+  );
+
+  // 构建右侧面板(仅在 dashboard 页面且选中对话时显示)
+  const rightPanelContent = currentPage === "dashboard" && selectedConversationId ? (
+    <VisitorDetailPanel
+      conversation={selectedConversation}
+      detail={conversationDetail}
+      onRefresh={handleRefreshVisitor}
+      onUpdateContact={updateContactInfo}
+    />
+  ) : undefined;
+
+  return (
+    <>
+      <ResponsiveLayout
+        sidebar={sidebarContent}
+        main={mainContent}
+        rightPanel={rightPanelContent}
+        sidebarWidth={currentPage === "dashboard" ? undefined : LAYOUT.navigationWidth}
+      />
 
       {/* 个人资料弹窗 */}
       <ProfileModal
@@ -223,7 +290,6 @@ export function DashboardShell() {
         onClose={() => setProfileModalOpen(false)}
         onUpdate={handleProfileUpdate}
       />
-    </div>
+    </>
   );
 }
-

+ 252 - 26
frontend/components/dashboard/MessageInput.tsx

@@ -1,12 +1,22 @@
 "use client";
 
-import { FormEvent, useEffect, useRef } from "react";
+import { FormEvent, useEffect, useRef, useState, useCallback } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { uploadFile, UploadFileResult } from "@/features/agent/services/messageApi";
+import { X, Paperclip, Image as ImageIcon } from "lucide-react";
 
 interface MessageInputProps {
   value: string;
   onChange: (value: string) => void;
-  onSubmit: () => Promise<void> | void;
+  onSubmit: (fileInfo?: UploadFileResult) => Promise<void> | void;
   sending: boolean;
+  conversationId?: number; // 对话ID,用于文件上传
+}
+
+interface FilePreview {
+  file: File;
+  preview?: string; // 图片预览URL
 }
 
 export function MessageInput({
@@ -14,11 +24,18 @@ export function MessageInput({
   onChange,
   onSubmit,
   sending,
+  conversationId,
 }: MessageInputProps) {
   // 输入框引用,用于发送消息后自动聚焦
   const inputRef = useRef<HTMLInputElement>(null);
+  // 文件输入框引用
+  const fileInputRef = useRef<HTMLInputElement>(null);
   // 记录上一次的 sending 状态,用于判断是否刚刚完成发送
   const prevSendingRef = useRef<boolean>(false);
+  // 文件预览状态
+  const [filePreview, setFilePreview] = useState<FilePreview | null>(null);
+  // 上传中状态
+  const [uploading, setUploading] = useState(false);
 
   // 当发送状态从 true 变为 false 时(发送完成),自动聚焦到输入框
   useEffect(() => {
@@ -34,37 +51,246 @@ export function MessageInput({
     prevSendingRef.current = sending;
   }, [sending]);
 
+  // 处理文件选择
+  const handleFileSelect = useCallback(
+    async (file: File) => {
+      // 验证文件大小(10MB)
+      const MAX_FILE_SIZE = 10 * 1024 * 1024;
+      if (file.size > MAX_FILE_SIZE) {
+        alert("文件大小超过限制(最大10MB)");
+        return;
+      }
+
+      // 验证文件类型
+      const ext = file.name.toLowerCase().split(".").pop();
+      const allowedExts = ["jpg", "jpeg", "png", "gif", "webp", "pdf", "doc", "docx", "txt"];
+      if (!ext || !allowedExts.includes(ext)) {
+        alert("不支持的文件类型");
+        return;
+      }
+
+      // 如果是图片,生成预览
+      let preview: string | undefined;
+      if (file.type.startsWith("image/")) {
+        preview = URL.createObjectURL(file);
+      }
+
+      setFilePreview({ file, preview });
+    },
+    []
+  );
+
+  // 处理文件输入框变化
+  const handleFileInputChange = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      const file = event.target.files?.[0];
+      if (file) {
+        handleFileSelect(file);
+      }
+      // 清空文件输入框,允许重复选择同一文件
+      if (fileInputRef.current) {
+        fileInputRef.current.value = "";
+      }
+    },
+    [handleFileSelect]
+  );
+
+  // 处理拖拽上传
+  const handleDragOver = useCallback((event: React.DragEvent) => {
+    event.preventDefault();
+    event.stopPropagation();
+  }, []);
+
+  const handleDrop = useCallback(
+    (event: React.DragEvent) => {
+      event.preventDefault();
+      event.stopPropagation();
+      const file = event.dataTransfer.files?.[0];
+      if (file) {
+        handleFileSelect(file);
+      }
+    },
+    [handleFileSelect]
+  );
+
+  // 处理粘贴图片
+  useEffect(() => {
+    const handlePaste = (event: ClipboardEvent) => {
+      const items = event.clipboardData?.items;
+      if (!items) return;
+
+      for (let i = 0; i < items.length; i++) {
+        const item = items[i];
+        if (item.type.startsWith("image/")) {
+          const file = item.getAsFile();
+          if (file) {
+            event.preventDefault();
+            handleFileSelect(file);
+            break;
+          }
+        }
+      }
+    };
+
+    const input = inputRef.current;
+    if (input) {
+      input.addEventListener("paste", handlePaste);
+      return () => {
+        input.removeEventListener("paste", handlePaste);
+      };
+    }
+  }, [handleFileSelect]);
+
+  // 移除文件预览
+  const handleRemoveFile = useCallback(() => {
+    if (filePreview?.preview) {
+      URL.revokeObjectURL(filePreview.preview);
+    }
+    setFilePreview(null);
+  }, [filePreview]);
+
+  // 格式化文件大小
+  const formatFileSize = (bytes: number): string => {
+    if (bytes < 1024) return bytes + " B";
+    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+    return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+  };
+
+  // 处理提交
   const handleSubmit = async (event: FormEvent) => {
     event.preventDefault();
-    if (sending) {
+    if (sending || uploading) {
       return;
     }
-    await onSubmit();
-    // 注意:聚焦逻辑由 useEffect 处理,当 sending 从 true 变为 false 时会自动聚焦
+
+    // 验证:必须有内容或文件
+    if (!value.trim() && !filePreview) {
+      return;
+    }
+
+    try {
+      let fileInfo: UploadFileResult | undefined;
+
+      // 如果有文件,先上传文件
+      if (filePreview) {
+        setUploading(true);
+        try {
+          fileInfo = await uploadFile(filePreview.file, conversationId);
+        } catch (error) {
+          alert((error as Error).message || "文件上传失败");
+          setUploading(false);
+          return;
+        }
+        setUploading(false);
+      }
+
+      // 发送消息(包含文件信息)
+      await onSubmit(fileInfo);
+
+      // 清空输入和文件预览
+      onChange("");
+      handleRemoveFile();
+    } catch (error) {
+      console.error("发送消息失败:", error);
+    }
   };
 
+  // 清理预览URL
+  useEffect(() => {
+    return () => {
+      if (filePreview?.preview) {
+        URL.revokeObjectURL(filePreview.preview);
+      }
+    };
+  }, [filePreview]);
+
   return (
-    <form
-      onSubmit={handleSubmit}
-      className="border-t border-gray-200 px-4 py-3 flex items-center gap-2 bg-white flex-shrink-0"
+    <div
+      className="bg-gradient-to-t from-background to-muted/30 flex-shrink-0 border-t border-border/50"
+      onDragOver={handleDragOver}
+      onDrop={handleDrop}
     >
-      <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>
+      {/* 文件预览区域 */}
+      {filePreview && (
+        <div className="px-4 pt-3 pb-2 flex items-start gap-2">
+          <div className="flex-1 min-w-0">
+            {filePreview.preview ? (
+              // 图片预览
+              <div className="relative inline-block">
+                <img
+                  src={filePreview.preview}
+                  alt="预览"
+                  className="max-w-[200px] max-h-[200px] rounded-lg object-cover border border-border shadow-sm"
+                />
+                <div className="mt-1 text-xs text-muted-foreground">
+                  {filePreview.file.name} ({formatFileSize(filePreview.file.size)})
+                </div>
+              </div>
+            ) : (
+              // 文档预览
+              <div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg border border-border/50">
+                <Paperclip className="w-4 h-4 text-muted-foreground" />
+                <div className="flex-1 min-w-0">
+                  <div className="text-sm font-medium truncate">{filePreview.file.name}</div>
+                  <div className="text-xs text-muted-foreground">
+                    {formatFileSize(filePreview.file.size)}
+                  </div>
+                </div>
+              </div>
+            )}
+          </div>
+          <Button
+            type="button"
+            variant="ghost"
+            size="sm"
+            onClick={handleRemoveFile}
+            className="flex-shrink-0 hover:bg-destructive/10 hover:text-destructive"
+            disabled={sending || uploading}
+          >
+            <X className="w-4 h-4" />
+          </Button>
+        </div>
+      )}
+
+      {/* 输入区域 */}
+      <form onSubmit={handleSubmit} className="px-4 py-3 flex items-center gap-2">
+        <input
+          ref={fileInputRef}
+          type="file"
+          accept="image/*,.pdf,.doc,.docx,.txt"
+          onChange={handleFileInputChange}
+          className="hidden"
+        />
+        <Button
+          type="button"
+          variant="ghost"
+          size="sm"
+          onClick={() => fileInputRef.current?.click()}
+          disabled={sending || uploading}
+          title="上传文件"
+          className="hover:bg-primary/10 hover:text-primary transition-colors"
+        >
+          <Paperclip className="w-4 h-4" />
+        </Button>
+        <Input
+          ref={inputRef}
+          type="text"
+          placeholder={filePreview ? "添加消息(可选)..." : "输入消息..."}
+          value={value}
+          onChange={(event) => onChange(event.target.value)}
+          className="flex-1 border-border/50 focus:border-primary/50 focus:ring-primary/20"
+          disabled={sending || uploading}
+        />
+        <Button
+          type="submit"
+          disabled={sending || uploading || (!value.trim() && !filePreview)}
+          variant="default"
+          size="default"
+          className="bg-primary hover:bg-primary/90 shadow-md hover:shadow-lg transition-all"
+        >
+          {uploading ? "上传中..." : sending ? "发送中..." : "发送"}
+        </Button>
+      </form>
+    </div>
   );
 }
-

+ 295 - 115
frontend/components/dashboard/MessageList.tsx

@@ -1,9 +1,14 @@
 "use client";
 
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
 import { MessageItem } from "@/features/agent/types";
 import { formatMessageTime } from "@/utils/format";
 import { highlightText } from "@/utils/highlight";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogContent } from "@/components/ui/dialog";
+import { Paperclip, Download, X } from "lucide-react";
+import { API_BASE_URL } from "@/lib/config";
 
 interface MessageListProps {
   messages: MessageItem[];
@@ -34,6 +39,10 @@ export function MessageList({
   const lastMarkedReadRef = useRef<number>(0);
   const lastMessageIdRef = useRef<number | null>(null);
   const lastMessageCountRef = useRef<number>(0);
+  const hasInitialScrolledRef = useRef(false); // 标记是否已经完成初始滚动
+  // 图片预览状态(必须在所有条件返回之前声明)
+  const [imagePreviewOpen, setImagePreviewOpen] = useState(false);
+  const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
 
   useEffect(() => {
     if (conversationId !== lastConversationIdRef.current) {
@@ -41,6 +50,7 @@ export function MessageList({
       shouldStickToBottomRef.current = true;
       lastMessageIdRef.current = null;
       lastMessageCountRef.current = 0;
+      hasInitialScrolledRef.current = false; // 重置初始滚动标记
     }
   }, [conversationId]);
 
@@ -134,45 +144,80 @@ export function MessageList({
         return;
       }
 
-      // 在 DOM 更新后检查当前位置
-      const { scrollTop, scrollHeight, clientHeight } = currentContainer;
-      const distanceToBottom = scrollHeight - scrollTop - clientHeight;
-      const isNearBottom = distanceToBottom < 100;
-      // 更新 shouldStickToBottomRef,确保使用最新的位置信息
-      shouldStickToBottomRef.current = isNearBottom;
+      // 对于新消息,需要延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
+      // 使用双重 requestAnimationFrame + 小延迟,给图片加载留出时间
+      const checkAndScroll = () => {
+        const container = containerRef.current;
+        if (!container) {
+          return;
+        }
+
+        // 在 DOM 更新后检查当前位置
+        const { scrollTop, scrollHeight, clientHeight } = container;
+        const distanceToBottom = scrollHeight - scrollTop - clientHeight;
+        const isNearBottom = distanceToBottom < 100;
+        // 更新 shouldStickToBottomRef,确保使用最新的位置信息
+        shouldStickToBottomRef.current = isNearBottom;
+
+        // 检查是否是初始加载(首次加载消息或切换对话后首次加载)
+        const isInitialLoad = !hasInitialScrolledRef.current && messages.length > 0;
+
+        // 滚动逻辑:
+        // 1. 如果是初始加载(首次加载消息或切换对话),无论什么情况都自动滚动到底部
+        // 2. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true)
+        // 3. 如果最后一条消息是对方发送的:
+        //    - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
+        //    - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
+        //    - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
+        // 4. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
+        // 这样确保访客端和客服端的行为一致:初始加载时显示最新消息,当用户在底部附近时,收到新消息会自动滚动到底部
+        const shouldAutoScroll =
+          isInitialLoad ||
+          (hasNewMessage &&
+            (isLastMessageFromCurrentUser || isNearBottom));
+        
+
+        if (keyword) {
+          const keywordLower = keyword.toLowerCase();
+          const matchingMessage = messages.find((message) =>
+            message.content.toLowerCase().includes(keywordLower)
+          );
 
-      // 滚动逻辑:
-      // 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",
-              });
+          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: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
+            });
+            // 标记初始滚动已完成
+            if (isInitialLoad) {
+              hasInitialScrolledRef.current = true;
             }
-            setTimeout(onHighlightClear, 3000);
           };
-
-          setTimeout(scroll, 200);
+          setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
+          onHighlightClear();
+          }
         } else {
           if (!shouldAutoScroll) {
             return;
@@ -182,62 +227,87 @@ export function MessageList({
             if (!container) {
               return;
             }
+            
+            // 如果 scrollHeight === clientHeight,说明没有滚动条,强制设置高度
+            // 这通常发生在 flex 布局中,子元素高度没有正确限制时
+            if (container.scrollHeight === container.clientHeight && container.parentElement) {
+              const parent = container.parentElement;
+              const parentHeight = parent.offsetHeight;
+              container.style.height = `${parentHeight}px`;
+              container.style.maxHeight = `${parentHeight}px`;
+            }
+            
             container.scrollTo({
               top: container.scrollHeight,
-              behavior: "smooth",
+              behavior: isInitialLoad ? "auto" : "smooth", // 初始加载时使用 instant,避免动画
             });
+            // 标记初始滚动已完成
+            if (isInitialLoad) {
+              hasInitialScrolledRef.current = true;
+            }
           };
-          setTimeout(scrollBottom, 100);
-          onHighlightClear();
-        }
-      } else {
-        if (!shouldAutoScroll) {
-          return;
+          setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
         }
-        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;
+        // 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
+        // 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
+        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;
-          });
+            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;
+            if (unreadMessages.length > 0) {
+              // 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
+              const now = Date.now();
+              if (now - lastMarkedReadRef.current < 2000) {
+                return;
+              }
+              onMarkMessagesRead(conversationId, currentUserIsAgent);
+              lastMarkedReadRef.current = now;
             }
-            onMarkMessagesRead(conversationId, currentUserIsAgent);
-            lastMarkedReadRef.current = now;
-          }
-        }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
+          }, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
+        }
+      };
+
+      // 对于新消息,延迟一点再检查位置,确保 DOM 完全更新(特别是图片/文件消息)
+      if (hasNewMessage) {
+        // 检查最后一条消息是否包含图片/文件
+        const lastMessageHasFile = lastMessage.file_url;
+        
+        if (lastMessageHasFile) {
+          // 如果包含文件,延迟更长时间,确保图片加载完成
+          requestAnimationFrame(() => {
+            requestAnimationFrame(() => {
+              setTimeout(() => {
+                checkAndScroll();
+              }, 200); // 给图片加载留出更多时间
+            });
+          });
+        } else {
+          // 普通消息,正常延迟
+          requestAnimationFrame(() => {
+            requestAnimationFrame(() => {
+              checkAndScroll();
+            });
+          });
+        }
+      } else {
+        // 非新消息(如状态更新),直接检查
+        checkAndScroll();
       }
     });
   }, [
@@ -252,27 +322,52 @@ export function MessageList({
 
   if (loading) {
     return (
-      <div className="flex-1 flex items-center justify-center bg-gray-50">
-        <span className="text-sm text-gray-500">消息加载中...</span>
+      <div className="flex-1 flex items-center justify-center bg-muted/30">
+        <span className="text-sm text-muted-foreground">消息加载中...</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 ref={containerRef} className="flex-1 min-h-0 overflow-y-auto p-4 bg-muted/30 scrollbar-auto">
+        <div className="text-center text-muted-foreground 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) => {
+    <>
+      {/* 图片预览对话框 */}
+      <Dialog open={imagePreviewOpen} onOpenChange={setImagePreviewOpen}>
+        <DialogContent className="max-w-4xl max-h-[90vh] p-0">
+          {previewImageUrl && (
+            <div className="relative">
+              <Button
+                variant="ghost"
+                size="sm"
+                className="absolute top-2 right-2 z-10"
+                onClick={() => setImagePreviewOpen(false)}
+              >
+                <X className="w-4 h-4" />
+              </Button>
+              <img
+                src={previewImageUrl}
+                alt="预览"
+                className="w-full h-auto max-h-[90vh] object-contain"
+              />
+            </div>
+          )}
+        </DialogContent>
+      </Dialog>
+
+      <div
+        ref={containerRef}
+        className="h-full w-full overflow-y-auto p-4 bg-muted/30 scrollbar-auto"
+        style={{ height: '100%' }}
+      >
+        <div className="space-y-4">
+          {messages.map((message) => {
           const keyword = highlightKeyword.trim();
           const isMatching =
             keyword !== "" &&
@@ -289,33 +384,66 @@ export function MessageList({
                 ref={(element) => {
                   messageRefs.current[message.id] = element;
                 }}
-                className={`text-center text-xs text-gray-500`}
+                className={`text-center text-xs text-muted-foreground`}
               >
-                <span className="inline-block px-3 py-1 rounded-full bg-gray-200 text-gray-700">
+                <Badge variant="secondary" className="inline-block">
                   {message.content}
-                </span>
+                </Badge>
               </div>
             );
           }
 
-          const isSenderAgent = message.sender_is_agent;
+          // 确保 sender_is_agent 是布尔值
+          const isSenderAgent = Boolean(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";
+            ? "bg-primary text-primary-foreground shadow-md"
+            : "bg-card text-card-foreground border border-border/50 shadow-sm";
           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"
-            : "";
+          // 计算已读回执的样式类名
+          // 统一使用相同的样式:蓝色半透明(text-primary/70)
+          // 因为访客端和客服端的当前用户消息都是蓝色背景(bg-primary),所以使用相同的样式
+          const receiptClass = isCurrentUser ? "text-primary/70" : "";
+
+          // 文件相关
+          const hasFile = Boolean(message.file_url);
+          const isImage = message.file_type === "image";
+          const isDocument = message.file_type === "document";
+
+          // 获取文件URL(完整URL)
+          const getFileUrl = (fileUrl: string | null | undefined): string => {
+            if (!fileUrl) return "";
+            if (fileUrl.startsWith("http")) return fileUrl;
+            return `${API_BASE_URL}${fileUrl}`;
+          };
+
+          // 格式化文件大小
+          const formatFileSize = (bytes: number | null | undefined): string => {
+            if (!bytes) return "";
+            if (bytes < 1024) return bytes + " B";
+            if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
+            return (bytes / (1024 * 1024)).toFixed(1) + " MB";
+          };
+
+          // 打开图片预览
+          const handleImageClick = (url: string) => {
+            setPreviewImageUrl(url);
+            setImagePreviewOpen(true);
+          };
+
+          // 下载文件
+          const handleDownload = (url: string, fileName: string | null | undefined) => {
+            const link = document.createElement("a");
+            link.href = url;
+            link.download = fileName || "file";
+            link.target = "_blank";
+            document.body.appendChild(link);
+            link.click();
+            document.body.removeChild(link);
+          };
 
           return (
             <div
@@ -327,15 +455,66 @@ export function MessageList({
             >
               <div className="max-w-[70%]">
                 <div
-                  className={`px-4 py-2 rounded-2xl shadow-sm ${
+                  className={`px-4 py-2.5 rounded-2xl ${
                     cornerClass
-                  } ${bubbleColor}`}
+                  } ${bubbleColor} transition-shadow hover:shadow-md`}
                 >
-                  <div className="whitespace-pre-wrap break-words text-sm">
-                    {bubbleContent}
-                  </div>
+                  {/* 文本内容 */}
+                  {message.content && (
+                    <div className="whitespace-pre-wrap break-words text-sm">
+                      {bubbleContent}
+                    </div>
+                  )}
+                  
+                  {/* 文件显示 */}
+                  {hasFile && message.file_url && (
+                    <div className={message.content ? "mt-2" : ""}>
+                      {isImage ? (
+                        // 图片预览
+                        <div
+                          className="cursor-pointer rounded-lg overflow-hidden max-w-[300px] border border-border/30 hover:border-primary/50 transition-colors shadow-sm"
+                          onClick={() => handleImageClick(getFileUrl(message.file_url))}
+                        >
+                          <img
+                            src={getFileUrl(message.file_url)}
+                            alt={message.file_name || "图片"}
+                            className="max-w-full h-auto"
+                            loading="lazy"
+                          />
+                        </div>
+                      ) : isDocument ? (
+                        // 文档显示
+                        <div className="flex items-center gap-2 p-3 bg-background/60 rounded-lg border border-border/30 hover:bg-background/80 transition-colors">
+                          <Paperclip className="w-4 h-4 flex-shrink-0" />
+                          <div className="flex-1 min-w-0">
+                            <div className="text-sm font-medium truncate">
+                              {message.file_name || "文件"}
+                            </div>
+                            {message.file_size && (
+                              <div className="text-xs text-muted-foreground">
+                                {formatFileSize(message.file_size)}
+                              </div>
+                            )}
+                          </div>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() =>
+                              handleDownload(
+                                getFileUrl(message.file_url),
+                                message.file_name
+                              )
+                            }
+                            className="flex-shrink-0"
+                          >
+                            <Download className="w-4 h-4" />
+                          </Button>
+                        </div>
+                      ) : null}
+                    </div>
+                  )}
                 </div>
-                <div className="flex items-center gap-1 mt-1 text-[10px] text-gray-400">
+                <div className="flex items-center gap-1 mt-1 text-[10px] text-muted-foreground">
                   {isCurrentUser && (
                     <span className={receiptClass}>
                       {message.is_read ? "✓✓" : "✓"}
@@ -347,8 +526,9 @@ export function MessageList({
             </div>
           );
         })}
+        </div>
       </div>
-    </div>
+    </>
   );
 }
 

+ 226 - 36
frontend/components/dashboard/NavigationSidebar.tsx

@@ -1,14 +1,77 @@
 "use client";
 
-export function NavigationSidebar() {
+import { useState, useRef, useEffect } from "react";
+import { useAuth } from "@/features/agent/hooks/useAuth";
+import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
+import { Button } from "@/components/ui/button";
+
+export type NavigationPage = "dashboard" | "faqs" | "users" | "settings";
+
+interface NavigationSidebarProps {
+  currentPage?: NavigationPage;
+  onNavigate?: (page: NavigationPage) => void;
+  onProfileClick?: () => void;
+  onLogout?: () => void;
+  avatarUrl?: string | null;
+}
+
+export function NavigationSidebar({ 
+  currentPage = "dashboard",
+  onNavigate,
+  onProfileClick,
+  onLogout,
+  avatarUrl,
+}: NavigationSidebarProps) {
+  const { agent } = useAuth();
+  const [profileMenuOpen, setProfileMenuOpen] = useState(false);
+  const menuRef = useRef<HTMLDivElement>(null);
+  
+  // 检查当前用户是否是管理员
+  const isAdmin = agent?.role === "admin";
+
+  const handleNavigate = (page: NavigationPage) => {
+    if (onNavigate) {
+      onNavigate(page);
+    }
+  };
+
+  // 点击外部关闭菜单
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent) => {
+      if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+        setProfileMenuOpen(false);
+      }
+    };
+
+    if (profileMenuOpen) {
+      document.addEventListener("mousedown", handleClickOutside);
+    }
+
+    return () => {
+      document.removeEventListener("mousedown", handleClickOutside);
+    };
+  }, [profileMenuOpen]);
+
+  // 头像相关
+  const avatarColor = getAvatarColor(agent?.username || "");
+  const displayInitial = getAvatarInitial(agent?.username || "");
+  const fullAvatarUrl = getAvatarUrl(avatarUrl);
+
   return (
-    <div className="w-16 bg-gray-50 flex flex-col items-center py-4 border-r border-gray-200">
+    <div className="w-16 bg-gray-50 flex flex-col items-center py-4 border-r border-gray-200 h-full">
       <button
-        className="w-10 h-10 rounded-lg bg-green-600 flex items-center justify-center mb-4 hover:bg-green-700 transition-colors"
+        className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
+          currentPage === "dashboard"
+            ? "bg-green-600 hover:bg-green-700"
+            : "bg-white border border-gray-200 hover:bg-gray-100"
+        }`}
         title="对话"
+        onClick={() => handleNavigate("dashboard")}
       >
         <svg
-          className="w-6 h-6 text-white"
+          className={`w-6 h-6 ${
+            currentPage === "dashboard" ? "text-white" : "text-gray-600"
+          }`}
           fill="none"
           stroke="currentColor"
           viewBox="0 0 24 24"
@@ -43,12 +106,18 @@ export function NavigationSidebar() {
       </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"
+        className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
+          currentPage === "faqs"
+            ? "bg-green-600 hover:bg-green-700"
+            : "bg-white border border-gray-200 hover:bg-gray-100"
+        }`}
         title="事件管理"
-        disabled
+        onClick={() => handleNavigate("faqs")}
       >
         <svg
-          className="w-6 h-6 text-gray-600"
+          className={`w-6 h-6 ${
+            currentPage === "faqs" ? "text-white" : "text-gray-600"
+          }`}
           fill="none"
           stroke="currentColor"
           viewBox="0 0 24 24"
@@ -62,33 +131,47 @@ export function NavigationSidebar() {
         </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"
+      {isAdmin && (
+        <button
+          className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
+            currentPage === "users"
+              ? "bg-green-600 hover:bg-green-700"
+              : "bg-white border border-gray-200 hover:bg-gray-100"
+          }`}
+          title="用户管理"
+          onClick={() => handleNavigate("users")}
         >
-          <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>
+          <svg
+            className={`w-6 h-6 ${
+              currentPage === "users" ? "text-white" : "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
+        className={`w-10 h-10 rounded-lg flex items-center justify-center mb-4 transition-colors ${
+          currentPage === "settings"
+            ? "bg-green-600 hover:bg-green-700"
+            : "bg-white border border-gray-200 hover:bg-gray-100"
+        }`}
+        title="AI 配置"
+        onClick={() => handleNavigate("settings")}
       >
         <svg
-          className="w-6 h-6 text-gray-600"
+          className={`w-6 h-6 ${
+            currentPage === "settings" ? "text-white" : "text-gray-600"
+          }`}
           fill="none"
           stroke="currentColor"
           viewBox="0 0 24 24"
@@ -97,16 +180,123 @@ export function NavigationSidebar() {
             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"
+            d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
           />
         </svg>
       </button>
+
+      {/* 个人资料按钮(固定在底部) */}
+      <div className="mt-auto relative" ref={menuRef}>
+        <button
+          className={`w-10 h-10 rounded-lg flex items-center justify-center transition-colors ${
+            profileMenuOpen
+              ? "bg-primary text-primary-foreground"
+              : "bg-white border border-gray-200 hover:bg-gray-100"
+          }`}
+          title="个人资料"
+          onClick={() => setProfileMenuOpen(!profileMenuOpen)}
+        >
+          {fullAvatarUrl ? (
+            <img
+              src={fullAvatarUrl}
+              alt={agent?.username || "用户"}
+              className="w-8 h-8 rounded-full object-cover"
+            />
+          ) : (
+            <div
+              className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold"
+              style={{ backgroundColor: avatarColor }}
+            >
+              {displayInitial}
+            </div>
+          )}
+        </button>
+
+        {/* 下拉菜单 */}
+        {profileMenuOpen && (
+          <div className="absolute bottom-12 left-0 w-64 bg-white border border-gray-200 rounded-lg shadow-lg z-50">
+            {/* 用户信息 */}
+            <div className="p-4 border-b border-gray-200">
+              <div className="flex items-center gap-3">
+                {fullAvatarUrl ? (
+                  <img
+                    src={fullAvatarUrl}
+                    alt={agent?.username || "用户"}
+                    className="w-12 h-12 rounded-full object-cover"
+                  />
+                ) : (
+                  <div
+                    className="w-12 h-12 rounded-full flex items-center justify-center text-white text-sm font-semibold"
+                    style={{ backgroundColor: avatarColor }}
+                  >
+                    {displayInitial}
+                  </div>
+                )}
+                <div>
+                  <div className="text-sm font-semibold text-foreground">
+                    {agent?.username || "用户"}
+                  </div>
+                  <div className="text-xs text-muted-foreground">
+                    {agent?.role === "admin" ? "管理员" : "客服"}
+                  </div>
+                </div>
+              </div>
+            </div>
+
+            {/* 菜单项 */}
+            <div className="p-2">
+              <Button
+                variant="ghost"
+                size="sm"
+                className="w-full justify-start"
+                onClick={() => {
+                  setProfileMenuOpen(false);
+                  onProfileClick?.();
+                }}
+              >
+                <svg
+                  className="w-4 h-4 mr-2"
+                  fill="none"
+                  stroke="currentColor"
+                  viewBox="0 0 24 24"
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
+                  />
+                </svg>
+                个人资料
+              </Button>
+              <Button
+                variant="ghost"
+                size="sm"
+                className="w-full justify-start text-red-600 hover:text-red-700 hover:bg-red-50"
+                onClick={() => {
+                  setProfileMenuOpen(false);
+                  onLogout?.();
+                }}
+              >
+                <svg
+                  className="w-4 h-4 mr-2"
+                  fill="none"
+                  stroke="currentColor"
+                  viewBox="0 0 24 24"
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
+                  />
+                </svg>
+                退出登录
+              </Button>
+            </div>
+          </div>
+        )}
+      </div>
     </div>
   );
 }

+ 20 - 0
frontend/components/dashboard/PageWrapper.tsx

@@ -0,0 +1,20 @@
+"use client";
+
+import { ReactNode } from "react";
+
+/**
+ * 页面包装组件
+ * 用于在 DashboardShell 中显示其他页面内容,去掉 ResponsiveLayout 包装
+ */
+interface PageWrapperProps {
+  children: ReactNode;
+}
+
+export function PageWrapper({ children }: PageWrapperProps) {
+  return (
+    <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+      {children}
+    </div>
+  );
+}
+

+ 53 - 63
frontend/components/dashboard/ProfileModal.tsx

@@ -8,6 +8,14 @@ import {
   UpdateProfilePayload,
 } from "@/features/agent/services/profileApi";
 import { getAvatarUrl, getAvatarColor, getAvatarInitial } from "@/utils/avatar";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
 
 interface ProfileModalProps {
   profile: Profile | null;
@@ -132,7 +140,7 @@ export function ProfileModal({
     }
   }, [profile, email, onUpdate]);
 
-  if (!open || !profile) {
+  if (!profile) {
     return null;
   }
 
@@ -142,31 +150,11 @@ export function ProfileModal({
   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>
+    <Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
+      <DialogContent className="max-h-[90vh] overflow-y-auto scrollbar-auto">
+        <DialogHeader>
+          <DialogTitle>个人资料</DialogTitle>
+        </DialogHeader>
 
         {/* 错误提示 */}
         {errorMessage && (
@@ -206,13 +194,15 @@ export function ProfileModal({
             onChange={handleAvatarSelect}
             disabled={uploading}
           />
-          <button
+          <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"
+            variant="default"
+            size="default"
+            className="mt-3"
           >
             {uploading ? "上传中..." : "更换头像"}
-          </button>
+          </Button>
         </div>
 
         {/* 用户名(只读) */}
@@ -232,39 +222,44 @@ export function ProfileModal({
           <div className="text-sm text-gray-500 mb-1 flex items-center justify-between">
             <span>昵称</span>
             {!editingNickname ? (
-              <button
+              <Button
                 onClick={() => setEditingNickname(true)}
-                className="text-blue-500 text-xs hover:text-blue-600"
+                variant="ghost"
+                size="sm"
+                className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                 disabled={saving}
               >
                 编辑
-              </button>
+              </Button>
             ) : (
               <div className="flex gap-2">
-                <button
+                <Button
                   onClick={() => {
                     setEditingNickname(false);
                     setNickname(profile.nickname || "");
                   }}
-                  className="text-gray-500 text-xs hover:text-gray-600"
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-gray-500 hover:text-gray-600"
                   disabled={saving}
                 >
                   取消
-                </button>
-                <button
+                </Button>
+                <Button
                   onClick={handleSaveNickname}
-                  className="text-blue-500 text-xs hover:text-blue-600"
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                   disabled={saving}
                 >
                   保存
-                </button>
+                </Button>
               </div>
             )}
           </div>
           {editingNickname ? (
-            <input
+            <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="请输入昵称"
@@ -282,39 +277,44 @@ export function ProfileModal({
           <div className="text-sm text-gray-500 mb-1 flex items-center justify-between">
             <span>邮箱</span>
             {!editingEmail ? (
-              <button
+              <Button
                 onClick={() => setEditingEmail(true)}
-                className="text-blue-500 text-xs hover:text-blue-600"
+                variant="ghost"
+                size="sm"
+                className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                 disabled={saving}
               >
                 编辑
-              </button>
+              </Button>
             ) : (
               <div className="flex gap-2">
-                <button
+                <Button
                   onClick={() => {
                     setEditingEmail(false);
                     setEmail(profile.email || "");
                   }}
-                  className="text-gray-500 text-xs hover:text-gray-600"
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-gray-500 hover:text-gray-600"
                   disabled={saving}
                 >
                   取消
-                </button>
-                <button
+                </Button>
+                <Button
                   onClick={handleSaveEmail}
-                  className="text-blue-500 text-xs hover:text-blue-600"
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                   disabled={saving}
                 >
                   保存
-                </button>
+                </Button>
               </div>
             )}
           </div>
           {editingEmail ? (
-            <input
+            <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="请输入邮箱"
@@ -327,18 +327,8 @@ export function ProfileModal({
           )}
         </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>
+      </DialogContent>
+    </Dialog>
   );
 }
 

+ 99 - 72
frontend/components/dashboard/VisitorDetailPanel.tsx

@@ -7,6 +7,17 @@ import {
   formatConversationTime,
   isVisitorOnline,
 } from "@/utils/format";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { Separator } from "@/components/ui/separator";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import {
+  Dialog,
+  DialogContent,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog";
 
 type ContactField = "email" | "phone" | "notes";
 type ContactUpdatePayload = Partial<Record<ContactField, string>>;
@@ -121,8 +132,8 @@ export function VisitorDetailPanel({
   };
 
   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="w-80 bg-background border-l border-border flex flex-col min-h-0">
+      <div className="h-16 flex items-center justify-between px-4 flex-shrink-0 relative z-10">
         <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"
@@ -131,21 +142,22 @@ export function VisitorDetailPanel({
             {conversation.visitor_id.toString().slice(-2)}
           </div>
           <div>
-            <div className="font-semibold text-gray-800 text-sm">
+            <div className="font-semibold text-foreground text-sm">
               访客 #{conversation.visitor_id}
             </div>
-            <div className="text-xs text-gray-500">
+            <div className="text-xs text-muted-foreground">
               {isOnline ? (
                 <span className="text-green-600">● 在线</span>
               ) : (
-                <span className="text-gray-400">● 离线</span>
+                <span className="text-muted-foreground">● 离线</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"
+          <Button
+            variant="ghost"
+            size="icon"
             title="刷新"
             onClick={onRefresh}
           >
@@ -162,9 +174,10 @@ export function VisitorDetailPanel({
                 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"
+          </Button>
+          <Button
+            variant="ghost"
+            size="icon"
             title="更多选项"
           >
             <svg
@@ -180,24 +193,30 @@ export function VisitorDetailPanel({
                 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>
+          </Button>
         </div>
       </div>
+      <Separator className="absolute bottom-0 left-0 right-0" />
 
-      <div className="flex-1 overflow-y-auto px-4 py-4 space-y-6">
+      <div className="flex-1 overflow-y-auto px-4 py-4 space-y-4 scrollbar-auto">
         {/* 联系信息区域 */}
-        <div className="p-4 border-b border-gray-200">
-          <h3 className="text-sm font-semibold text-gray-700 mb-3">联系信息</h3>
+        <Card>
+          <CardHeader className="pb-3">
+            <CardTitle className="text-sm font-semibold">联系信息</CardTitle>
+          </CardHeader>
+          <CardContent className="pt-0">
           <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"
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                   onClick={() => handleOpenEditor("email")}
                 >
                   {actionLabel("email")}
-                </button>
+                </Button>
               </div>
               <div className="text-xs text-gray-700 break-all">
                 {displayValue(detail?.email, "暂未填写")}
@@ -206,12 +225,14 @@ export function VisitorDetailPanel({
             <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"
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                   onClick={() => handleOpenEditor("phone")}
                 >
                   {actionLabel("phone")}
-                </button>
+                </Button>
               </div>
               <div className="text-xs text-gray-700 break-all">
                 {displayValue(detail?.phone, "暂未填写")}
@@ -220,23 +241,29 @@ export function VisitorDetailPanel({
             <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"
+                <Button
+                  variant="ghost"
+                  size="sm"
+                  className="text-xs h-auto py-0 px-1 text-blue-500 hover:text-blue-600"
                   onClick={() => handleOpenEditor("notes")}
                 >
                   {actionLabel("notes")}
-                </button>
+                </Button>
               </div>
               <div className="text-xs text-gray-700 whitespace-pre-wrap break-words min-h-[1rem]">
                 {displayValue(detail?.notes, "暂无备注")}
               </div>
             </div>
           </div>
-        </div>
+          </CardContent>
+        </Card>
 
         {/* 技术信息区域 */}
-        <div className="p-4">
-          <h3 className="text-sm font-semibold text-gray-700 mb-3">技术信息</h3>
+        <Card>
+          <CardHeader className="pb-3">
+            <CardTitle className="text-sm font-semibold">技术信息</CardTitle>
+          </CardHeader>
+          <CardContent className="pt-0">
           <div className="space-y-3 text-sm">
             <div>
               <div className="text-gray-500 mb-1 text-xs">网站</div>
@@ -307,55 +334,55 @@ export function VisitorDetailPanel({
               </div>
             </div>
           </div>
-        </div>
+          </CardContent>
+        </Card>
       </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>
+      <Dialog open={!!editingField} onOpenChange={() => !saving && handleCloseEditor()}>
+        <DialogContent className="max-w-sm">
+          <DialogHeader>
+            <DialogTitle>编辑{editingField ? fieldLabels[editingField] : ""}</DialogTitle>
+          </DialogHeader>
+          {editingField === "notes" ? (
+            <Textarea
+              className="w-full resize-none h-32"
+              value={editingValue}
+              onChange={(event) => setEditingValue(event.target.value)}
+              placeholder={`请输入${editingField ? fieldLabels[editingField] : ""}`}
+            />
+          ) : (
+            <Input
+              type="text"
+              value={editingValue}
+              onChange={(event) => setEditingValue(event.target.value)}
+              placeholder={`请输入${editingField ? 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"
+              variant="outline"
+              size="sm"
+              onClick={handleCloseEditor}
+              disabled={saving}
+            >
+              取消
+            </Button>
+            <Button
+              type="button"
+              variant="default"
+              size="sm"
+              onClick={handleSubmit}
+              disabled={saving}
+            >
+              {saving ? "保存中..." : "保存"}
+            </Button>
           </div>
-        </div>
-      )}
+        </DialogContent>
+      </Dialog>
     </div>
   );
 }

+ 153 - 0
frontend/components/layout/Footer.tsx

@@ -0,0 +1,153 @@
+"use client";
+
+import Link from "next/link";
+import { Github, Mail, MessageSquare } from "lucide-react";
+import { websiteConfig } from "@/lib/website-config";
+
+/**
+ * 官网底部页脚
+ * 包含公司信息、友情链接、联系方式等
+ */
+export function Footer() {
+  return (
+    <footer className="border-t bg-muted/30">
+      <div className="container mx-auto px-4 py-12">
+        <div className="grid grid-cols-1 md:grid-cols-4 gap-8">
+          {/* 关于产品 */}
+          <div>
+            <div className="flex items-center space-x-2 mb-4">
+              <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
+                <span className="text-primary-foreground font-bold text-lg">AI</span>
+              </div>
+              <span className="text-lg font-bold">AI-CS</span>
+            </div>
+            <p className="text-sm text-muted-foreground mb-4">
+              AI-CS 是一款 AI 驱动的智能客服系统,融合 AI 技术与人工客服,为企业提供高效、智能的客户服务解决方案。
+            </p>
+            <div className="flex items-center space-x-4">
+              <a
+                href={websiteConfig.github.repo}
+                target="_blank"
+                rel="noopener noreferrer"
+                className="text-muted-foreground hover:text-foreground transition-colors"
+                aria-label="GitHub"
+              >
+                <Github className="w-5 h-5" />
+              </a>
+            </div>
+          </div>
+
+          {/* 产品链接 */}
+          <div>
+            <h3 className="font-semibold mb-4">产品</h3>
+            <ul className="space-y-2 text-sm">
+              <li>
+                <Link
+                  href="#features"
+                  className="text-muted-foreground hover:text-foreground transition-colors"
+                >
+                  功能特性
+                </Link>
+              </li>
+              <li>
+                <Link
+                  href="#screenshots"
+                  className="text-muted-foreground hover:text-foreground transition-colors"
+                >
+                  界面展示
+                </Link>
+              </li>
+              <li>
+                <Link
+                  href="#faq"
+                  className="text-muted-foreground hover:text-foreground transition-colors"
+                >
+                  常见问题
+                </Link>
+              </li>
+              <li>
+                <Link
+                  href="/agent/login"
+                  className="text-muted-foreground hover:text-foreground transition-colors"
+                >
+                  客服登录
+                </Link>
+              </li>
+            </ul>
+          </div>
+
+          {/* 友情链接 */}
+          <div>
+            <h3 className="font-semibold mb-4">友情链接</h3>
+            <ul className="space-y-2 text-sm">
+              {websiteConfig.friendLinks.length > 0 ? (
+                websiteConfig.friendLinks.map((link, index) => (
+                  <li key={index}>
+                    <a
+                      href={link.url}
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="text-muted-foreground hover:text-foreground transition-colors"
+                    >
+                      {link.name}
+                    </a>
+                  </li>
+                ))
+              ) : (
+                <li className="text-muted-foreground text-xs">
+                  暂无友情链接
+                </li>
+              )}
+            </ul>
+          </div>
+
+          {/* 联系我们 */}
+          <div>
+            <h3 className="font-semibold mb-4">联系我们</h3>
+            <ul className="space-y-3 text-sm">
+              <li className="flex items-center space-x-2 text-muted-foreground">
+                <Github className="w-4 h-4" />
+                <a
+                  href={websiteConfig.github.repo}
+                  target="_blank"
+                  rel="noopener noreferrer"
+                  className="hover:text-foreground transition-colors"
+                >
+                  GitHub
+                </a>
+              </li>
+              <li className="flex items-center space-x-2 text-muted-foreground">
+                <MessageSquare className="w-4 h-4" />
+                <Link
+                  href="/chat"
+                  className="hover:text-foreground transition-colors"
+                >
+                  在线客服
+                </Link>
+              </li>
+            </ul>
+          </div>
+        </div>
+
+        {/* 版权信息 */}
+        <div className="mt-8 pt-8 border-t text-center text-sm text-muted-foreground">
+          <p className="mb-2">
+            © {websiteConfig.copyright.year} {websiteConfig.copyright.company}. All rights reserved.
+          </p>
+          <p>
+            Powered by Next.js & Go | 
+            <a
+              href="https://github.com/your-username/ai-cs"
+              target="_blank"
+              rel="noopener noreferrer"
+              className="ml-1 hover:text-foreground transition-colors"
+            >
+              开源协议
+            </a>
+          </p>
+        </div>
+      </div>
+    </footer>
+  );
+}
+

+ 99 - 0
frontend/components/layout/Header.tsx

@@ -0,0 +1,99 @@
+"use client";
+
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Github } from "lucide-react";
+import { websiteConfig } from "@/lib/website-config";
+
+/**
+ * 官网顶部导航栏
+ * 包含 Logo、导航链接和 GitHub 链接
+ */
+export function Header() {
+  return (
+    <header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
+      <div className="container mx-auto px-4">
+        <div className="flex h-16 items-center justify-between">
+          {/* Logo 和品牌名称 */}
+          <Link href="/" className="flex items-center space-x-2">
+            <div className="flex items-center space-x-2">
+              <div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
+                <span className="text-primary-foreground font-bold text-lg">AI</span>
+              </div>
+              <span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
+                AI-CS
+              </span>
+            </div>
+          </Link>
+
+          {/* 右侧:导航链接和操作按钮 */}
+          <div className="flex items-center space-x-6">
+            {/* 导航链接 */}
+            <nav className="hidden md:flex items-center space-x-6">
+              <Link
+                href="#features"
+                className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
+              >
+                功能特性
+              </Link>
+              <Link
+                href="#screenshots"
+                className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
+              >
+                界面展示
+              </Link>
+              <Link
+                href="#faq"
+                className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
+              >
+                常见问题
+              </Link>
+              <Link
+                href="/agent/login"
+                className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
+              >
+                客服登录
+              </Link>
+            </nav>
+
+            {/* GitHub 链接 */}
+            <Button
+              variant="ghost"
+              size="sm"
+              asChild
+              className="hidden sm:flex"
+            >
+              <a
+                href={websiteConfig.github.repo}
+                target="_blank"
+                rel="noopener noreferrer"
+                className="flex items-center space-x-2"
+              >
+                <Github className="w-4 h-4" />
+                <span>GitHub</span>
+              </a>
+            </Button>
+            
+            {/* 移动端 GitHub 图标按钮 */}
+            <Button
+              variant="ghost"
+              size="icon"
+              asChild
+              className="sm:hidden"
+            >
+              <a
+                href={websiteConfig.github.repo}
+                target="_blank"
+                rel="noopener noreferrer"
+                aria-label="GitHub"
+              >
+                <Github className="w-5 h-5" />
+              </a>
+            </Button>
+          </div>
+        </div>
+      </div>
+    </header>
+  );
+}
+

+ 121 - 0
frontend/components/layout/ResponsiveLayout.tsx

@@ -0,0 +1,121 @@
+"use client";
+
+import * as React from "react";
+import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import { Menu } from "lucide-react";
+import { LAYOUT } from "@/lib/constants/breakpoints";
+
+/**
+ * ResponsiveLayout - 响应式布局组件
+ * 
+ * 提供统一的响应式布局,支持桌面端和移动端自适应。
+ * 
+ * @example
+ * ```tsx
+ * <ResponsiveLayout
+ *   sidebar={<ConversationSidebar />}
+ *   main={<MessageList />}
+ *   rightPanel={<VisitorDetailPanel />}
+ * />
+ * ```
+ * 
+ * @param sidebar - 侧边栏内容(桌面端显示,移动端可折叠)
+ * @param main - 主内容区(所有设备都显示)
+ * @param rightPanel - 右侧面板(大屏幕显示,小屏幕隐藏或折叠)
+ * @param header - 顶部栏(可选)
+ * @param className - 额外的 CSS 类名
+ */
+export interface ResponsiveLayoutProps {
+  sidebar?: React.ReactNode;
+  main: React.ReactNode;
+  rightPanel?: React.ReactNode;
+  header?: React.ReactNode;
+  className?: string;
+  sidebarWidth?: string; // 侧边栏宽度(可选,默认使用 LAYOUT.sidebarWidth)
+}
+
+export function ResponsiveLayout({
+  sidebar,
+  main,
+  rightPanel,
+  header,
+  className,
+  sidebarWidth,
+}: ResponsiveLayoutProps) {
+  const actualSidebarWidth = sidebarWidth || LAYOUT.sidebarWidth;
+  
+  return (
+    <div className={`flex h-screen bg-background overflow-hidden ${className || ""}`}>
+      {/* 桌面端侧边栏:中等屏幕及以上显示 */}
+      {sidebar && (
+        <aside className={`hidden md:block border-r bg-background flex-shrink-0`} style={{ width: actualSidebarWidth }}>
+          {sidebar}
+        </aside>
+      )}
+
+      {/* 移动端侧边栏:使用 Sheet 组件实现可折叠侧边栏 */}
+      {sidebar && (
+        <Sheet>
+          <SheetTrigger asChild>
+            <Button
+              variant="ghost"
+              size="icon"
+              className="fixed top-4 left-4 z-50 md:hidden bg-background/80 backdrop-blur-sm"
+            >
+              <Menu className="h-6 w-6" />
+              <span className="sr-only">打开菜单</span>
+            </Button>
+          </SheetTrigger>
+          <SheetContent side="left" className="w-80 p-0" style={{ width: actualSidebarWidth }}>
+            {sidebar}
+          </SheetContent>
+        </Sheet>
+      )}
+
+      {/* 主内容区 */}
+      <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
+        {/* 顶部栏(如果提供) */}
+        {header && (
+          <header className="flex-shrink-0 border-b bg-background">
+            {header}
+          </header>
+        )}
+
+        {/* 主内容区和右侧面板容器 */}
+        <div className="flex flex-1 min-h-0 overflow-hidden">
+          {/* 主内容区 */}
+          <main className="flex-1 flex flex-col min-h-0 overflow-hidden">
+            {main}
+          </main>
+
+          {/* 右侧面板:大屏幕显示,小屏幕隐藏 */}
+          {rightPanel && (
+            <>
+              <aside className={`hidden lg:block border-l bg-background flex-shrink-0`} style={{ width: LAYOUT.rightPanelWidth }}>
+                {rightPanel}
+              </aside>
+              {/* 移动端右侧面板:使用 Sheet 组件实现可折叠面板 */}
+              <Sheet>
+                <SheetTrigger asChild>
+                  <Button
+                    variant="ghost"
+                    size="icon"
+                    className="fixed top-4 right-4 z-50 lg:hidden bg-background/80 backdrop-blur-sm"
+                  >
+                    <Menu className="h-6 w-6" />
+                    <span className="sr-only">打开详情</span>
+                  </Button>
+                </SheetTrigger>
+                <SheetContent side="right" className="w-80 p-0" style={{ width: LAYOUT.rightPanelWidth }}>
+                  {rightPanel}
+                </SheetContent>
+              </Sheet>
+            </>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+

+ 6 - 0
frontend/components/layout/index.ts

@@ -0,0 +1,6 @@
+/**
+ * 响应式布局组件统一导出
+ */
+
+export { ResponsiveLayout, type ResponsiveLayoutProps } from "./ResponsiveLayout";
+

+ 37 - 0
frontend/components/ui/badge.tsx

@@ -0,0 +1,37 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const badgeVariants = cva(
+  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
+  {
+    variants: {
+      variant: {
+        default:
+          "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
+        secondary:
+          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        destructive:
+          "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
+        outline: "text-foreground",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+);
+
+export interface BadgeProps
+  extends React.HTMLAttributes<HTMLDivElement>,
+    VariantProps<typeof badgeVariants> {}
+
+function Badge({ className, variant, ...props }: BadgeProps) {
+  return (
+    <div className={cn(badgeVariants({ variant }), className)} {...props} />
+  );
+}
+
+export { Badge, badgeVariants };
+

+ 57 - 0
frontend/components/ui/button.tsx

@@ -0,0 +1,57 @@
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 shadow-sm hover:shadow-md active:scale-[0.98]",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-md hover:shadow-lg",
+        destructive:
+          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+        outline:
+          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+        ghost: "hover:bg-accent hover:text-accent-foreground",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default: "h-10 px-4 py-2",
+        sm: "h-9 rounded-md px-3",
+        lg: "h-11 rounded-md px-8",
+        icon: "h-10 w-10",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+);
+
+export interface ButtonProps
+  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof buttonVariants> {
+  asChild?: boolean;
+}
+
+const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant, size, asChild = false, ...props }, ref) => {
+    const Comp = asChild ? Slot : "button";
+    return (
+      <Comp
+        className={cn(buttonVariants({ variant, size, className }))}
+        ref={ref}
+        {...props}
+      />
+    );
+  }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
+

+ 80 - 0
frontend/components/ui/card.tsx

@@ -0,0 +1,80 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn(
+      "rounded-lg border bg-card text-card-foreground shadow-sm",
+      className
+    )}
+    {...props}
+  />
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn("flex flex-col space-y-1.5 p-6", className)}
+    {...props}
+  />
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+  HTMLParagraphElement,
+  React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+  <h3
+    ref={ref}
+    className={cn(
+      "text-2xl font-semibold leading-none tracking-tight",
+      className
+    )}
+    {...props}
+  />
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+  HTMLParagraphElement,
+  React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+  <p
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+  HTMLDivElement,
+  React.HTMLAttributes<HTMLDivElement>
+>(({ className, ...props }, ref) => (
+  <div
+    ref={ref}
+    className={cn("flex items-center p-6 pt-0", className)}
+    {...props}
+  />
+));
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+

+ 31 - 0
frontend/components/ui/checkbox.tsx

@@ -0,0 +1,31 @@
+"use client";
+
+import * as React from "react";
+import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
+import { Check } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Checkbox = React.forwardRef<
+  React.ElementRef<typeof CheckboxPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
+>(({ className, ...props }, ref) => (
+  <CheckboxPrimitive.Root
+    ref={ref}
+    className={cn(
+      "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
+      className
+    )}
+    {...props}
+  >
+    <CheckboxPrimitive.Indicator
+      className={cn("flex items-center justify-center text-current")}
+    >
+      <Check className="h-4 w-4" />
+    </CheckboxPrimitive.Indicator>
+  </CheckboxPrimitive.Root>
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };
+

+ 121 - 0
frontend/components/ui/dialog.tsx

@@ -0,0 +1,121 @@
+import * as React from "react";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+const Dialog = DialogPrimitive.Root;
+
+const DialogTrigger = DialogPrimitive.Trigger;
+
+const DialogPortal = DialogPrimitive.Portal;
+
+const DialogClose = DialogPrimitive.Close;
+
+const DialogOverlay = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Overlay
+    ref={ref}
+    className={cn(
+      "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+  />
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+
+const DialogContent = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Content>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
+>(({ className, children, ...props }, ref) => (
+  <DialogPortal>
+    <DialogOverlay />
+    <DialogPrimitive.Content
+      ref={ref}
+      className={cn(
+        "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg backdrop-blur-sm",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
+        <X className="h-4 w-4" />
+        <span className="sr-only">关闭</span>
+      </DialogPrimitive.Close>
+    </DialogPrimitive.Content>
+  </DialogPortal>
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+const DialogHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-1.5 text-center sm:text-left",
+      className
+    )}
+    {...props}
+  />
+);
+DialogHeader.displayName = "DialogHeader";
+
+const DialogFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+      className
+    )}
+    {...props}
+  />
+);
+DialogFooter.displayName = "DialogFooter";
+
+const DialogTitle = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Title
+    ref={ref}
+    className={cn(
+      "text-lg font-semibold leading-none tracking-tight",
+      className
+    )}
+    {...props}
+  />
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+const DialogDescription = React.forwardRef<
+  React.ElementRef<typeof DialogPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <DialogPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
+
+export {
+  Dialog,
+  DialogPortal,
+  DialogOverlay,
+  DialogClose,
+  DialogTrigger,
+  DialogContent,
+  DialogHeader,
+  DialogFooter,
+  DialogTitle,
+  DialogDescription,
+};
+

+ 26 - 0
frontend/components/ui/input.tsx

@@ -0,0 +1,26 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+  extends React.InputHTMLAttributes<HTMLInputElement> {}
+
+const Input = React.forwardRef<HTMLInputElement, InputProps>(
+  ({ className, type, ...props }, ref) => {
+    return (
+      <input
+        type={type}
+        className={cn(
+          "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:border-primary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm focus-visible:shadow-sm",
+          className
+        )}
+        ref={ref}
+        {...props}
+      />
+    );
+  }
+);
+Input.displayName = "Input";
+
+export { Input };
+

+ 27 - 0
frontend/components/ui/label.tsx

@@ -0,0 +1,27 @@
+"use client";
+
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const labelVariants = cva(
+  "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+);
+
+const Label = React.forwardRef<
+  React.ElementRef<typeof LabelPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
+    VariantProps<typeof labelVariants>
+>(({ className, ...props }, ref) => (
+  <LabelPrimitive.Root
+    ref={ref}
+    className={cn(labelVariants(), className)}
+    {...props}
+  />
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
+

+ 30 - 0
frontend/components/ui/separator.tsx

@@ -0,0 +1,30 @@
+import * as React from "react";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+
+import { cn } from "@/lib/utils";
+
+const Separator = React.forwardRef<
+  React.ElementRef<typeof SeparatorPrimitive.Root>,
+  React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
+>(
+  (
+    { className, orientation = "horizontal", decorative = true, ...props },
+    ref
+  ) => (
+    <SeparatorPrimitive.Root
+      ref={ref}
+      decorative={decorative}
+      orientation={orientation}
+      className={cn(
+        "shrink-0 bg-border",
+        orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
+        className
+      )}
+      {...props}
+    />
+  )
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
+

+ 145 - 0
frontend/components/ui/sheet.tsx

@@ -0,0 +1,145 @@
+"use client"
+
+import * as React from "react"
+import * as SheetPrimitive from "@radix-ui/react-dialog"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Sheet = SheetPrimitive.Root
+
+const SheetTrigger = SheetPrimitive.Trigger
+
+const SheetClose = SheetPrimitive.Close
+
+const SheetPortal = SheetPrimitive.Portal
+
+const SheetOverlay = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Overlay>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Overlay
+    className={cn(
+      "fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
+      className
+    )}
+    {...props}
+    ref={ref}
+  />
+))
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+
+const sheetVariants = cva(
+  "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
+  {
+    variants: {
+      side: {
+        top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+        bottom:
+          "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+        left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+        right:
+          "inset-y-0 right-0 h-full w-3/4  border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+      },
+    },
+    defaultVariants: {
+      side: "right",
+    },
+  }
+)
+
+interface SheetContentProps
+  extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
+    VariantProps<typeof sheetVariants> {}
+
+const SheetContent = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Content>,
+  SheetContentProps
+>(({ side = "right", className, children, ...props }, ref) => (
+  <SheetPortal>
+    <SheetOverlay />
+    <SheetPrimitive.Content
+      ref={ref}
+      className={cn(sheetVariants({ side }), className)}
+      {...props}
+    >
+      {/* 隐藏的标题,用于可访问性 */}
+      <SheetPrimitive.Title className="sr-only">
+        侧边栏
+      </SheetPrimitive.Title>
+      {children}
+      <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
+        <X className="h-4 w-4" />
+        <span className="sr-only">Close</span>
+      </SheetPrimitive.Close>
+    </SheetPrimitive.Content>
+  </SheetPortal>
+))
+SheetContent.displayName = SheetPrimitive.Content.displayName
+
+const SheetHeader = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col space-y-2 text-center sm:text-left",
+      className
+    )}
+    {...props}
+  />
+)
+SheetHeader.displayName = "SheetHeader"
+
+const SheetFooter = ({
+  className,
+  ...props
+}: React.HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
+      className
+    )}
+    {...props}
+  />
+)
+SheetFooter.displayName = "SheetFooter"
+
+const SheetTitle = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Title>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Title
+    ref={ref}
+    className={cn("text-lg font-semibold text-foreground", className)}
+    {...props}
+  />
+))
+SheetTitle.displayName = SheetPrimitive.Title.displayName
+
+const SheetDescription = React.forwardRef<
+  React.ElementRef<typeof SheetPrimitive.Description>,
+  React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
+>(({ className, ...props }, ref) => (
+  <SheetPrimitive.Description
+    ref={ref}
+    className={cn("text-sm text-muted-foreground", className)}
+    {...props}
+  />
+))
+SheetDescription.displayName = SheetPrimitive.Description.displayName
+
+export {
+  Sheet,
+  SheetPortal,
+  SheetOverlay,
+  SheetTrigger,
+  SheetClose,
+  SheetContent,
+  SheetHeader,
+  SheetFooter,
+  SheetTitle,
+  SheetDescription,
+}
+

+ 120 - 0
frontend/components/ui/tabs.tsx

@@ -0,0 +1,120 @@
+"use client"
+
+import * as React from "react"
+import { cn } from "@/lib/utils"
+
+interface TabsContextValue {
+  value: string
+  onValueChange: (value: string) => void
+}
+
+const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
+
+interface TabsProps {
+  defaultValue: string
+  value?: string
+  onValueChange?: (value: string) => void
+  className?: string
+  children: React.ReactNode
+}
+
+const Tabs = ({ defaultValue, value: controlledValue, onValueChange: controlledOnValueChange, className, children }: TabsProps) => {
+  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue)
+  const isControlled = controlledValue !== undefined
+  const value = isControlled ? controlledValue : uncontrolledValue
+  const onValueChange = isControlled ? controlledOnValueChange : setUncontrolledValue
+
+  return (
+    <TabsContext.Provider value={{ value, onValueChange: onValueChange || (() => {}) }}>
+      <div className={className}>{children}</div>
+    </TabsContext.Provider>
+  )
+}
+
+interface TabsListProps {
+  className?: string
+  children: React.ReactNode
+}
+
+const TabsList = React.forwardRef<HTMLDivElement, TabsListProps>(
+  ({ className, ...props }, ref) => (
+    <div
+      ref={ref}
+      className={cn(
+        "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+)
+TabsList.displayName = "TabsList"
+
+interface TabsTriggerProps {
+  value: string
+  className?: string
+  children: React.ReactNode
+}
+
+const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
+  ({ value, className, children, ...props }, ref) => {
+    const context = React.useContext(TabsContext)
+    if (!context) {
+      throw new Error("TabsTrigger must be used within Tabs")
+    }
+
+    const isActive = context.value === value
+
+    return (
+      <button
+        ref={ref}
+        type="button"
+        onClick={() => context.onValueChange(value)}
+        className={cn(
+          "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
+          isActive && "bg-background text-foreground shadow-sm",
+          className
+        )}
+        {...props}
+      >
+        {children}
+      </button>
+    )
+  }
+)
+TabsTrigger.displayName = "TabsTrigger"
+
+interface TabsContentProps {
+  value: string
+  className?: string
+  children: React.ReactNode
+}
+
+const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
+  ({ value, className, children, ...props }, ref) => {
+    const context = React.useContext(TabsContext)
+    if (!context) {
+      throw new Error("TabsContent must be used within Tabs")
+    }
+
+    if (context.value !== value) {
+      return null
+    }
+
+    return (
+      <div
+        ref={ref}
+        className={cn(
+          "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
+          className
+        )}
+        {...props}
+      >
+        {children}
+      </div>
+    )
+  }
+)
+TabsContent.displayName = "TabsContent"
+
+export { Tabs, TabsList, TabsTrigger, TabsContent }

+ 25 - 0
frontend/components/ui/textarea.tsx

@@ -0,0 +1,25 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface TextareaProps
+  extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
+
+const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
+  ({ className, ...props }, ref) => {
+    return (
+      <textarea
+        className={cn(
+          "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
+          className
+        )}
+        ref={ref}
+        {...props}
+      />
+    );
+  }
+);
+Textarea.displayName = "Textarea";
+
+export { Textarea };
+

+ 156 - 0
frontend/components/visitor/ChatModeSelector.tsx

@@ -0,0 +1,156 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { fetchPublicAIModels, type AIConfig } from "@/features/agent/services/aiConfigApi";
+
+interface ChatModeSelectorProps {
+  onSelect: (mode: "human" | "ai", aiConfigId?: number) => void;
+  loading?: boolean;
+}
+
+export function ChatModeSelector({ onSelect, loading }: ChatModeSelectorProps) {
+  const [aiModels, setAiModels] = useState<AIConfig[]>([]);
+  const [loadingModels, setLoadingModels] = useState(true);
+  const [selectedModel, setSelectedModel] = useState<number | null>(null);
+
+  // 加载开放的 AI 模型列表
+  useEffect(() => {
+    async function loadModels() {
+      try {
+        const models = await fetchPublicAIModels("text");
+        setAiModels(models);
+        // 如果有模型,默认选择第一个
+        if (models.length > 0) {
+          setSelectedModel(models[0].id);
+        }
+      } catch (error) {
+        console.error("加载模型列表失败:", error);
+      } finally {
+        setLoadingModels(false);
+      }
+    }
+    loadModels();
+  }, []);
+
+  const handleSelectHuman = () => {
+    onSelect("human");
+  };
+
+  const handleSelectAI = () => {
+    if (selectedModel) {
+      onSelect("ai", selectedModel);
+    } else if (aiModels.length > 0) {
+      // 如果没有选择,使用第一个模型
+      onSelect("ai", aiModels[0].id);
+    }
+  };
+
+  return (
+    <div className="flex flex-col items-center justify-center min-h-screen bg-background p-4">
+      <div className="w-full max-w-2xl">
+        <h1 className="text-3xl font-bold text-center mb-2 text-gray-800">
+          欢迎使用客服系统
+        </h1>
+        <p className="text-center text-gray-600 mb-8">
+          请选择您需要的服务方式
+        </p>
+
+        <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
+          {/* 人工客服选项 */}
+          <Card className="p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-primary">
+            <div className="flex flex-col items-center text-center">
+              <div className="w-16 h-16 rounded-full bg-blue-100 flex items-center justify-center mb-4">
+                <svg
+                  className="w-8 h-8 text-blue-600"
+                  fill="none"
+                  stroke="currentColor"
+                  viewBox="0 0 24 24"
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
+                  />
+                </svg>
+              </div>
+              <h3 className="text-xl font-semibold mb-2">人工客服</h3>
+              <p className="text-sm text-gray-600 mb-4">
+                由专业客服人员为您提供一对一服务
+              </p>
+              <Button
+                onClick={handleSelectHuman}
+                disabled={loading}
+                className="w-full"
+              >
+                {loading ? "连接中..." : "选择人工客服"}
+              </Button>
+            </div>
+          </Card>
+
+          {/* AI 客服选项 */}
+          <Card className="p-6 cursor-pointer hover:shadow-lg transition-shadow border-2 hover:border-primary">
+            <div className="flex flex-col items-center text-center">
+              <div className="w-16 h-16 rounded-full bg-purple-100 flex items-center justify-center mb-4">
+                <svg
+                  className="w-8 h-8 text-purple-600"
+                  fill="none"
+                  stroke="currentColor"
+                  viewBox="0 0 24 24"
+                >
+                  <path
+                    strokeLinecap="round"
+                    strokeLinejoin="round"
+                    strokeWidth={2}
+                    d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
+                  />
+                </svg>
+              </div>
+              <h3 className="text-xl font-semibold mb-2">AI 客服</h3>
+              <p className="text-sm text-gray-600 mb-4">
+                智能 AI 助手,24 小时在线为您服务
+              </p>
+              {loadingModels ? (
+                <div className="w-full py-2 text-sm text-gray-500">
+                  加载模型中...
+                </div>
+              ) : aiModels.length === 0 ? (
+                <div className="w-full py-2 text-sm text-red-500">
+                  暂无可用的 AI 模型
+                </div>
+              ) : (
+                <>
+                  {/* 模型选择下拉框 */}
+                  <select
+                    value={selectedModel || ""}
+                    onChange={(e) => setSelectedModel(Number(e.target.value))}
+                    className="w-full mb-4 px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
+                    disabled={loading}
+                  >
+                    {aiModels.map((model) => (
+                      <option key={model.id} value={model.id}>
+                        {model.provider} - {model.model}
+                        {model.description ? ` (${model.description})` : ""}
+                      </option>
+                    ))}
+                  </select>
+                  <Button
+                    onClick={handleSelectAI}
+                    disabled={loading || !selectedModel}
+                    variant="default"
+                    className="w-full"
+                  >
+                    {loading ? "连接中..." : "选择 AI 客服"}
+                  </Button>
+                </>
+              )}
+            </div>
+          </Card>
+        </div>
+      </div>
+    </div>
+  );
+}
+

+ 611 - 0
frontend/components/visitor/ChatWidget.tsx

@@ -0,0 +1,611 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { MessageList } from "@/components/dashboard/MessageList";
+import { MessageInput } from "@/components/dashboard/MessageInput";
+import { OnlineAgentsList, type OnlineAgent } from "./OnlineAgentsList";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import {
+  ChatWebSocketPayload,
+  MessageItem,
+  MessagesReadPayload,
+} from "@/features/agent/types";
+import {
+  fetchMessages,
+  markMessagesRead,
+  sendMessage,
+  UploadFileResult,
+} from "@/features/agent/services/messageApi";
+import { initVisitorConversation } from "@/features/visitor/services/conversationApi";
+import { fetchOnlineAgents } from "@/features/visitor/services/visitorApi";
+import { fetchPublicAIModels } from "@/features/agent/services/aiConfigApi";
+import { useWebSocket } from "@/features/agent/hooks/useWebSocket";
+import type { WSMessage } from "@/lib/websocket";
+
+interface ChatWidgetProps {
+  visitorId: number;
+  isOpen: boolean;
+  onToggle: () => void;
+}
+
+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 function ChatWidget({ visitorId, isOpen, onToggle }: ChatWidgetProps) {
+  // ===== 状态管理 =====
+  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 [chatMode, setChatMode] = useState<"human" | "ai">("human");
+  const [initializing, setInitializing] = useState(false);
+  const [selectedAIConfigId, setSelectedAIConfigId] = useState<
+    number | undefined
+  >(undefined);
+  const [aiModels, setAiModels] = useState<
+    Array<{ id: number; provider: string; model: string }>
+  >([]);
+  const [onlineAgents, setOnlineAgents] = useState<OnlineAgent[]>([]);
+  const [loadingAgents, setLoadingAgents] = useState(false);
+
+  const noopHighlight = useCallback(() => {}, []);
+
+  // 加载在线客服列表
+  const loadOnlineAgents = useCallback(async () => {
+    setLoadingAgents(true);
+    try {
+      const agents = await fetchOnlineAgents();
+      setOnlineAgents(agents);
+    } catch (error) {
+      console.error("加载在线客服列表失败:", error);
+    } finally {
+      setLoadingAgents(false);
+    }
+  }, []);
+
+  // 当小窗打开时,加载在线客服列表
+  useEffect(() => {
+    if (isOpen) {
+      loadOnlineAgents();
+      // 定期刷新在线客服列表(每30秒)
+      const interval = setInterval(loadOnlineAgents, 30000);
+      return () => clearInterval(interval);
+    }
+  }, [isOpen, loadOnlineAgents]);
+
+  // 加载开放的 AI 模型列表
+  useEffect(() => {
+    async function loadModels() {
+      try {
+        const models = await fetchPublicAIModels("text");
+        setAiModels(models);
+        if (models.length > 0) {
+          setSelectedAIConfigId(models[0].id);
+        }
+      } catch (error) {
+        console.error("加载 AI 模型列表失败:", error);
+      }
+    }
+    loadModels();
+  }, []);
+
+  // 创建或恢复访客会话
+  const initializeConversation = useCallback(
+    async (id: number, mode: "human" | "ai", aiConfigId?: number) => {
+      setInitializing(true);
+      try {
+        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,
+          chatMode: mode,
+          aiConfigId,
+        });
+        if (result.conversation_id) {
+          setConversationId(result.conversation_id);
+          setConversationStatus(result.status);
+          setChatMode(mode);
+        }
+      } catch (error) {
+        console.error("初始化对话失败:", error);
+        alert("初始化对话失败,请重试");
+      } finally {
+        setInitializing(false);
+      }
+    },
+    []
+  );
+
+  // 初始化默认对话(人工模式)
+  useEffect(() => {
+    if (visitorId !== null && !conversationId && !initializing && isOpen) {
+      initializeConversation(visitorId, "human");
+    }
+  }, [visitorId, conversationId, initializing, isOpen, initializeConversation]);
+
+  // 处理模式切换
+  const handleModeSwitch = useCallback(
+    (mode: "human" | "ai") => {
+      if (visitorId === null || initializing) {
+        return;
+      }
+      if (mode === "ai" && !selectedAIConfigId) {
+        alert("请先选择一个 AI 模型");
+        return;
+      }
+      initializeConversation(
+        visitorId,
+        mode,
+        mode === "ai" ? selectedAIConfigId : undefined
+      );
+    },
+    [visitorId, initializing, selectedAIConfigId, initializeConversation]
+  );
+
+  // 标记客服消息已读
+  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);
+      const normalizedMessages = data.map((msg) => ({
+        ...msg,
+        is_read: msg.is_read ?? false,
+        read_at: msg.read_at ?? null,
+      }));
+      // 刷新消息列表时,移除所有临时消息(ID 大于 1000000000000 的消息是临时消息)
+      // 临时消息使用 Date.now() 作为 ID,真实消息的 ID 通常较小
+      setMessages(normalizedMessages);
+    } catch (error) {
+      console.error("拉取消息失败:", error);
+    } finally {
+      setLoadingMessages(false);
+    }
+  }, [conversationId]);
+
+  useEffect(() => {
+    if (isOpen && conversationId) {
+      loadMessages();
+    }
+  }, [isOpen, conversationId, loadMessages]);
+
+
+  // 收到新消息时更新状态
+  const handleNewMessage = useCallback(
+    (message: MessageItem) => {
+      if (!conversationId || message.conversation_id !== conversationId) {
+        return;
+      }
+      setMessages((prev) => {
+        // 检查是否已存在相同ID的消息(真实消息)
+        const exists = prev.some((item) => item.id === message.id);
+        if (exists) {
+          // 更新已存在的消息,确保创建新数组引用
+          const updated = prev.map((msg) =>
+            msg.id === message.id
+              ? {
+                  ...msg,
+                  ...message,
+                  is_read: message.is_read ?? msg.is_read ?? false,
+                  read_at: message.read_at ?? msg.read_at ?? null,
+                }
+              : msg
+          );
+          // 检查是否有实际变化,如果没有变化也返回新数组引用
+          const hasChange = updated.some((msg, idx) => {
+            const oldMsg = prev[idx];
+            return !oldMsg || msg.id !== oldMsg.id || JSON.stringify(msg) !== JSON.stringify(oldMsg);
+          });
+          if (!hasChange) {
+            return [...updated]; // 强制创建新数组引用
+          }
+          return updated;
+        }
+        
+        // 如果是访客自己发送的消息(sender_is_agent = false),移除对应的临时消息
+        // 临时消息的 ID 是 Date.now(),通常大于 1000000000000
+        // 真实消息的 ID 通常较小
+        const isVisitorMessage = !message.sender_is_agent;
+        if (isVisitorMessage) {
+          // 移除所有临时消息(ID 大于 1000000000000)和已存在的相同真实消息(如果有)
+          // 这样可以避免临时消息和真实消息重复显示
+          const filteredPrev = prev.filter((msg) => 
+            msg.id < 1000000000000 && msg.id !== message.id
+          );
+          
+          // 检查过滤后的数组和原数组是否不同,或者消息ID是否变化
+          const hasTempMessage = prev.some((msg) => msg.id >= 1000000000000);
+          const hasSameMessage = prev.some((msg) => msg.id === message.id);
+          
+          // 如果列表没有变化(没有临时消息需要移除,且消息已存在),仍然创建新数组引用
+          if (!hasTempMessage && hasSameMessage) {
+            // 即使没有变化,也创建新数组引用,确保 React 检测到变化
+            return [...prev];
+          }
+          
+          // 确保新消息不在列表中,然后添加
+          // 检查过滤后的列表是否已包含该消息
+          const alreadyInFiltered = filteredPrev.some((msg) => msg.id === message.id);
+          if (alreadyInFiltered) {
+            // 即使已存在,也创建新数组引用以确保渲染
+            return [...filteredPrev];
+          }
+          
+          const newMessages = [
+            ...filteredPrev,
+            {
+              ...message,
+              is_read: message.is_read ?? false,
+            },
+          ];
+          
+          // 强制创建新数组引用,确保 React 检测到变化
+          return newMessages;
+        }
+        
+        // 其他消息(客服发送的)直接添加
+        // 检查是否已存在,避免重复添加
+        const alreadyExists = prev.some((msg) => msg.id === message.id);
+        if (alreadyExists) {
+          // 即使消息已存在,也创建新数组引用,确保 React 检测到变化
+          return [...prev];
+        }
+        const newMessages = [
+          ...prev,
+          {
+            ...message,
+            is_read: message.is_read ?? false,
+          },
+        ];
+        // 强制创建新数组引用,确保 React 检测到变化
+        return newMessages;
+      });
+    },
+    [conversationId]
+  );
+
+  // 处理 WebSocket 的已读事件
+  const handleMessagesReadEvent = useCallback(
+    (payload: MessagesReadPayload) => {
+      if (!conversationId) {
+        return;
+      }
+      const payloadConversationId = payload?.conversation_id;
+      if (payloadConversationId && payloadConversationId !== conversationId) {
+        return;
+      }
+      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) =>
+          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") {
+        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) && isOpen,
+    isVisitor: true,
+    onMessage: handleWebSocketMessage,
+    onError: (error) => {
+      console.error("WebSocket 连接错误(访客端):", error);
+    },
+  });
+
+  const handleSendMessage = useCallback(
+    async (fileInfo?: UploadFileResult) => {
+      if (!conversationId || sending) {
+        return;
+      }
+      if (!input.trim() && !fileInfo) {
+        return;
+      }
+      const messageContent = input.trim();
+      
+      // 乐观更新:立即将消息添加到本地状态(临时消息,稍后会被服务器返回的真实消息替换)
+      const tempMessage: MessageItem = {
+        id: Date.now(), // 临时ID,发送成功后会被真实ID替换
+        conversation_id: conversationId,
+        content: messageContent,
+        sender_id: visitorId || 0,
+        sender_is_agent: false,
+        message_type: fileInfo?.file_type === "image" ? "image" : fileInfo?.file_type === "document" ? "document" : "text",
+        is_read: false,
+        read_at: null,
+        created_at: new Date().toISOString(),
+        file_url: fileInfo?.file_url || null,
+        file_name: fileInfo?.file_name || null,
+        file_size: fileInfo?.file_size || null,
+        mime_type: fileInfo?.mime_type || null,
+        chat_mode: chatMode,
+      };
+      
+      // 立即添加到消息列表
+      setMessages((prev) => [...prev, tempMessage]);
+      setInput("");
+      setSending(true);
+      
+      try {
+        await sendMessage({
+          conversationId,
+          content: messageContent,
+          senderIsAgent: false,
+          fileUrl: fileInfo?.file_url,
+          fileType: fileInfo?.file_type as "image" | "document" | undefined,
+          fileName: fileInfo?.file_name,
+          fileSize: fileInfo?.file_size,
+          mimeType: fileInfo?.mime_type,
+        });
+        
+        // 不在这里调用 loadMessages,完全依赖 WebSocket 来接收新消息
+        // WebSocket 会收到服务器广播的消息,包括自己发送的消息
+        // 这样可以避免 loadMessages 覆盖 WebSocket 的更新
+      } catch (error) {
+        // 发送失败,移除临时消息
+        setMessages((prev) => prev.filter((msg) => msg.id !== tempMessage.id));
+        console.error("❌ 发送消息失败:", error);
+        alert((error as Error).message || "发送消息失败,请稍后重试");
+        // 恢复输入内容
+        setInput(messageContent);
+      } finally {
+        setSending(false);
+      }
+    },
+    [conversationId, input, sending, visitorId, chatMode]
+  );
+
+  // 如果不打开,不渲染内容
+  if (!isOpen) {
+    return null;
+  }
+
+  return (
+    <Card className="fixed bottom-20 right-4 sm:bottom-24 sm:right-6 w-[calc(100vw-2rem)] max-w-[400px] h-[500px] sm:w-[400px] sm:max-w-none sm:h-[600px] md:w-[480px] md:h-[700px] flex flex-col shadow-2xl z-40 border border-border/50 overflow-hidden rounded-2xl bg-background backdrop-blur-sm ring-1 ring-black/5">
+      {/* 头部:标题和关闭按钮 - 使用渐变背景 */}
+      <div className="bg-gradient-to-r from-primary to-primary/80 border-b border-primary/20 p-4 flex items-center justify-between rounded-t-2xl">
+        <div className="flex items-center gap-2">
+          <div className="w-8 h-8 rounded-lg bg-white/20 backdrop-blur-sm flex items-center justify-center">
+            <svg
+              className="w-5 h-5 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>
+          </div>
+          <h2 className="text-lg font-bold text-white">客服聊天</h2>
+        </div>
+        <Button
+          variant="ghost"
+          size="sm"
+          onClick={onToggle}
+          className="text-white hover:bg-white/20 h-8 w-8 p-0 rounded-lg transition-colors"
+          aria-label="关闭聊天"
+        >
+          <svg
+            className="w-5 h-5"
+            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>
+
+      {/* 模式切换和在线客服列表 */}
+      <div className="p-4 border-b bg-gradient-to-b from-muted/50 to-background">
+        {/* 模式切换按钮 */}
+        <div className="flex items-center gap-2 mb-3 justify-center">
+          <Button
+            variant={chatMode === "human" ? "default" : "outline"}
+            size="sm"
+            onClick={() => handleModeSwitch("human")}
+            disabled={initializing}
+            className={
+              chatMode === "human"
+                ? "bg-primary text-primary-foreground shadow-md hover:shadow-lg transition-shadow"
+                : "hover:bg-muted border-border"
+            }
+          >
+            人工客服
+          </Button>
+          <Button
+            variant={chatMode === "ai" ? "default" : "outline"}
+            size="sm"
+            onClick={() => handleModeSwitch("ai")}
+            disabled={initializing || aiModels.length === 0 || !selectedAIConfigId}
+            className={
+              chatMode === "ai"
+                ? "bg-primary text-primary-foreground shadow-md hover:shadow-lg transition-shadow"
+                : "hover:bg-muted border-border"
+            }
+          >
+            AI 客服
+          </Button>
+        </div>
+        {/* AI 模型选择下拉框(仅 AI 模式显示) */}
+        {aiModels.length > 0 && chatMode === "ai" && (
+          <div className="flex justify-center mb-3">
+            <select
+              value={selectedAIConfigId || ""}
+              onChange={(e) => {
+                const configId = Number(e.target.value);
+                setSelectedAIConfigId(configId);
+                if (visitorId) {
+                  initializeConversation(visitorId, "ai", configId);
+                }
+              }}
+              disabled={initializing}
+              className="px-3 py-1.5 text-xs rounded-md border border-border bg-background hover:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-colors"
+            >
+              {aiModels.map((model) => (
+                <option key={model.id} value={model.id}>
+                  {model.provider} - {model.model}
+                </option>
+              ))}
+            </select>
+          </div>
+        )}
+        {/* 在线客服列表(仅人工模式显示) */}
+        {chatMode === "human" && (
+          <OnlineAgentsList
+            agents={onlineAgents}
+            onAgentClick={(agent) => {
+              // 点击客服可以切换对话(如果需要的话)
+            }}
+          />
+        )}
+      </div>
+
+      {/* 消息列表 */}
+      <div className="flex-1 overflow-hidden min-h-0 bg-gradient-to-b from-background to-muted/20">
+        <MessageList
+          key={`messages-${conversationId}`} // 简化 key,只使用 conversationId,避免不必要的重新挂载
+          messages={messages}
+          loading={loadingMessages}
+          highlightKeyword=""
+          onHighlightClear={noopHighlight}
+          currentUserIsAgent={false}
+          disableAutoScroll={false}
+          conversationId={conversationId}
+          onMarkMessagesRead={handleMarkAgentMessagesRead}
+        />
+      </div>
+
+      {/* 消息输入框 */}
+      <div className="border-t border-border/50 bg-background rounded-b-2xl">
+        <MessageInput
+          value={input}
+          onChange={setInput}
+          onSubmit={handleSendMessage}
+          sending={sending}
+          conversationId={conversationId ?? undefined}
+        />
+      </div>
+    </Card>
+  );
+}
+

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff