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({ )} ) : ( - +
+ +
+ + +
+
)}
diff --git a/frontend/components/dashboard/DashboardShell.tsx b/frontend/components/dashboard/DashboardShell.tsx index 763e5a2..db2643c 100644 --- a/frontend/components/dashboard/DashboardShell.tsx +++ b/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} diff --git a/frontend/components/layout/Footer.tsx b/frontend/components/layout/Footer.tsx index 45a2549..a417c82 100644 --- a/frontend/components/layout/Footer.tsx +++ b/frontend/components/layout/Footer.tsx @@ -68,6 +68,8 @@ export function Footer() {
  • 客服登录 diff --git a/frontend/components/layout/Header.tsx b/frontend/components/layout/Header.tsx index 73d036a..3fcf97a 100644 --- a/frontend/components/layout/Header.tsx +++ b/frontend/components/layout/Header.tsx @@ -43,6 +43,8 @@ export function Header() { 客服登录 diff --git a/frontend/components/marketing/HomePageClient.tsx b/frontend/components/marketing/HomePageClient.tsx index 841fc81..f4e7e0c 100644 --- a/frontend/components/marketing/HomePageClient.tsx +++ b/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 > - + 客服登录 diff --git a/frontend/features/agent/hooks/useConversations.ts b/frontend/features/agent/hooks/useConversations.ts index aed2c6c..a3161d0 100644 --- a/frontend/features/agent/hooks/useConversations.ts +++ b/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([]); 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) => diff --git a/frontend/features/agent/services/conversationApi.ts b/frontend/features/agent/services/conversationApi.ts index 49b98b0..cc7b0e5 100644 --- a/frontend/features/agent/services/conversationApi.ts +++ b/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 { 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 { + 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 { + 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;