mirror of
https://github.com/2930134478/AI-CS.git
synced 2026-06-15 00:44:30 +08:00
新增会话关闭
This commit is contained in:
@@ -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`
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) // 获取开放的模型列表(供访客选择)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
客服登录
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
客服登录
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user