v1.0.0
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "密码更新成功"})
|
||||
}
|
||||
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
|
||||
@@ -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 字段(用于判断在线状态)
|
||||
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"` // 更新时间
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ type ControllerSet struct {
|
||||
Message *controller.MessageController
|
||||
Admin *controller.AdminController
|
||||
Profile *controller.ProfileController
|
||||
AIConfig *controller.AIConfigController
|
||||
FAQ *controller.FAQController
|
||||
Visitor *controller.VisitorController
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册 HTTP 路由及对应的处理函数。
|
||||
@@ -26,20 +29,46 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
|
||||
r.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
|
||||
r.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
|
||||
r.GET("/conversations/search", controllers.Conversation.SearchConversations)
|
||||
r.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
|
||||
|
||||
// Message
|
||||
r.POST("/messages", controllers.Message.CreateMessage)
|
||||
r.POST("/messages/upload", controllers.Message.UploadFile) // 文件上传接口
|
||||
r.GET("/messages", controllers.Message.ListMessages)
|
||||
r.PUT("/messages/read", controllers.Message.MarkMessagesRead)
|
||||
|
||||
// Admin
|
||||
r.POST("/admin/users", controllers.Admin.CreateAgent)
|
||||
// Admin(用户管理)
|
||||
r.GET("/admin/users", controllers.Admin.ListUsers) // 获取所有用户列表
|
||||
r.GET("/admin/users/:id", controllers.Admin.GetUser) // 获取用户详情
|
||||
r.POST("/admin/users", controllers.Admin.CreateUser) // 创建新用户
|
||||
r.PUT("/admin/users/:id", controllers.Admin.UpdateUser) // 更新用户信息
|
||||
r.DELETE("/admin/users/:id", controllers.Admin.DeleteUser) // 删除用户
|
||||
r.PUT("/admin/users/:id/password", controllers.Admin.UpdateUserPassword) // 更新用户密码
|
||||
// 兼容旧接口
|
||||
r.POST("/admin/agents", controllers.Admin.CreateAgent) // 创建客服(兼容旧接口)
|
||||
|
||||
// Profile(个人资料)
|
||||
r.GET("/agent/profile/:user_id", controllers.Profile.GetProfile)
|
||||
r.PUT("/agent/profile/:user_id", controllers.Profile.UpdateProfile)
|
||||
r.POST("/agent/avatar/:user_id", controllers.Profile.UploadAvatar)
|
||||
|
||||
// AI Config(AI 配置)
|
||||
r.POST("/agent/ai-config/:user_id", controllers.AIConfig.CreateAIConfig)
|
||||
r.GET("/agent/ai-config/:user_id", controllers.AIConfig.ListAIConfigs)
|
||||
r.GET("/agent/ai-config/:user_id/:id", controllers.AIConfig.GetAIConfig)
|
||||
r.PUT("/agent/ai-config/:user_id/:id", controllers.AIConfig.UpdateAIConfig)
|
||||
r.DELETE("/agent/ai-config/:user_id/:id", controllers.AIConfig.DeleteAIConfig)
|
||||
|
||||
// FAQ(事件管理/常见问题)
|
||||
r.GET("/faqs", controllers.FAQ.ListFAQs) // 获取 FAQ 列表(支持关键词搜索)
|
||||
r.GET("/faqs/:id", controllers.FAQ.GetFAQ) // 获取 FAQ 详情
|
||||
r.POST("/faqs", controllers.FAQ.CreateFAQ) // 创建 FAQ
|
||||
r.PUT("/faqs/:id", controllers.FAQ.UpdateFAQ) // 更新 FAQ
|
||||
r.DELETE("/faqs/:id", controllers.FAQ.DeleteFAQ) // 删除 FAQ
|
||||
|
||||
// Visitor(访客相关)
|
||||
r.GET("/visitor/online-agents", controllers.Visitor.GetOnlineAgents) // 获取在线客服列表
|
||||
|
||||
// WebSocket
|
||||
r.GET("/ws", wsHandler)
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AIProvider AI 服务提供商接口(可扩展设计)
|
||||
// 不同的 AI 服务提供商需要实现这个接口
|
||||
type AIProvider interface {
|
||||
// GenerateResponse 生成 AI 回复
|
||||
// conversationHistory: 对话历史(用于上下文)
|
||||
// userMessage: 用户当前消息
|
||||
// 返回: AI 回复内容
|
||||
GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error)
|
||||
}
|
||||
|
||||
// AdapterConfig 适配器配置(用于适配不同服务商的 API 格式差异)
|
||||
type AdapterConfig struct {
|
||||
// 认证头格式(默认:Bearer)
|
||||
AuthHeader string `json:"auth_header"` // 例如:"Bearer"、"X-API-Key"、"Authorization"
|
||||
// 响应解析路径(默认:choices[0].message.content)
|
||||
ResponsePath string `json:"response_path"` // 例如:"choices[0].message.content"、"data.text"、"result.content"
|
||||
// 请求格式自定义(可选)
|
||||
RequestFormat map[string]interface{} `json:"request_format"` // 用于覆盖默认的请求格式
|
||||
}
|
||||
|
||||
// MessageHistory 对话历史记录
|
||||
type MessageHistory struct {
|
||||
Role string `json:"role"` // "user" 或 "assistant"
|
||||
Content string `json:"content"` // 消息内容
|
||||
}
|
||||
|
||||
// AIConfig 用于 AI 调用的配置信息
|
||||
type AIConfig struct {
|
||||
APIURL string
|
||||
APIKey string
|
||||
Model string
|
||||
ModelType string
|
||||
Provider string
|
||||
AdapterConfig *AdapterConfig // 适配器配置(用于适配不同服务商的差异)
|
||||
}
|
||||
|
||||
// UniversalAIProvider 通用 AI 服务提供商(支持所有 OpenAI 兼容格式)
|
||||
// 通过适配器配置来适配不同服务商的细微差异
|
||||
// 这样 90% 的服务商都可以用同一个 Provider,无需单独实现
|
||||
type UniversalAIProvider struct {
|
||||
config AIConfig
|
||||
client *http.Client
|
||||
adapter *AdapterConfig
|
||||
}
|
||||
|
||||
// NewUniversalAIProvider 创建通用 AI 提供商实例。
|
||||
func NewUniversalAIProvider(config AIConfig) *UniversalAIProvider {
|
||||
// 设置默认适配器配置
|
||||
adapter := config.AdapterConfig
|
||||
if adapter == nil {
|
||||
adapter = &AdapterConfig{
|
||||
AuthHeader: "Bearer", // 默认使用 Bearer Token
|
||||
ResponsePath: "choices[0].message.content", // 默认 OpenAI 格式
|
||||
}
|
||||
} else {
|
||||
// 设置默认值
|
||||
if adapter.AuthHeader == "" {
|
||||
adapter.AuthHeader = "Bearer"
|
||||
}
|
||||
if adapter.ResponsePath == "" {
|
||||
adapter.ResponsePath = "choices[0].message.content"
|
||||
}
|
||||
}
|
||||
|
||||
return &UniversalAIProvider{
|
||||
config: config,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second, // 30 秒超时
|
||||
},
|
||||
adapter: adapter,
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateResponse 生成 AI 回复(支持 OpenAI 兼容格式,通过适配器适配不同服务商)。
|
||||
func (p *UniversalAIProvider) GenerateResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
|
||||
// 根据模型类型选择不同的处理逻辑
|
||||
switch p.config.ModelType {
|
||||
case "text":
|
||||
return p.generateTextResponse(conversationHistory, userMessage)
|
||||
case "image":
|
||||
// 图片生成(未来扩展)
|
||||
return "", fmt.Errorf("图片模型暂未支持")
|
||||
case "audio":
|
||||
// 语音识别/合成(未来扩展)
|
||||
return "", fmt.Errorf("语音模型暂未支持")
|
||||
case "video":
|
||||
// 视频生成(未来扩展)
|
||||
return "", fmt.Errorf("视频模型暂未支持")
|
||||
default:
|
||||
return "", fmt.Errorf("不支持的模型类型: %s", p.config.ModelType)
|
||||
}
|
||||
}
|
||||
|
||||
// generateTextResponse 生成文本回复(通用实现,支持所有 OpenAI 兼容格式)。
|
||||
func (p *UniversalAIProvider) generateTextResponse(conversationHistory []MessageHistory, userMessage string) (string, error) {
|
||||
// 构建消息列表(包含历史对话和当前消息)
|
||||
messages := make([]map[string]string, 0)
|
||||
|
||||
// 添加历史对话
|
||||
for _, history := range conversationHistory {
|
||||
messages = append(messages, map[string]string{
|
||||
"role": history.Role,
|
||||
"content": history.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// 添加当前用户消息
|
||||
messages = append(messages, map[string]string{
|
||||
"role": "user",
|
||||
"content": userMessage,
|
||||
})
|
||||
|
||||
// 构建请求体(OpenAI 兼容格式)
|
||||
requestBody := map[string]interface{}{
|
||||
"model": p.config.Model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建 HTTP 请求
|
||||
req, err := http.NewRequest("POST", p.config.APIURL, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
|
||||
// 设置请求头
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 根据适配器配置设置认证头
|
||||
authValue := p.config.APIKey
|
||||
if p.adapter.AuthHeader == "Bearer" {
|
||||
authValue = "Bearer " + p.config.APIKey
|
||||
req.Header.Set("Authorization", authValue)
|
||||
} else if p.adapter.AuthHeader == "X-API-Key" {
|
||||
req.Header.Set("X-API-Key", p.config.APIKey)
|
||||
} else {
|
||||
// 默认使用 Authorization: Bearer
|
||||
req.Header.Set("Authorization", "Bearer "+p.config.APIKey)
|
||||
}
|
||||
|
||||
// 发送请求
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查 HTTP 状态码
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("API 返回错误: %s (状态码: %d)", string(body), resp.StatusCode)
|
||||
}
|
||||
|
||||
// 解析响应(支持灵活的响应路径)
|
||||
var responseData map[string]interface{}
|
||||
if err := json.Unmarshal(body, &responseData); err != nil {
|
||||
return "", fmt.Errorf("解析响应失败: %v", err)
|
||||
}
|
||||
|
||||
// 检查是否有错误字段
|
||||
if errorMsg, ok := responseData["error"].(map[string]interface{}); ok {
|
||||
if msg, ok := errorMsg["message"].(string); ok {
|
||||
return "", fmt.Errorf("API 错误: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据适配器配置的响应路径提取内容
|
||||
content, err := p.extractResponseContent(responseData, p.adapter.ResponsePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if content == "" {
|
||||
return "", errors.New("API 返回空内容")
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// extractResponseContent 根据响应路径提取内容(支持灵活的路径配置)。
|
||||
// 例如:"choices[0].message.content" 或 "data.text" 或 "result.content"
|
||||
func (p *UniversalAIProvider) extractResponseContent(data map[string]interface{}, path string) (string, error) {
|
||||
// 默认路径:choices[0].message.content(OpenAI 格式)
|
||||
if path == "" || path == "choices[0].message.content" {
|
||||
// 尝试 OpenAI 格式
|
||||
if choices, ok := data["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试其他常见格式
|
||||
// 格式1: data.text
|
||||
if dataObj, ok := data["data"].(map[string]interface{}); ok {
|
||||
if text, ok := dataObj["text"].(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 格式2: result.content
|
||||
if result, ok := data["result"].(map[string]interface{}); ok {
|
||||
if content, ok := result["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 格式3: content(直接字段)
|
||||
if content, ok := data["content"].(string); ok {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// 格式4: text(直接字段)
|
||||
if text, ok := data["text"].(string); ok {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
return "", errors.New("无法从响应中提取内容,请检查响应格式或配置适配器")
|
||||
}
|
||||
|
||||
// AIProviderFactory AI 提供商工厂(用于创建不同类型的提供商)
|
||||
type AIProviderFactory struct{}
|
||||
|
||||
// NewAIProviderFactory 创建 AI 提供商工厂实例。
|
||||
func NewAIProviderFactory() *AIProviderFactory {
|
||||
return &AIProviderFactory{}
|
||||
}
|
||||
|
||||
// CreateProvider 根据配置创建对应的 AI 提供商。
|
||||
// 设计理念:
|
||||
// 所有主流 AI 服务商都使用 REST API(HTTP/HTTPS),统一使用 UniversalAIProvider 处理
|
||||
// 通过 AdapterConfig 适配不同服务商的细微差异(认证头、响应路径等)
|
||||
func (f *AIProviderFactory) CreateProvider(config AIConfig) (AIProvider, error) {
|
||||
// 所有服务商都使用 REST API,统一处理
|
||||
return NewUniversalAIProvider(config), nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 将消息标记为已读并通知监听方。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 531 B |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 531 B |
|
After Width: | Height: | Size: 531 B |
|
After Width: | Height: | Size: 162 KiB |
|
After Width: | Height: | Size: 531 B |
|
After Width: | Height: | Size: 19 KiB |
@@ -0,0 +1,118 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// 获取加密密钥(优先从环境变量读取,否则使用默认值)
|
||||
// 重要说明:
|
||||
// 1. 这个密钥是固定的,用于加密/解密所有 API Key
|
||||
// 2. API Key 的长度可以不同,但都用同一个加密密钥加密
|
||||
// 3. 如果改变这个密钥,之前加密的 API Key 将无法解密
|
||||
// 4. 生产环境必须从环境变量 ENCRYPTION_KEY 读取
|
||||
// AES-256 需要 32 字节的密钥
|
||||
func getEncryptionKey() []byte {
|
||||
key := os.Getenv("ENCRYPTION_KEY")
|
||||
if key != "" && len(key) == 32 {
|
||||
return []byte(key)
|
||||
}
|
||||
// 默认密钥(仅用于开发环境,生产环境必须设置 ENCRYPTION_KEY)
|
||||
return []byte("abcdefghijklmnopqrstuvwxyz123456") // 32 bytes for AES-256
|
||||
}
|
||||
|
||||
// EncryptAPIKey 加密 API Key
|
||||
// 参数:
|
||||
// - plaintext: 用户输入的 API Key(长度可变,不同服务商不同)
|
||||
//
|
||||
// 返回:
|
||||
// - 加密后的字符串(base64 编码)
|
||||
//
|
||||
// 说明:
|
||||
// - 无论 API Key 多长,都用同一个固定的 encryptionKey 加密
|
||||
// - 加密密钥(encryptionKey)是固定的,不会因为 API Key 长度变化而改变
|
||||
func EncryptAPIKey(plaintext string) (string, error) {
|
||||
// 验证输入
|
||||
if plaintext == "" {
|
||||
return "", errors.New("API Key 不能为空")
|
||||
}
|
||||
|
||||
// 获取加密密钥(优先从环境变量读取)
|
||||
key := getEncryptionKey()
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("加密密钥长度必须为 32 字节")
|
||||
}
|
||||
|
||||
// 创建 AES cipher
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建加密器失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建 GCM(Galois/Counter Mode)
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建 GCM 失败: %v", err)
|
||||
}
|
||||
|
||||
// 生成随机 nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", fmt.Errorf("生成随机数失败: %v", err)
|
||||
}
|
||||
|
||||
// 加密数据
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
|
||||
// 返回 base64 编码的密文
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// DecryptAPIKey 解密 API Key
|
||||
func DecryptAPIKey(ciphertext string) (string, error) {
|
||||
// 获取加密密钥(优先从环境变量读取)
|
||||
key := getEncryptionKey()
|
||||
if len(key) != 32 {
|
||||
return "", errors.New("加密密钥长度必须为 32 字节")
|
||||
}
|
||||
|
||||
// 解码 base64
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 创建 AES cipher
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 创建 GCM
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 检查数据长度
|
||||
if len(data) < gcm.NonceSize() {
|
||||
return "", errors.New("ciphertext too short")
|
||||
}
|
||||
|
||||
// 提取 nonce 和密文
|
||||
nonce, ciphertextBytes := data[:gcm.NonceSize()], data[gcm.NonceSize():]
|
||||
|
||||
// 解密数据
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,13 +3,16 @@ package websocket
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/2930134478/AI-CS/backend/models"
|
||||
)
|
||||
|
||||
// OnClientConnectCallback 客户端连接时的回调函数。
|
||||
// conversationID: 对话ID
|
||||
// isVisitor: 是否是访客
|
||||
// visitorCount: 该对话当前的访客连接数
|
||||
type OnClientConnectCallback func(conversationID uint, isVisitor bool, visitorCount int)
|
||||
// agentID: 客服ID(如果是客服连接)
|
||||
type OnClientConnectCallback func(conversationID uint, isVisitor bool, visitorCount int, agentID uint)
|
||||
|
||||
// OnClientDisconnectCallback 客户端断开连接时的回调函数。
|
||||
// conversationID: 对话ID
|
||||
@@ -85,7 +88,7 @@ func (h *Hub) Run() {
|
||||
|
||||
// 调用连接回调函数
|
||||
if h.onConnect != nil {
|
||||
h.onConnect(client.conversationID, client.isVisitor, visitorCount)
|
||||
h.onConnect(client.conversationID, client.isVisitor, visitorCount, client.agentID)
|
||||
}
|
||||
|
||||
// 客户端断开连接
|
||||
@@ -194,8 +197,24 @@ func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
|
||||
|
||||
// 为每个客服客户端创建消息并发送
|
||||
for _, client := range allAgents {
|
||||
// 如果 data 是 Message 对象,使用消息的 conversation_id
|
||||
// 否则使用客户端连接的对话ID
|
||||
var conversationID uint
|
||||
if msg, ok := data.(*models.Message); ok {
|
||||
conversationID = msg.ConversationID
|
||||
} else if convID, ok := data.(map[string]interface{})["conversation_id"]; ok {
|
||||
if id, ok := convID.(uint); ok {
|
||||
conversationID = id
|
||||
} else if id, ok := convID.(float64); ok {
|
||||
conversationID = uint(id)
|
||||
} else {
|
||||
conversationID = client.conversationID
|
||||
}
|
||||
} else {
|
||||
conversationID = client.conversationID
|
||||
}
|
||||
message := &Message{
|
||||
ConversationID: client.conversationID, // 使用客户端连接的对话ID
|
||||
ConversationID: conversationID,
|
||||
Type: messageType,
|
||||
Data: data,
|
||||
}
|
||||
@@ -216,3 +235,20 @@ func (h *Hub) BroadcastToAllAgents(messageType string, data interface{}) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetOnlineAgentIDs 获取所有在线客服的用户ID列表(去重)
|
||||
// 返回一个 map,key 是 agentID,value 是 true(用于快速查找)
|
||||
func (h *Hub) GetOnlineAgentIDs() map[uint]bool {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
agentIDs := make(map[uint]bool)
|
||||
for _, clients := range h.conversations {
|
||||
for client := range clients {
|
||||
if !client.isVisitor && client.agentID > 0 {
|
||||
agentIDs[client.agentID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentIDs
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 大模型,包括 OpenAI、DeepSeek、百智云等主流平台,支持自定义 API 配置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">如何实现 AI 和人工客服的切换?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
访客可以在聊天界面一键切换 AI 客服和人工客服模式,系统会自动处理会话转移。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">系统支持文件上传吗?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
支持,访客和客服都可以上传图片和文件,支持图片预览和文件下载功能。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">如何集成到现有网站?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
提供网页挂件(Widget)功能,只需在网站中嵌入一段代码即可,支持自定义样式和位置。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">系统支持多客服协作吗?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
支持,多个客服可以同时在线,系统支持会话分配、转接和协作功能。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="cursor-pointer hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<HelpCircle className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">数据安全如何保障?</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
系统采用 API 密钥加密存储,支持权限管理,所有数据都经过安全加密处理。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer />
|
||||
|
||||
{/* 客服插件 */}
|
||||
{visitorId !== null && (
|
||||
<>
|
||||
{/* 浮动按钮 */}
|
||||
<FloatingButton onClick={handleToggleChat} isOpen={isChatOpen} />
|
||||
{/* 聊天小窗 */}
|
||||
{isChatOpen && (
|
||||
<ChatWidget
|
||||
visitorId={visitorId}
|
||||
isOpen={isChatOpen}
|
||||
onToggle={handleToggleChat}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 响应式布局组件统一导出
|
||||
*/
|
||||
|
||||
export { ResponsiveLayout, type ResponsiveLayoutProps } from "./ResponsiveLayout";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||