新增会话关闭

This commit is contained in:
537yaha
2026-03-30 19:49:37 +08:00
parent a65e22c81a
commit a60ea15148
14 changed files with 227 additions and 30 deletions
+6 -6
View File
@@ -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
View File
@@ -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 {
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2
View File
@@ -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
View File
@@ -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;