This commit is contained in:
537yaha
2025-12-02 20:03:39 +08:00
parent c72086dfb6
commit 109e927152
130 changed files with 11596 additions and 1276 deletions
+53
View File
@@ -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/
+161 -176
View File
@@ -1,209 +1,194 @@
# AI-CS 智能客服系统
## 项目简介
> 一个融合 AI 技术与人工客服的现代化智能客服解决方案
这是一个基于Go后端和Next.js前端的智能客服系统,用于处理访客与客服之间的对话交流。
## ✨ 核心特性
## 项目结构
- 🤖 **AI 客服支持**:支持多厂商 AI 模型,可配置 API 和模型选择
- 👥 **人工客服**:实时在线状态显示,支持多客服协作
- 💬 **实时通信**:基于 WebSocket 的双向实时消息推送
- 📁 **文件传输**:支持图片、文档上传和预览
- 📚 **FAQ 管理**:知识库管理,关键词搜索
- 👤 **用户管理**:完整的用户权限管理系统
- 🎨 **现代化 UI**:基于 Shadcn UI 的响应式设计
- 🔌 **访客小窗插件**:可嵌入任何网站的客服小窗组件
- 🌐 **产品官网**:内置产品展示页面
## 🏗️ 技术栈
### 后端
- **语言**: Go 1.21+
- **框架**: Gin (Web 框架)
- **ORM**: GORM
- **数据库**: MySQL 8.0+
- **实时通信**: WebSocket (gorilla/websocket)
- **密码加密**: bcrypt
- **文件存储**: 本地存储(可扩展为云存储)
### 前端
- **框架**: Next.js 14+ (App Router)
- **语言**: TypeScript
- **UI 组件**: Shadcn UI
- **样式**: Tailwind CSS
- **状态管理**: React Hooks
- **实时通信**: WebSocket Client
## 📁 项目结构
```
AI-CS/
├── backend/ # Go后端服务
│ ├── controller/ # 控制器层
│ ├── models/ # 数据模型
│ ├── service/ # 业务逻辑
│ ├── repository/ # 数据访问层
│ ├── middleware/ # 中间件
│ ├── router/ # 路由配置
│ ├── infra/ # 基础设施(数据库等)
── utils/ # 工具函数
└── frontend/ # Next.js前端应用
── app/ # 应用页面
├── public/ # 静态资源
── ...
├── 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 # 本文件
```
## 核心功能
## 🚀 快速开始
### 1. 用户管理
- **用户注册** (`Register`): 创建新用户账户
- **用户登录** (`Login`): 验证用户身份
### 环境要求
### 2. 对话管理
- **初始化对话** (`InitConversation`): 为访客创建或获取现有对话
- **发送消息**: 处理消息发送
- **拉取消息**: 获取对话历史
- Go 1.21 或更高版本
- Node.js 18+ 和 npm/yarn
- MySQL 8.0 或更高版本
## 数据模型
### 1. 克隆项目
### 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"`
}
```bash
git clone <repository-url>
cd AI-CS
```
### 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"`
}
```
### 2. 配置后端
### Message (消息)
```go
type Message struct {
ID uint `json:"id" gorm:"primarykey"`
ConversationID uint `json:"conversation_id"`
SenderID uint `json:"sender_id"`
SenderIsAgent bool `json:"sender_is_agent"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
```
## API接口说明
### 用户相关接口
#### 用户注册
- **路径**: `POST /register`
- **参数**:
```json
{
"username": "用户名",
"password": "密码",
"role": "角色"
}
```
- **返回**: 注册成功或失败信息
#### 用户登录
- **路径**: `POST /login`
- **参数**:
```json
{
"username": "用户名",
"password": "密码"
}
```
- **返回**: 登录成功或失败信息
### 对话相关接口
#### 初始化对话
- **路径**: `POST /conversation/init`
- **参数**:
```json
{
"visitor_id": 访客ID
}
```
- **返回**:
```json
{
"conversation_id": 对话ID,
"status": "对话状态"
}
```
#### 发送消息
- **路径**: `POST /messages`
- **参数**:
```json
{
"conversation_id": 对话ID,
"content": "消息内容",
"sender_is_agent": 是否客服,
"sender_id": 发送者ID(客服必填,访客可省略或传0)
}
```
- **返回**: { "message": "创建消息成功" }
#### 拉取消息
- **路径**: `GET /messages?conversation_id=对话ID`
- **返回**: 消息数组,按创建时间升序
## 对话初始化逻辑详解
当你调用对话初始化接口时,系统会执行以下步骤:
1. **检查现有对话**: 系统会查找该访客是否已有未关闭的对话
2. **复用或创建**:
- 如果找到现有对话,直接返回该对话信息
- 如果没有找到,创建一个新的对话并返回
这就像你去银行办事:
- 如果之前有没办完的业务,继续办理
- 如果没有,开一个新的业务单
## 技术栈
### 后端
- **语言**: Go
- **框架**: Gin (Web框架)
- **数据库**: GORM (ORM)
- **密码加密**: bcrypt
### 前端
- **框架**: Next.js
- **语言**: TypeScript
- **样式**: CSS
## 开发环境设置
### 后端启动
```bash
cd backend
cp .env.example .env # 按需修改数据库配置
go mod tidy
go run main.go
```
### 前端启动
```bash
cd frontend
cp .env.local.example .env.local # 可选:如需自定义 API 地址
npm install
npm run dev
```
## 注意事项
1. 确保数据库连接配置正确(后端从环境变量读取)
2. 用户密码会自动加密存储
3. 对话状态包括: "open"(开放), "closed"(关闭)
4. 消息发送者通过 `SenderIsAgent` 字段区分是访客还是客服
### 后端环境变量
在 `backend/.env` 中配置以下变量(主进程会自动加载):
```
# 创建 .env 文件
cat > .env << EOF
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=CS
DB_NAME=ai_cs
EOF
# 安装依赖
go mod tidy
# 启动服务(默认端口 8080
go run main.go
```
### 3. 配置前端
```bash
cd frontend
# 安装依赖
npm install
# 启动开发服务器(默认端口 3000)
npm run dev
```
### 4. 访问应用
- **官网首页**: http://localhost:3000
- **访客聊天**: http://localhost:3000/chat
- **客服登录**: http://localhost:3000/agent/login
### 5. 默认账号
系统会自动创建默认管理员账号:
- **用户名**: `admin`
- **密码**: `admin123`
> ⚠️ 生产环境请务必修改默认密码!
## 📖 主要功能
### 访客端
- 人工/AI 客服模式切换
- 实时消息收发
- 文件/图片上传
- 在线客服列表查看
- 访客小窗插件(可嵌入第三方网站)
### 客服端
- 对话列表管理(全部/我的/他人的对话)
- 实时消息推送
- 访客信息查看和编辑
- 在线状态显示
- 消息已读状态同步
- AI 配置管理(多厂商支持)
- FAQ 知识库管理
- 用户权限管理
- 个人资料管理
## ⚙️ 配置说明
### 后端环境变量
`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`。生产环境修改为实际后端地址。
## 更新日志
详见 `doc/CHANGELOG.md` 文件
## 🤝 贡献
欢迎提交 Issue 和 Pull Request
## 📄 许可证
[待添加]
## 🙏 致谢
感谢所有为这个项目做出贡献的开发者!
---
**最后更新**: 2025-01-XX
+266 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
{
"dependencies": {
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8"
}
}
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ConfigAI 配置)
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
View File
@@ -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
View File
@@ -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.contentOpenAI 格式)
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 APIHTTP/HTTPS),统一使用 UniversalAIProvider 处理
// 通过 AdapterConfig 适配不同服务商的细微差异(认证头、响应路径等)
func (f *AIProviderFactory) CreateProvider(config AIConfig) (AIProvider, error) {
// 所有服务商都使用 REST API,统一处理
return NewUniversalAIProvider(config), nil
}
+154
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 531 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+118
View File
@@ -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)
}
// 创建 GCMGalois/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
View File
@@ -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
View File
@@ -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
View File
@@ -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列表(去重)
// 返回一个 mapkey 是 agentIDvalue 是 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
View File
@@ -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 地址)
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
</>
);
}
+21 -275
View File
@@ -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
View File
@@ -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
View File
@@ -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({
+468 -89
View File
@@ -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();
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";
// 客服登录
async function handleLogin(e: FormEvent<HTMLFormElement>) {
e.preventDefault(); // 阻止默认行为
/**
* AI-CS 智能客服系统 - 产品官网首页
*
* 包含:
* - Hero 区域(主标题、副标题、CTA按钮)
* - 核心功能介绍
* - 产品特性
* - 案例研究/客户评价
* - 底部 CTA
*/
export default function HomePage() {
const [visitorId, setVisitorId] = useState<number | null>(null);
const [isChatOpen, setIsChatOpen] = useState(false);
if (!username || !password) {
setError("用户名和密码不能为空");
return;
// 初始化访客 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);
}, []);
setLoading(true);
setError("");
const handleToggleChat = () => {
setIsChatOpen((prev) => !prev);
};
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>
<div className="min-h-screen bg-gradient-to-b from-background to-muted/20">
{/* 顶部导航栏 */}
<Header />
<form onSubmit={handleLogin}>
<input
type="text"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-3 mb-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400"
disabled={loading}
/>
<input
type="password"
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-3 mb-4 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-400"
disabled={loading}
/>
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-500 text-white py-3 rounded-md hover:bg-blue-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
>
{loading ? "登录中..." : "登录"}
</button>
</form>
<div className="mt-4 text-center text-xs text-gray-400">
<p>admin / admin123</p>
{/* 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>
</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>
<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>
</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 OpenAIDeepSeek 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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
@@ -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>
<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"
>
<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"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">All</span>
<svg
className="w-4 h-4 text-gray-400"
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>
);
}
@@ -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}
@@ -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>
);
}
@@ -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"
@@ -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}
@@ -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>
);
}
+145 -79
View File
@@ -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}
/>
// 构建侧边栏内容(包含导航栏和对话列表)
// 在 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>
);
<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>
)}
// 构建主内容区
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>
<VisitorDetailPanel
conversation={selectedConversation}
detail={conversationDetail}
onRefresh={handleRefreshVisitor}
onUpdateContact={updateContactInfo}
/>
)
) : (
<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>
</>
);
}
+254 -28
View File
@@ -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 handleSubmit = async (event: FormEvent) => {
// 处理文件选择
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();
if (sending) {
return;
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);
};
}
await onSubmit();
// 注意:聚焦逻辑由 useEffect 处理,当 sending 从 true 变为 false 时会自动聚焦
}, [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 || uploading) {
return;
}
// 验证:必须有内容或文件
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
View File
@@ -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;
}
// 滚动逻辑:
// 1. 如果最后一条消息是自己发送的,无论在哪里都自动滚动到底部(即使 disableAutoScroll 为 true
// 2. 如果最后一条消息是对方发送的:
// - 如果用户在底部附近(isNearBottom),无论 disableAutoScroll 是什么值,都自动滚动到底部(保持"粘到底部"的行为)
// - 如果用户不在底部附近,且 disableAutoScroll 为 true,不自动滚动(用于查看历史消息时不被新消息打断)
// - 如果用户不在底部附近,且 disableAutoScroll 为 false,不自动滚动(与上面的行为一致)
// 3. 如果没有新消息(例如只是消息状态更新),不改变滚动位置
// 这样确保访客端和客服端的行为一致:当用户在底部附近时,收到新消息会自动滚动到底部
const shouldAutoScroll =
hasNewMessage &&
(isLastMessageFromCurrentUser || isNearBottom);
// 在 DOM 更新后检查当前位置
const { scrollTop, scrollHeight, clientHeight } = container;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceToBottom < 100;
// 更新 shouldStickToBottomRef,确保使用最新的位置信息
shouldStickToBottomRef.current = isNearBottom;
if (keyword) {
const keywordLower = keyword.toLowerCase();
const matchingMessage = messages.find((message) =>
message.content.toLowerCase().includes(keywordLower)
);
// 检查是否是初始加载(首次加载消息或切换对话后首次加载)
const isInitialLoad = !hasInitialScrolledRef.current && messages.length > 0;
if (matchingMessage) {
const scroll = () => {
const target = messageRefs.current[matchingMessage.id];
if (target) {
target.scrollIntoView({
behavior: "smooth",
block: "center",
inline: "nearest",
});
// 滚动逻辑:
// 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)
);
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();
setTimeout(scrollBottom, isInitialLoad ? 0 : 100); // 初始加载时立即滚动
}
} else {
if (!shouldAutoScroll) {
return;
}
const scrollBottom = () => {
const container = containerRef.current;
if (!container) {
return;
// 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
// 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
if (conversationId && onMarkMessagesRead && messages.length > 0) {
// 延迟标记为已读,确保滚动动画完成
if (markReadTimerRef.current) {
clearTimeout(markReadTimerRef.current);
}
container.scrollTo({
top: container.scrollHeight,
behavior: "smooth",
});
};
setTimeout(scrollBottom, 100);
}
// 当消息列表更新且自动滚动到底部时,检查是否需要标记为已读
// 或者如果用户已经在底部附近,也应该标记为已读(即使没有自动滚动)
if (conversationId && onMarkMessagesRead && messages.length > 0) {
// 延迟标记为已读,确保滚动动画完成
if (markReadTimerRef.current) {
clearTimeout(markReadTimerRef.current);
}
markReadTimerRef.current = setTimeout(() => {
// 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
const shouldMarkRead = shouldAutoScroll || isNearBottom;
if (!shouldMarkRead) {
return;
}
const unreadMessages = messages.filter((msg) => {
const isFromOther = currentUserIsAgent
? !msg.sender_is_agent
: msg.sender_is_agent;
return isFromOther && !msg.is_read;
});
if (unreadMessages.length > 0) {
// 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
const now = Date.now();
if (now - lastMarkedReadRef.current < 2000) {
markReadTimerRef.current = setTimeout(() => {
// 如果自动滚动到底部,或者用户已经在底部附近,都标记为已读
const shouldMarkRead = shouldAutoScroll || isNearBottom;
if (!shouldMarkRead) {
return;
}
onMarkMessagesRead(conversationId, currentUserIsAgent);
lastMarkedReadRef.current = now;
}
}, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
const unreadMessages = messages.filter((msg) => {
const isFromOther = currentUserIsAgent
? !msg.sender_is_agent
: msg.sender_is_agent;
return isFromOther && !msg.is_read;
});
if (unreadMessages.length > 0) {
// 避免频繁调用:如果距离上次标记不到 2 秒,则跳过
const now = Date.now();
if (now - lastMarkedReadRef.current < 2000) {
return;
}
onMarkMessagesRead(conversationId, currentUserIsAgent);
lastMarkedReadRef.current = now;
}
}, shouldAutoScroll ? 800 : 300); // 如果自动滚动,等待 800ms;否则等待 300ms
}
};
// 对于新消息,延迟一点再检查位置,确保 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>
</>
);
}
@@ -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,13 +131,47 @@ export function NavigationSidebar() {
</svg>
</button>
{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")}
>
<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 mb-4 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"
@@ -77,36 +180,123 @@ export function NavigationSidebar() {
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"
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>
<button
className="w-10 h-10 rounded-lg bg-white border border-gray-200 flex items-center justify-center mt-auto hover:bg-gray-100 transition-colors"
title="设置"
disabled
>
<svg
className="w-6 h-6 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
{/* 个人资料按钮(固定在底部) */}
<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)}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
{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>
);
}
@@ -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
View File
@@ -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>
);
}
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
@@ -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
View File
@@ -0,0 +1,6 @@
/**
*
*/
export { ResponsiveLayout, type ResponsiveLayoutProps } from "./ResponsiveLayout";
+37
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
@@ -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
View File
@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More