Explorar el Código

新增会话关闭

537yaha hace 3 meses
padre
commit
a60ea15148

+ 6 - 6
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`
 

+ 28 - 3
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

+ 40 - 0
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 {

+ 1 - 0
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) // 获取开放的模型列表(供访客选择)

+ 39 - 5
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
 	}

+ 10 - 1
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 {

+ 6 - 4
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({
         <Button
           variant="ghost"
           size="icon"
-          title="标记全部已读"
-          onClick={onMarkAllRead}
-          disabled={unreadCount === 0}
+          title="关闭会话"
+          onClick={onCloseConversation}
+          disabled={!onCloseConversation}
         >
           <svg
             className="w-5 h-5"
@@ -114,7 +116,7 @@ export function ChatHeader({
               strokeLinecap="round"
               strokeLinejoin="round"
               strokeWidth={2}
-              d="M5 13l4 4L19 7"
+              d="M6 6l12 12M18 6L6 18"
             />
           </svg>
         </Button>

+ 33 - 1
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({
           )}
         </div>
       ) : (
-        <ConversationHeader filter={filter} onFilterChange={onFilterChange} />
+        <div className="flex flex-col">
+          <ConversationHeader filter={filter} onFilterChange={onFilterChange} />
+          <div className="px-3 pb-2 flex items-center gap-2">
+            <button
+              type="button"
+              className={`px-3 py-1.5 rounded-md text-xs border transition ${
+                status === "open"
+                  ? "bg-green-600 text-white border-green-600"
+                  : "bg-background text-muted-foreground border-border hover:text-foreground"
+              }`}
+              onClick={() => onStatusChange?.("open")}
+            >
+              进行中
+            </button>
+            <button
+              type="button"
+              className={`px-3 py-1.5 rounded-md text-xs border transition ${
+                status === "closed"
+                  ? "bg-green-600 text-white border-green-600"
+                  : "bg-background text-muted-foreground border-border hover:text-foreground"
+              }`}
+              onClick={() => onStatusChange?.("closed")}
+            >
+              历史
+            </button>
+          </div>
+        </div>
       )}
       <div className="flex-shrink-0 px-2 min-w-0">
         <ConversationSearch value={searchQuery} onChange={onSearchChange} />

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

@@ -7,6 +7,7 @@ import { useAuth } from "@/features/agent/hooks/useAuth";
 import { useConversations } from "@/features/agent/hooks/useConversations";
 import { useMessages } from "@/features/agent/hooks/useMessages";
 import { initInternalConversation } from "@/features/agent/services/conversationApi";
+import { closeConversation } from "@/features/agent/services/conversationApi";
 import { toast } from "@/hooks/useToast";
 import { useProfile } from "@/features/agent/hooks/useProfile";
 import { Profile } from "@/features/agent/types";
@@ -86,6 +87,7 @@ export function DashboardShell() {
 
   // 会话过滤状态
   const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
+  const [conversationStatus, setConversationStatus] = useState<"open" | "closed">("open");
 
   // 声音通知开关(客服端)
   const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(false);
@@ -110,6 +112,7 @@ export function DashboardShell() {
     agentId: agent?.id ?? null,
     filter: conversationFilter,
     listType: isInternalChat ? "internal" : "visitor",
+    status: conversationStatus,
   });
 
   // 计算总未读消息数
@@ -190,6 +193,19 @@ export function DashboardShell() {
     }
   }, [markMessagesAsRead, selectedConversationId]);
 
+  const handleCloseConversation = useCallback(async () => {
+    if (!selectedConversationId) return;
+    try {
+      await closeConversation(selectedConversationId);
+      toast.success("已关闭会话");
+      // 清空选中并刷新列表/详情
+      selectConversation(null);
+      refreshConversations();
+    } catch (e) {
+      toast.error((e as Error).message || "关闭会话失败");
+    }
+  }, [refreshConversations, selectConversation, selectedConversationId]);
+
   // 手动刷新消息与访客详情
   const handleRefreshChat = useCallback(() => {
     if (!selectedConversationId) return;
@@ -274,6 +290,12 @@ export function DashboardShell() {
         onSelectConversation={handleConversationSelect}
         filter={conversationFilter}
         onFilterChange={setConversationFilter}
+        status={conversationStatus}
+        onStatusChange={(s) => {
+          setConversationStatus(s);
+          // 切换状态时清空搜索,更直观
+          setSearchQuery("");
+        }}
         mode={isInternalChat ? "internal" : "visitor"}
         onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
       />
@@ -301,6 +323,7 @@ export function DashboardShell() {
               lastSeenAt={conversationDetail?.last_seen_at}
               unreadCount={selectedUnreadCount}
               onMarkAllRead={handleMarkAllRead}
+              onCloseConversation={handleCloseConversation}
               onRefresh={handleRefreshChat}
               includeAIMessages={includeAIMessages}
               onToggleAIMessages={toggleAIMessages}

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

@@ -68,6 +68,8 @@ export function Footer() {
               <li>
                 <Link
                   href="/agent/login"
+                  target="_blank"
+                  rel="noopener noreferrer"
                   className="text-muted-foreground hover:text-foreground transition-colors"
                 >
                   客服登录

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

@@ -43,6 +43,8 @@ export function Header() {
               </Link>
               <Link
                 href="/agent/login"
+                target="_blank"
+                rel="noopener noreferrer"
                 className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
               >
                 客服登录

+ 6 - 1
frontend/components/marketing/HomePageClient.tsx

@@ -253,7 +253,12 @@ export function HomePageClient() {
                   className="rounded-xl border-border/80 px-8 py-6 text-[15px] bg-background/60 backdrop-blur-sm"
                   asChild
                 >
-                  <Link href="/agent/login" className="inline-flex items-center justify-center gap-2">
+                  <Link
+                    href="/agent/login"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    className="inline-flex items-center justify-center gap-2"
+                  >
                     客服登录
                   </Link>
                 </Button>

+ 11 - 5
frontend/features/agent/hooks/useConversations.ts

@@ -7,6 +7,7 @@ import {
   searchConversations,
 } from "../../agent/services/conversationApi";
 import type { ConversationListType } from "../../agent/services/conversationApi";
+import type { ConversationStatus } from "../../agent/services/conversationApi";
 import { ConversationSummary, VisitorStatusUpdatePayload } from "../../agent/types";
 import { useWebSocket } from "./useWebSocket";
 import { WSMessage } from "@/lib/websocket";
@@ -25,10 +26,12 @@ interface UseConversationsOptions {
   filter?: ConversationFilter;
   /** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
   listType?: ConversationListType;
+  /** 会话状态:open(进行中)/ closed(历史) */
+  status?: ConversationStatus;
 }
 
 export function useConversations(options?: UseConversationsOptions) {
-  const { agentId, filter = "all", listType = "visitor" } = options || {};
+  const { agentId, filter = "all", listType = "visitor", status = "open" } = options || {};
   const [conversations, setConversations] = useState<ConversationSummary[]>([]);
   const [filteredConversations, setFilteredConversations] = useState<
     ConversationSummary[]
@@ -75,7 +78,10 @@ export function useConversations(options?: UseConversationsOptions) {
         setSelectedConversationId(null);
         return;
       }
-      const data = await fetchConversations(agentId ?? undefined, listType === "internal" ? { type: "internal" } : undefined);
+      const data = await fetchConversations(
+        agentId ?? undefined,
+        listType === "internal" ? { type: "internal", status } : { status }
+      );
       setConversations(data);
       const filtered = listType === "internal" ? data : applyFilter(data);
       if (!searchRef.current.trim()) {
@@ -93,7 +99,7 @@ export function useConversations(options?: UseConversationsOptions) {
       setLoading(false);
       setIsInitialLoad(false);
     }
-  }, [applyFilter, agentId, filter, listType]);
+  }, [applyFilter, agentId, filter, listType, status]);
 
   useEffect(() => {
     loadConversations();
@@ -127,7 +133,7 @@ export function useConversations(options?: UseConversationsOptions) {
       }
       try {
         setLoading(true);
-        const data = await searchConversations(query, agentId ?? undefined);
+        const data = await searchConversations(query, agentId ?? undefined, { status });
         const filtered = applyFilter(data);
         setFilteredConversations(sortByUpdatedAtDesc(filtered));
       } catch (error) {
@@ -139,7 +145,7 @@ export function useConversations(options?: UseConversationsOptions) {
     }, 300);
 
     return () => clearTimeout(handler);
-  }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType]);
+  }, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType, status]);
 
   const selectConversation = useCallback((conversationId: number | null) => {
     setSelectedConversationId((prev) =>

+ 20 - 4
frontend/features/agent/services/conversationApi.ts

@@ -5,14 +5,16 @@ import {
 } from "../types";
 
 export type ConversationListType = "visitor" | "internal";
+export type ConversationStatus = "open" | "closed";
 
 export async function fetchConversations(
   userId?: number,
-  opts?: { type?: ConversationListType }
+  opts?: { type?: ConversationListType; status?: ConversationStatus }
 ): Promise<ConversationSummary[]> {
   const params = new URLSearchParams();
   if (userId) params.set("user_id", String(userId));
   if (opts?.type) params.set("type", opts.type);
+  if (opts?.status) params.set("status", opts.status);
   const url = `${apiUrl("/conversations")}?${params.toString()}`;
   const res = await fetch(url, { cache: "no-store", headers: getAgentHeaders() });
   if (!res.ok) {
@@ -45,11 +47,13 @@ export async function initInternalConversation(userId: number): Promise<{ conver
 
 export async function searchConversations(
   query: string,
-  userId?: number
+  userId?: number,
+  opts?: { status?: ConversationStatus }
 ): Promise<ConversationSummary[]> {
+  const status = opts?.status ?? "open";
   const url = userId
-    ? `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&user_id=${userId}`
-    : `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}`;
+    ? `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&user_id=${userId}&status=${status}`
+    : `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&status=${status}`;
   const res = await fetch(url, {
     cache: "no-store",
     headers: getAgentHeaders(),
@@ -86,6 +90,18 @@ export async function fetchConversationDetail(
   };
 }
 
+/** 关闭会话(进入历史/归档)。访客再次发消息会自动 reopen(B 方案)。 */
+export async function closeConversation(conversationId: number): Promise<void> {
+  const res = await fetch(apiUrl(`/conversations/${conversationId}/close`), {
+    method: "POST",
+    headers: getAgentHeaders(),
+  });
+  if (!res.ok) {
+    const j = await res.json().catch(() => ({}));
+    throw new Error((j as { error?: string }).error || `关闭会话失败(${res.status})`);
+  }
+}
+
 export interface UpdateConversationContactPayload {
   email?: string;
   phone?: string;