mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 08:45:41 +08:00
新增会话关闭
This commit is contained in:
@@ -5,9 +5,9 @@
|
|||||||
|
|
||||||
## 在线演示
|
## 在线演示
|
||||||
|
|
||||||
- **官网首页(产品介绍 + SEO)**:`https://demo.cscorp.top`
|
- **官网首页(产品介绍 + SEO)**:[demo.cscorp.top](https://demo.cscorp.top)
|
||||||
- **访客聊天页**:`https://demo.cscorp.top/chat`(也可从首页右下角按钮进入)
|
- **访客聊天页**:[demo.cscorp.top/chat](https://demo.cscorp.top/chat)(也可从首页右下角按钮进入)
|
||||||
- **客服登录**:`https://demo.cscorp.top/agent/login`
|
- **客服登录**:[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)访问
|
#### 3)访问
|
||||||
|
|
||||||
- **官网首页**:`http://localhost:3000`
|
- **官网首页**:[localhost:3000](http://localhost:3000)
|
||||||
- **访客聊天**:`http://localhost:3000/chat`
|
- **访客聊天**:[localhost:3000/chat](http://localhost:3000/chat)
|
||||||
- **客服登录**:`http://localhost:3000/agent/login`
|
- **客服登录**:[localhost:3000/agent/login](http://localhost:3000/agent/login)
|
||||||
- 用户名:`admin`(或 `.env` 中 `ADMIN_USERNAME`)
|
- 用户名:`admin`(或 `.env` 中 `ADMIN_USERNAME`)
|
||||||
- 密码:`.env` 中 `ADMIN_PASSWORD`
|
- 密码:`.env` 中 `ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
|||||||
@@ -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 时返回该客服的内部对话(知识库测试)。
|
// ListConversations 返回当前活跃会话的列表。type=internal 时返回该客服的内部对话(知识库测试)。
|
||||||
func (cc *ConversationController) ListConversations(c *gin.Context) {
|
func (cc *ConversationController) ListConversations(c *gin.Context) {
|
||||||
var userID uint
|
var userID uint
|
||||||
@@ -177,6 +200,7 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
conversationType := c.DefaultQuery("type", "visitor")
|
conversationType := c.DefaultQuery("type", "visitor")
|
||||||
|
status := c.DefaultQuery("status", "open")
|
||||||
var conversations []service.ConversationSummary
|
var conversations []service.ConversationSummary
|
||||||
var err error
|
var err error
|
||||||
if conversationType == "internal" {
|
if conversationType == "internal" {
|
||||||
@@ -187,9 +211,9 @@ func (cc *ConversationController) ListConversations(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "内部对话列表需要 user_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
conversations, err = cc.conversationService.ListInternalConversations(userID)
|
conversations, err = cc.conversationService.ListInternalConversations(userID, status)
|
||||||
} else {
|
} else {
|
||||||
conversations, err = cc.conversationService.ListConversations(userID)
|
conversations, err = cc.conversationService.ListConversations(userID, status)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "查询对话列表失败"})
|
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": "搜索关键词不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "搜索关键词不能为空"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
status := c.DefaultQuery("status", "open")
|
||||||
|
|
||||||
// 从查询参数获取 user_id(可选,用于检查参与状态)
|
// 从查询参数获取 user_id(可选,用于检查参与状态)
|
||||||
var userID uint
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "搜索失败"})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -74,6 +74,46 @@ func (r *ConversationRepository) ListActive() ([]models.Conversation, error) {
|
|||||||
return conversations, nil
|
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 批量查询会话。
|
// ListByIDs 根据多个 ID 批量查询会话。
|
||||||
func (r *ConversationRepository) ListByIDs(ids []uint) ([]models.Conversation, error) {
|
func (r *ConversationRepository) ListByIDs(ids []uint) ([]models.Conversation, error) {
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func RegisterRoutes(r *gin.Engine, controllers ControllerSet, wsHandler gin.Hand
|
|||||||
routes.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试)
|
routes.POST("/conversations/internal", controllers.Conversation.InitInternalConversation) // 创建内部对话(知识库测试)
|
||||||
routes.GET("/conversations", controllers.Conversation.ListConversations)
|
routes.GET("/conversations", controllers.Conversation.ListConversations)
|
||||||
routes.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
|
routes.GET("/conversations/:id", controllers.Conversation.GetConversationDetail)
|
||||||
|
routes.POST("/conversations/:id/close", controllers.Conversation.CloseConversation)
|
||||||
routes.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
|
routes.PUT("/conversations/:id/contact", controllers.Conversation.UpdateContactInfo)
|
||||||
routes.GET("/conversations/search", controllers.Conversation.SearchConversations)
|
routes.GET("/conversations/search", controllers.Conversation.SearchConversations)
|
||||||
routes.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
|
routes.GET("/conversations/ai-models", controllers.Conversation.GetPublicAIModels) // 获取开放的模型列表(供访客选择)
|
||||||
|
|||||||
@@ -19,6 +19,30 @@ type ConversationService struct {
|
|||||||
systemLogSvc *SystemLogService // 可选,结构化日志
|
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 实例。
|
// NewConversationService 创建 ConversationService 实例。
|
||||||
func NewConversationService(
|
func NewConversationService(
|
||||||
conversations *repository.ConversationRepository,
|
conversations *repository.ConversationRepository,
|
||||||
@@ -349,8 +373,12 @@ func (s *ConversationService) buildSummary(conv models.Conversation, userID uint
|
|||||||
// 1. 默认不显示 ChatMode == "ai" 的对话
|
// 1. 默认不显示 ChatMode == "ai" 的对话
|
||||||
// 2. 如果 userID > 0 且该用户的 ReceiveAIConversations == false,则不显示 AI 对话
|
// 2. 如果 userID > 0 且该用户的 ReceiveAIConversations == false,则不显示 AI 对话
|
||||||
// 3. 只显示 ChatMode == "human" 且存在访客消息的对话(访客切换到人工并发送消息后)
|
// 3. 只显示 ChatMode == "human" 且存在访客消息的对话(访客切换到人工并发送消息后)
|
||||||
func (s *ConversationService) ListConversations(userID uint) ([]ConversationSummary, error) {
|
func (s *ConversationService) ListConversations(userID uint, status string) ([]ConversationSummary, error) {
|
||||||
conversations, err := s.conversations.ListActive()
|
// 默认展示进行中(open);历史使用 status=closed
|
||||||
|
if status == "" {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
conversations, err := s.conversations.ListByTypeAndStatus("visitor", status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -425,7 +453,7 @@ func (s *ConversationService) GetConversationDetail(id uint, userID uint) (*Conv
|
|||||||
|
|
||||||
// SearchConversations 根据关键字检索会话摘要。
|
// SearchConversations 根据关键字检索会话摘要。
|
||||||
// userID: 当前登录的客服ID(可选,用于检查参与状态)
|
// 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 + "%"
|
pattern := "%" + query + "%"
|
||||||
|
|
||||||
idSet := map[uint]struct{}{}
|
idSet := map[uint]struct{}{}
|
||||||
@@ -462,6 +490,9 @@ func (s *ConversationService) SearchConversations(query string, userID uint) ([]
|
|||||||
|
|
||||||
result := make([]ConversationSummary, 0, len(conversations))
|
result := make([]ConversationSummary, 0, len(conversations))
|
||||||
for _, conv := range conversations {
|
for _, conv := range conversations {
|
||||||
|
if status != "" && status != "all" && conv.Status != status {
|
||||||
|
continue
|
||||||
|
}
|
||||||
summary, err := s.buildSummary(conv, userID)
|
summary, err := s.buildSummary(conv, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -525,11 +556,14 @@ func (s *ConversationService) InitInternalConversation(agentID uint) (*InitConve
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。
|
// ListInternalConversations 返回当前客服的全部内部对话(知识库测试用)。
|
||||||
func (s *ConversationService) ListInternalConversations(agentID uint) ([]ConversationSummary, error) {
|
func (s *ConversationService) ListInternalConversations(agentID uint, status string) ([]ConversationSummary, error) {
|
||||||
if agentID == 0 {
|
if agentID == 0 {
|
||||||
return []ConversationSummary{}, nil
|
return []ConversationSummary{}, nil
|
||||||
}
|
}
|
||||||
conversations, err := s.conversations.ListActiveInternalByAgentID(agentID)
|
if status == "" {
|
||||||
|
status = "open"
|
||||||
|
}
|
||||||
|
conversations, err := s.conversations.ListInternalByAgentIDAndStatus(agentID, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,8 +47,17 @@ func (s *MessageService) CreateMessage(input CreateMessageInput) (*models.Messag
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B 方案:会话关闭后,如访客再次发消息则自动 reopen
|
||||||
if conv.Status == "closed" {
|
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 {
|
if input.SenderIsAgent && input.SenderID == 0 {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ interface ChatHeaderProps {
|
|||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
onMarkAllRead: () => void;
|
onMarkAllRead: () => void;
|
||||||
|
onCloseConversation?: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
includeAIMessages?: boolean;
|
includeAIMessages?: boolean;
|
||||||
onToggleAIMessages?: () => void;
|
onToggleAIMessages?: () => void;
|
||||||
@@ -22,6 +23,7 @@ export function ChatHeader({
|
|||||||
lastSeenAt,
|
lastSeenAt,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
onMarkAllRead,
|
onMarkAllRead,
|
||||||
|
onCloseConversation,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
includeAIMessages = false,
|
includeAIMessages = false,
|
||||||
onToggleAIMessages,
|
onToggleAIMessages,
|
||||||
@@ -100,9 +102,9 @@ export function ChatHeader({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
title="标记全部已读"
|
title="关闭会话"
|
||||||
onClick={onMarkAllRead}
|
onClick={onCloseConversation}
|
||||||
disabled={unreadCount === 0}
|
disabled={!onCloseConversation}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-5 h-5"
|
className="w-5 h-5"
|
||||||
@@ -114,7 +116,7 @@ export function ChatHeader({
|
|||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M5 13l4 4L19 7"
|
d="M6 6l12 12M18 6L6 18"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import { ConversationList } from "./ConversationList";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
type ConversationStatus = "open" | "closed";
|
||||||
|
|
||||||
interface ConversationSidebarProps {
|
interface ConversationSidebarProps {
|
||||||
conversations: ConversationSummary[];
|
conversations: ConversationSummary[];
|
||||||
selectedConversationId: number | null;
|
selectedConversationId: number | null;
|
||||||
@@ -15,6 +17,8 @@ interface ConversationSidebarProps {
|
|||||||
onSelectConversation: (id: number) => void;
|
onSelectConversation: (id: number) => void;
|
||||||
filter: ConversationFilter;
|
filter: ConversationFilter;
|
||||||
onFilterChange: (filter: ConversationFilter) => void;
|
onFilterChange: (filter: ConversationFilter) => void;
|
||||||
|
status?: ConversationStatus;
|
||||||
|
onStatusChange?: (status: ConversationStatus) => void;
|
||||||
/** 内部对话(知识库测试)模式:显示「新建内部对话」按钮,隐藏筛选 */
|
/** 内部对话(知识库测试)模式:显示「新建内部对话」按钮,隐藏筛选 */
|
||||||
mode?: "visitor" | "internal";
|
mode?: "visitor" | "internal";
|
||||||
onNewClick?: () => void;
|
onNewClick?: () => void;
|
||||||
@@ -28,6 +32,8 @@ export function ConversationSidebar({
|
|||||||
onSelectConversation,
|
onSelectConversation,
|
||||||
filter,
|
filter,
|
||||||
onFilterChange,
|
onFilterChange,
|
||||||
|
status = "open",
|
||||||
|
onStatusChange,
|
||||||
mode = "visitor",
|
mode = "visitor",
|
||||||
onNewClick,
|
onNewClick,
|
||||||
}: ConversationSidebarProps) {
|
}: ConversationSidebarProps) {
|
||||||
@@ -44,7 +50,33 @@ export function ConversationSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="flex-shrink-0 px-2 min-w-0">
|
||||||
<ConversationSearch value={searchQuery} onChange={onSearchChange} />
|
<ConversationSearch value={searchQuery} onChange={onSearchChange} />
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useAuth } from "@/features/agent/hooks/useAuth";
|
|||||||
import { useConversations } from "@/features/agent/hooks/useConversations";
|
import { useConversations } from "@/features/agent/hooks/useConversations";
|
||||||
import { useMessages } from "@/features/agent/hooks/useMessages";
|
import { useMessages } from "@/features/agent/hooks/useMessages";
|
||||||
import { initInternalConversation } from "@/features/agent/services/conversationApi";
|
import { initInternalConversation } from "@/features/agent/services/conversationApi";
|
||||||
|
import { closeConversation } from "@/features/agent/services/conversationApi";
|
||||||
import { toast } from "@/hooks/useToast";
|
import { toast } from "@/hooks/useToast";
|
||||||
import { useProfile } from "@/features/agent/hooks/useProfile";
|
import { useProfile } from "@/features/agent/hooks/useProfile";
|
||||||
import { Profile } from "@/features/agent/types";
|
import { Profile } from "@/features/agent/types";
|
||||||
@@ -86,6 +87,7 @@ export function DashboardShell() {
|
|||||||
|
|
||||||
// 会话过滤状态
|
// 会话过滤状态
|
||||||
const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
|
const [conversationFilter, setConversationFilter] = useState<"all" | "mine" | "others">("all");
|
||||||
|
const [conversationStatus, setConversationStatus] = useState<"open" | "closed">("open");
|
||||||
|
|
||||||
// 声音通知开关(客服端)
|
// 声音通知开关(客服端)
|
||||||
const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(false);
|
const { enabled: soundEnabled, toggle: toggleSound } = useSoundNotification(false);
|
||||||
@@ -110,6 +112,7 @@ export function DashboardShell() {
|
|||||||
agentId: agent?.id ?? null,
|
agentId: agent?.id ?? null,
|
||||||
filter: conversationFilter,
|
filter: conversationFilter,
|
||||||
listType: isInternalChat ? "internal" : "visitor",
|
listType: isInternalChat ? "internal" : "visitor",
|
||||||
|
status: conversationStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算总未读消息数
|
// 计算总未读消息数
|
||||||
@@ -190,6 +193,19 @@ export function DashboardShell() {
|
|||||||
}
|
}
|
||||||
}, [markMessagesAsRead, selectedConversationId]);
|
}, [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(() => {
|
const handleRefreshChat = useCallback(() => {
|
||||||
if (!selectedConversationId) return;
|
if (!selectedConversationId) return;
|
||||||
@@ -274,6 +290,12 @@ export function DashboardShell() {
|
|||||||
onSelectConversation={handleConversationSelect}
|
onSelectConversation={handleConversationSelect}
|
||||||
filter={conversationFilter}
|
filter={conversationFilter}
|
||||||
onFilterChange={setConversationFilter}
|
onFilterChange={setConversationFilter}
|
||||||
|
status={conversationStatus}
|
||||||
|
onStatusChange={(s) => {
|
||||||
|
setConversationStatus(s);
|
||||||
|
// 切换状态时清空搜索,更直观
|
||||||
|
setSearchQuery("");
|
||||||
|
}}
|
||||||
mode={isInternalChat ? "internal" : "visitor"}
|
mode={isInternalChat ? "internal" : "visitor"}
|
||||||
onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
|
onNewClick={isInternalChat ? handleNewInternalConversation : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -301,6 +323,7 @@ export function DashboardShell() {
|
|||||||
lastSeenAt={conversationDetail?.last_seen_at}
|
lastSeenAt={conversationDetail?.last_seen_at}
|
||||||
unreadCount={selectedUnreadCount}
|
unreadCount={selectedUnreadCount}
|
||||||
onMarkAllRead={handleMarkAllRead}
|
onMarkAllRead={handleMarkAllRead}
|
||||||
|
onCloseConversation={handleCloseConversation}
|
||||||
onRefresh={handleRefreshChat}
|
onRefresh={handleRefreshChat}
|
||||||
includeAIMessages={includeAIMessages}
|
includeAIMessages={includeAIMessages}
|
||||||
onToggleAIMessages={toggleAIMessages}
|
onToggleAIMessages={toggleAIMessages}
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export function Footer() {
|
|||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href="/agent/login"
|
href="/agent/login"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
客服登录
|
客服登录
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="/agent/login"
|
href="/agent/login"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
className="text-[15px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
客服登录
|
客服登录
|
||||||
|
|||||||
@@ -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"
|
className="rounded-xl border-border/80 px-8 py-6 text-[15px] bg-background/60 backdrop-blur-sm"
|
||||||
asChild
|
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>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
searchConversations,
|
searchConversations,
|
||||||
} from "../../agent/services/conversationApi";
|
} from "../../agent/services/conversationApi";
|
||||||
import type { ConversationListType } 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 { ConversationSummary, VisitorStatusUpdatePayload } from "../../agent/types";
|
||||||
import { useWebSocket } from "./useWebSocket";
|
import { useWebSocket } from "./useWebSocket";
|
||||||
import { WSMessage } from "@/lib/websocket";
|
import { WSMessage } from "@/lib/websocket";
|
||||||
@@ -25,10 +26,12 @@ interface UseConversationsOptions {
|
|||||||
filter?: ConversationFilter;
|
filter?: ConversationFilter;
|
||||||
/** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
|
/** 内部对话(知识库测试)时传 "internal",默认访客对话 "visitor" */
|
||||||
listType?: ConversationListType;
|
listType?: ConversationListType;
|
||||||
|
/** 会话状态:open(进行中)/ closed(历史) */
|
||||||
|
status?: ConversationStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useConversations(options?: UseConversationsOptions) {
|
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 [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||||
const [filteredConversations, setFilteredConversations] = useState<
|
const [filteredConversations, setFilteredConversations] = useState<
|
||||||
ConversationSummary[]
|
ConversationSummary[]
|
||||||
@@ -75,7 +78,10 @@ export function useConversations(options?: UseConversationsOptions) {
|
|||||||
setSelectedConversationId(null);
|
setSelectedConversationId(null);
|
||||||
return;
|
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);
|
setConversations(data);
|
||||||
const filtered = listType === "internal" ? data : applyFilter(data);
|
const filtered = listType === "internal" ? data : applyFilter(data);
|
||||||
if (!searchRef.current.trim()) {
|
if (!searchRef.current.trim()) {
|
||||||
@@ -93,7 +99,7 @@ export function useConversations(options?: UseConversationsOptions) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setIsInitialLoad(false);
|
setIsInitialLoad(false);
|
||||||
}
|
}
|
||||||
}, [applyFilter, agentId, filter, listType]);
|
}, [applyFilter, agentId, filter, listType, status]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
@@ -127,7 +133,7 @@ export function useConversations(options?: UseConversationsOptions) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await searchConversations(query, agentId ?? undefined);
|
const data = await searchConversations(query, agentId ?? undefined, { status });
|
||||||
const filtered = applyFilter(data);
|
const filtered = applyFilter(data);
|
||||||
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
setFilteredConversations(sortByUpdatedAtDesc(filtered));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -139,7 +145,7 @@ export function useConversations(options?: UseConversationsOptions) {
|
|||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
return () => clearTimeout(handler);
|
return () => clearTimeout(handler);
|
||||||
}, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType]);
|
}, [searchQuery, conversations, isInitialLoad, applyFilter, agentId, listType, status]);
|
||||||
|
|
||||||
const selectConversation = useCallback((conversationId: number | null) => {
|
const selectConversation = useCallback((conversationId: number | null) => {
|
||||||
setSelectedConversationId((prev) =>
|
setSelectedConversationId((prev) =>
|
||||||
|
|||||||
@@ -5,14 +5,16 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
export type ConversationListType = "visitor" | "internal";
|
export type ConversationListType = "visitor" | "internal";
|
||||||
|
export type ConversationStatus = "open" | "closed";
|
||||||
|
|
||||||
export async function fetchConversations(
|
export async function fetchConversations(
|
||||||
userId?: number,
|
userId?: number,
|
||||||
opts?: { type?: ConversationListType }
|
opts?: { type?: ConversationListType; status?: ConversationStatus }
|
||||||
): Promise<ConversationSummary[]> {
|
): Promise<ConversationSummary[]> {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (userId) params.set("user_id", String(userId));
|
if (userId) params.set("user_id", String(userId));
|
||||||
if (opts?.type) params.set("type", opts.type);
|
if (opts?.type) params.set("type", opts.type);
|
||||||
|
if (opts?.status) params.set("status", opts.status);
|
||||||
const url = `${apiUrl("/conversations")}?${params.toString()}`;
|
const url = `${apiUrl("/conversations")}?${params.toString()}`;
|
||||||
const res = await fetch(url, { cache: "no-store", headers: getAgentHeaders() });
|
const res = await fetch(url, { cache: "no-store", headers: getAgentHeaders() });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -45,11 +47,13 @@ export async function initInternalConversation(userId: number): Promise<{ conver
|
|||||||
|
|
||||||
export async function searchConversations(
|
export async function searchConversations(
|
||||||
query: string,
|
query: string,
|
||||||
userId?: number
|
userId?: number,
|
||||||
|
opts?: { status?: ConversationStatus }
|
||||||
): Promise<ConversationSummary[]> {
|
): Promise<ConversationSummary[]> {
|
||||||
|
const status = opts?.status ?? "open";
|
||||||
const url = userId
|
const url = userId
|
||||||
? `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&user_id=${userId}`
|
? `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&user_id=${userId}&status=${status}`
|
||||||
: `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}`;
|
: `${apiUrl("/conversations/search")}?q=${encodeURIComponent(query)}&status=${status}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
headers: getAgentHeaders(),
|
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 {
|
export interface UpdateConversationContactPayload {
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user