diff --git a/README.md b/README.md
index 1b097a5..7b21f25 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,9 @@
## 在线演示
-- **官网首页(产品介绍 + SEO)**:`https://demo.cscorp.top`
-- **访客聊天页**:`https://demo.cscorp.top/chat`(也可从首页右下角按钮进入)
-- **客服登录**:`https://demo.cscorp.top/agent/login`
+- **官网首页(产品介绍 + SEO)**:[demo.cscorp.top](https://demo.cscorp.top)
+- **访客聊天页**:[demo.cscorp.top/chat](https://demo.cscorp.top/chat)(也可从首页右下角按钮进入)
+- **客服登录**:[demo.cscorp.top/agent/login](https://demo.cscorp.top/agent/login)
## 你能用它做什么(功能回顾)
@@ -67,9 +67,9 @@ docker-compose -f docker-compose.prod.yml up -d
#### 3)访问
-- **官网首页**:`http://localhost:3000`
-- **访客聊天**:`http://localhost:3000/chat`
-- **客服登录**:`http://localhost:3000/agent/login`
+- **官网首页**:[localhost:3000](http://localhost:3000)
+- **访客聊天**:[localhost:3000/chat](http://localhost:3000/chat)
+- **客服登录**:[localhost:3000/agent/login](http://localhost:3000/agent/login)
- 用户名:`admin`(或 `.env` 中 `ADMIN_USERNAME`)
- 密码:`.env` 中 `ADMIN_PASSWORD`
diff --git a/backend/controller/conversation_controller.go b/backend/controller/conversation_controller.go
index baf2941..39d5aa0 100644
--- a/backend/controller/conversation_controller.go
+++ b/backend/controller/conversation_controller.go
@@ -167,6 +167,29 @@ func (cc *ConversationController) UpdateContactInfo(c *gin.Context) {
})
}
+// CloseConversation 客服关闭会话(进入历史/归档)。
+// POST /conversations/:id/close
+func (cc *ConversationController) CloseConversation(c *gin.Context) {
+ if !requirePermission(c, cc.users, string(service.PermChat)) {
+ return
+ }
+ id, err := parseUintParam(c, "id")
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "会话ID不合法"})
+ return
+ }
+ userID := getUserIDFromHeader(c)
+ if err := cc.conversationService.CloseConversation(uint(id), userID); err != nil {
+ if err == service.ErrConversationNotFound {
+ c.JSON(http.StatusNotFound, gin.H{"error": "会话不存在"})
+ return
+ }
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
+
// ListConversations 返回当前活跃会话的列表。type=internal 时返回该客服的内部对话(知识库测试)。
func (cc *ConversationController) ListConversations(c *gin.Context) {
var userID uint
@@ -177,6 +200,7 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
}
conversationType := c.DefaultQuery("type", "visitor")
+ status := c.DefaultQuery("status", "open")
var conversations []service.ConversationSummary
var err error
if conversationType == "internal" {
@@ -187,9 +211,9 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"})
return
}
- conversations, err = cc.conversationService.ListInternalConversations(userID)
+ conversations, err = cc.conversationService.ListInternalConversations(userID, status)
} else {
- conversations, err = cc.conversationService.ListConversations(userID)
+ conversations, err = cc.conversationService.ListConversations(userID, status)
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"})
@@ -304,6 +328,7 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
return
}
+ status := c.DefaultQuery("status", "open")
// 从查询参数获取 user_id(可选,用于检查参与状态)
var userID uint
@@ -314,7 +339,7 @@ func (cc *ConversationController) SearchConversations(c *gin.Context) {
}
}
- conversations, err := cc.conversationService.SearchConversations(query, userID)
+ conversations, err := cc.conversationService.SearchConversations(query, userID, status)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
return
diff --git a/backend/repository/conversation_repository.go b/backend/repository/conversation_repository.go
index 5242e93..e0ddb62 100644
--- a/backend/repository/conversation_repository.go
+++ b/backend/repository/conversation_repository.go
@@ -74,6 +74,46 @@ func (r *ConversationRepository) ListActive() ([]models.Conversation, error) {
return conversations, nil
}
+// ListByTypeAndStatus 返回指定类型的会话列表(支持 open/closed/all)。
+func (r *ConversationRepository) ListByTypeAndStatus(conversationType string, status string) ([]models.Conversation, error) {
+ var conversations []models.Conversation
+ q := r.db.Where("conversation_type = ?", conversationType)
+ switch status {
+ case "open":
+ q = q.Where("status = ?", "open")
+ case "closed":
+ q = q.Where("status = ?", "closed")
+ case "", "all":
+ // no-op
+ default:
+ return nil, errors.New("invalid status")
+ }
+ if err := q.Order("updated_at desc").Find(&conversations).Error; err != nil {
+ return nil, err
+ }
+ return conversations, nil
+}
+
+// ListInternalByAgentIDAndStatus 返回某客服的内部对话(支持 open/closed/all)。
+func (r *ConversationRepository) ListInternalByAgentIDAndStatus(agentID uint, status string) ([]models.Conversation, error) {
+ var conversations []models.Conversation
+ q := r.db.Where("conversation_type = ? AND agent_id = ?", "internal", agentID)
+ switch status {
+ case "open":
+ q = q.Where("status = ?", "open")
+ case "closed":
+ q = q.Where("status = ?", "closed")
+ case "", "all":
+ // no-op
+ default:
+ return nil, errors.New("invalid status")
+ }
+ if err := q.Order("updated_at desc").Find(&conversations).Error; err != nil {
+ return nil, err
+ }
+ return conversations, nil
+}
+
// ListByIDs 根据多个 ID 批量查询会话。
func (r *ConversationRepository) ListByIDs(ids []uint) ([]models.Conversation, error) {
if len(ids) == 0 {
diff --git a/backend/router/router.go b/backend/router/router.go
index 7ed1157..95c0372 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -37,6 +37,7 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
routes.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试)
routes.GET("/conversations", controllers.Conversation.ListConversations)
routes.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
+ routes.POST("/conversations/:id/close", controllers.Conversation.CloseConversation)
routes.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
routes.GET("/conversations/search", controllers.Conversation.SearchConversations)
routes.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
diff --git a/backend/service/conversation_service.go b/backend/service/conversation_service.go
index 0eb7f94..0c778a9 100644
--- a/backend/service/conversation_service.go
+++ b/backend/service/conversation_service.go
@@ -19,6 +19,30 @@ type ConversationService struct {
systemLogSvc *SystemLogService // 可选,结构化日志
}
+// CloseConversation 客服主动关闭会话(visitor/internal 通用)。
+func (s *ConversationService) CloseConversation(conversationID uint, userID uint) error {
+ if conversationID == 0 {
+ return errors.New("conversation_id is required")
+ }
+ conv, err := s.conversations.GetByID(conversationID)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrConversationNotFound
+ }
+ return err
+ }
+ // internal 会话仅允许本人关闭;visitor 会话允许任意客服关闭(你们目前没有租户/组织概念)
+ if conv.ConversationType == "internal" && userID > 0 && conv.AgentID != userID {
+ return errors.New("权限不足:只能关闭自己的内部对话")
+ }
+ if conv.Status == "closed" {
+ return nil
+ }
+ return s.conversations.UpdateFields(conversationID, map[string]interface{}{
+ "status": "closed",
+ })
+}
+
// NewConversationService 创建 ConversationService 实例。
func NewConversationService(
conversations *repository.ConversationRepository,
@@ -349,8 +373,12 @@ func (s *ConversationService) buildSummary(conv models.Conversation, userID uint
// 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()
+func (s *ConversationService) ListConversations(userID uint, status string) ([]ConversationSummary, error) {
+ // 默认展示进行中(open);历史使用 status=closed
+ if status == "" {
+ status = "open"
+ }
+ conversations, err := s.conversations.ListByTypeAndStatus("visitor", status)
if err != nil {
return nil, err
}
@@ -425,7 +453,7 @@ func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*Conv
// SearchConversations 根据关键字检索会话摘要。
// userID: 当前登录的客服ID(可选,用于检查参与状态)
-func (s *ConversationService) SearchConversations(query string, userID uint) ([]ConversationSummary, error) {
+func (s *ConversationService) SearchConversations(query string, userID uint, status string) ([]ConversationSummary, error) {
pattern := "%" + query + "%"
idSet := map[uint]struct{}{}
@@ -462,6 +490,9 @@ func (s *ConversationService) SearchConversations(query string, userID uint) ([]
result := make([]ConversationSummary, 0, len(conversations))
for _, conv := range conversations {
+ if status != "" && status != "all" && conv.Status != status {
+ continue
+ }
summary, err := s.buildSummary(conv, userID)
if err != nil {
return nil, err
@@ -525,11 +556,14 @@ func (s *ConversationService) InitInternalConversation(agentID uint) (*InitConve
}
// ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。
-func (s *ConversationService) ListInternalConversations(agentID uint) ([]ConversationSummary, error) {
+func (s *ConversationService) ListInternalConversations(agentID uint, status string) ([]ConversationSummary, error) {
if agentID == 0 {
return []ConversationSummary{}, nil
}
- conversations, err := s.conversations.ListActiveInternalByAgentID(agentID)
+ if status == "" {
+ status = "open"
+ }
+ conversations, err := s.conversations.ListInternalByAgentIDAndStatus(agentID, status)
if err != nil {
return nil, err
}
diff --git a/backend/service/message_service.go b/backend/service/message_service.go
index a83261f..632d473 100644
--- a/backend/service/message_service.go
+++ b/backend/service/message_service.go
@@ -47,8 +47,17 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag
return nil, err
}
+ // B 方案:会话关闭后,如访客再次发消息则自动 reopen
if conv.Status == "closed" {
- return nil, ErrConversationClosed
+ if input.SenderIsAgent {
+ return nil, ErrConversationClosed
+ }
+ if err := s.conversations.UpdateFields(conv.ID, map[string]interface{}{
+ "status": "open",
+ }); err != nil {
+ return nil, err
+ }
+ conv.Status = "open"
}
if input.SenderIsAgent && input.SenderID == 0 {
diff --git a/frontend/components/dashboard/ChatHeader.tsx b/frontend/components/dashboard/ChatHeader.tsx
index 64e2f29..b1e6663 100644
--- a/frontend/components/dashboard/ChatHeader.tsx
+++ b/frontend/components/dashboard/ChatHeader.tsx
@@ -9,6 +9,7 @@ interface ChatHeaderProps {
lastSeenAt?: string | null;
unreadCount: number;
onMarkAllRead: () => void;
+ onCloseConversation?: () => void;
onRefresh: () => void;
includeAIMessages?: boolean;
onToggleAIMessages?: () => void;
@@ -22,6 +23,7 @@ export function ChatHeader({
lastSeenAt,
unreadCount,
onMarkAllRead,
+ onCloseConversation,
onRefresh,
includeAIMessages = false,
onToggleAIMessages,
@@ -100,9 +102,9 @@ export function ChatHeader({
diff --git a/frontend/components/dashboard/ConversationSidebar.tsx b/frontend/components/dashboard/ConversationSidebar.tsx
index ec6a29f..9361b24 100644
--- a/frontend/components/dashboard/ConversationSidebar.tsx
+++ b/frontend/components/dashboard/ConversationSidebar.tsx
@@ -7,6 +7,8 @@ import { ConversationList } from "./ConversationList";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
+type ConversationStatus = "open" | "closed";
+
interface ConversationSidebarProps {
conversations: ConversationSummary[];
selectedConversationId: number | null;
@@ -15,6 +17,8 @@ interface ConversationSidebarProps {
onSelectConversation: (id: number) => void;
filter: ConversationFilter;
onFilterChange: (filter: ConversationFilter) => void;
+ status?: ConversationStatus;
+ onStatusChange?: (status: ConversationStatus) => void;
/** 内部对话(知识库测试)模式:显示「新建内部对话」按钮,隐藏筛选 */
mode?: "visitor" | "internal";
onNewClick?: () => void;
@@ -28,6 +32,8 @@ export function ConversationSidebar({
onSelectConversation,
filter,
onFilterChange,
+ status = "open",
+ onStatusChange,
mode = "visitor",
onNewClick,
}: ConversationSidebarProps) {
@@ -44,7 +50,33 @@ export function ConversationSidebar({
)}
) : (
-