优化咨询客服
This commit is contained in:
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
type ChatConversation struct {
|
type ChatConversation struct {
|
||||||
ID uint64 `gorm:"primaryKey" json:"id"`
|
ID uint64 `gorm:"primaryKey" json:"id"`
|
||||||
OrderID uint64 `gorm:"not null;uniqueIndex" json:"order_id"`
|
OrderID *uint64 `gorm:"uniqueIndex" json:"order_id"`
|
||||||
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
Type string `gorm:"size:32;not null;default:'order_group'" json:"type"`
|
||||||
Title string `gorm:"size:128;not null" json:"title"`
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
Status string `gorm:"size:32;not null;default:'active'" json:"status"`
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ type Principal struct {
|
|||||||
|
|
||||||
type ConversationDTO struct {
|
type ConversationDTO struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
OrderID uint64 `json:"order_id"`
|
OrderID *uint64 `json:"order_id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ func (h *Handler) OrderConversation(c *gin.Context) {
|
|||||||
response.OK(c, item)
|
response.OK(c, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) EnsureSupportConversation(c *gin.Context) {
|
||||||
|
userID, ok := currentUserID(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "缺少用户上下文")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := h.service.EnsureSupportConversation(userID)
|
||||||
|
if err != nil {
|
||||||
|
writeChatError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, item)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) Messages(c *gin.Context) {
|
func (h *Handler) Messages(c *gin.Context) {
|
||||||
userID, ok := currentUserID(c)
|
userID, ok := currentUserID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func EnsureOrderConversation(tx *gorm.DB, order model.RentalOrder) (*model.ChatC
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
conversation := model.ChatConversation{
|
conversation := model.ChatConversation{
|
||||||
OrderID: order.ID,
|
OrderID: &order.ID,
|
||||||
Type: "order_group",
|
Type: "order_group",
|
||||||
Title: orderConversationTitle(order),
|
Title: orderConversationTitle(order),
|
||||||
Status: "active",
|
Status: "active",
|
||||||
@@ -182,6 +182,89 @@ func (r *Repository) FindOrderConversation(userID uint64, orderID uint64) (*Conv
|
|||||||
return &dto, nil
|
return &dto, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) EnsureSupportConversation(userID uint64) (*ConversationDTO, error) {
|
||||||
|
var conversationID uint64
|
||||||
|
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var existing model.ChatConversation
|
||||||
|
err := tx.Table("chat_conversations AS c").
|
||||||
|
Select("c.*").
|
||||||
|
Joins("JOIN chat_participants AS cp ON cp.conversation_id = c.id").
|
||||||
|
Where("c.type = ? AND cp.participant_type = ? AND cp.participant_id = ?", "general_support", "user", userID).
|
||||||
|
Order("c.id ASC").
|
||||||
|
First(&existing).Error
|
||||||
|
if err == nil {
|
||||||
|
conversationID = existing.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
conversation := model.ChatConversation{
|
||||||
|
Type: "general_support",
|
||||||
|
Title: "平台客服",
|
||||||
|
Status: "active",
|
||||||
|
}
|
||||||
|
if err := tx.Create(&conversation).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
participants := []model.ChatParticipant{
|
||||||
|
{
|
||||||
|
ConversationID: conversation.ID,
|
||||||
|
ParticipantType: "user",
|
||||||
|
ParticipantID: userID,
|
||||||
|
Role: "customer",
|
||||||
|
JoinedAt: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if supportID := defaultSupportAdminID(tx); supportID > 0 {
|
||||||
|
participants = append(participants, model.ChatParticipant{
|
||||||
|
ConversationID: conversation.ID,
|
||||||
|
ParticipantType: "admin",
|
||||||
|
ParticipantID: supportID,
|
||||||
|
Role: "support",
|
||||||
|
JoinedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, participant := range participants {
|
||||||
|
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&participant).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message := model.ChatMessage{
|
||||||
|
ConversationID: conversation.ID,
|
||||||
|
SenderType: "system",
|
||||||
|
SenderRole: "system",
|
||||||
|
ContentType: "system",
|
||||||
|
Content: "您好,客服会尽快回复,请直接描述您遇到的问题。",
|
||||||
|
AttachmentURLS: emptyJSONList(),
|
||||||
|
}
|
||||||
|
if err := tx.Create(&message).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conversation.LastMessageID = &message.ID
|
||||||
|
conversation.LastMessagePreview = truncatePreview(message.Content)
|
||||||
|
conversation.LastMessageAt = &message.CreatedAt
|
||||||
|
if err := tx.Save(&conversation).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
conversationID = conversation.ID
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item, err := r.FindConversation(Principal{Type: "user", ID: userID}, conversationID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.NotifyNewConversation(conversationID)
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
|
func (r *Repository) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
page, pageSize = normalizePagination(page, pageSize)
|
page, pageSize = normalizePagination(page, pageSize)
|
||||||
if _, err := r.findParticipant(r.db, principal, conversationID, false); err != nil {
|
if _, err := r.findParticipant(r.db, principal, conversationID, false); err != nil {
|
||||||
@@ -473,7 +556,7 @@ func (r *Repository) adminNames(ids []uint64) (map[uint64]string, error) {
|
|||||||
|
|
||||||
type conversationRow struct {
|
type conversationRow struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
OrderID uint64
|
OrderID *uint64
|
||||||
Type string
|
Type string
|
||||||
Title string
|
Title string
|
||||||
Status string
|
Status string
|
||||||
|
|||||||
@@ -41,6 +41,16 @@ func (s *Service) FindOrderConversation(userID uint64, orderID uint64) (*Convers
|
|||||||
return s.repo.FindOrderConversation(userID, orderID)
|
return s.repo.FindOrderConversation(userID, orderID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureSupportConversation(userID uint64) (*ConversationDTO, error) {
|
||||||
|
if s.repo == nil {
|
||||||
|
return nil, ErrDependencyUnavailable
|
||||||
|
}
|
||||||
|
if userID == 0 {
|
||||||
|
return nil, ErrPermissionDenied
|
||||||
|
}
|
||||||
|
return s.repo.EnsureSupportConversation(userID)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
|
func (s *Service) Messages(principal Principal, conversationID uint64, page, pageSize int) (*PaginatedResult, error) {
|
||||||
if s.repo == nil {
|
if s.repo == nil {
|
||||||
return nil, ErrDependencyUnavailable
|
return nil, ErrDependencyUnavailable
|
||||||
|
|||||||
@@ -247,6 +247,7 @@ func New(cfg config.Config, deps Dependencies, logger *zap.Logger) *gin.Engine {
|
|||||||
if chatHubHandler != nil {
|
if chatHubHandler != nil {
|
||||||
chatRoutes.GET("/events", chatHubHandler.UserEvents)
|
chatRoutes.GET("/events", chatHubHandler.UserEvents)
|
||||||
}
|
}
|
||||||
|
chatRoutes.POST("/support", chatHandler.EnsureSupportConversation)
|
||||||
chatRoutes.GET("", chatHandler.List)
|
chatRoutes.GET("", chatHandler.List)
|
||||||
chatRoutes.GET("/:id", chatHandler.Detail)
|
chatRoutes.GET("/:id", chatHandler.Detail)
|
||||||
chatRoutes.GET("/:id/messages", chatHandler.Messages)
|
chatRoutes.GET("/:id/messages", chatHandler.Messages)
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ CREATE TABLE system_configs (
|
|||||||
|
|
||||||
CREATE TABLE chat_conversations (
|
CREATE TABLE chat_conversations (
|
||||||
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
order_id BIGINT UNSIGNED NOT NULL,
|
order_id BIGINT UNSIGNED NULL,
|
||||||
type VARCHAR(32) NOT NULL DEFAULT 'order_group',
|
type VARCHAR(32) NOT NULL DEFAULT 'order_group',
|
||||||
title VARCHAR(128) NOT NULL,
|
title VARCHAR(128) NOT NULL,
|
||||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE chat_conversations
|
||||||
|
MODIFY order_id BIGINT UNSIGNED NULL;
|
||||||
@@ -6,7 +6,7 @@ export interface ChatParticipant {
|
|||||||
conversation_id: number
|
conversation_id: number
|
||||||
participant_type: 'user' | 'admin'
|
participant_type: 'user' | 'admin'
|
||||||
participant_id: number
|
participant_id: number
|
||||||
role: 'renter' | 'owner' | 'support'
|
role: 'renter' | 'owner' | 'support' | 'customer'
|
||||||
remark: string
|
remark: string
|
||||||
display_name: string
|
display_name: string
|
||||||
avatar_url: string
|
avatar_url: string
|
||||||
@@ -16,11 +16,11 @@ export interface ChatParticipant {
|
|||||||
|
|
||||||
export interface ChatConversation {
|
export interface ChatConversation {
|
||||||
id: number
|
id: number
|
||||||
order_id: number
|
order_id: number | null
|
||||||
type: string
|
type: string
|
||||||
title: string
|
title: string
|
||||||
status: string
|
status: string
|
||||||
role: 'renter' | 'owner' | 'support'
|
role: 'renter' | 'owner' | 'support' | 'customer'
|
||||||
participants?: ChatParticipant[]
|
participants?: ChatParticipant[]
|
||||||
last_message_id?: number
|
last_message_id?: number
|
||||||
last_message_preview: string
|
last_message_preview: string
|
||||||
@@ -35,7 +35,7 @@ export interface ChatMessage {
|
|||||||
conversation_id: number
|
conversation_id: number
|
||||||
sender_type: 'user' | 'admin' | 'system'
|
sender_type: 'user' | 'admin' | 'system'
|
||||||
sender_id: number
|
sender_id: number
|
||||||
sender_role: 'renter' | 'owner' | 'support' | 'system'
|
sender_role: 'renter' | 'owner' | 'support' | 'customer' | 'system'
|
||||||
sender_name: string
|
sender_name: string
|
||||||
sender_avatar: string
|
sender_avatar: string
|
||||||
is_self: boolean
|
is_self: boolean
|
||||||
@@ -62,6 +62,11 @@ export async function fetchOrderChat(orderId: number) {
|
|||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function ensureSupportChat() {
|
||||||
|
const { data } = await apiClient.post<ApiResponse<ChatConversation>>('/chats/support')
|
||||||
|
return data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
|
export async function fetchChatMessages(id: number, page = 1, pageSize = 100) {
|
||||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
|
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
|
||||||
params: { page, page_size: pageSize },
|
params: { page, page_size: pageSize },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ChatDotRound,
|
ChatDotRound,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
UserFilled,
|
UserFilled,
|
||||||
Wallet,
|
Wallet,
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
|
import { ensureSupportChat } from "@/api/chats";
|
||||||
import { useSessionStore } from "@/stores/session";
|
import { useSessionStore } from "@/stores/session";
|
||||||
|
|
||||||
const session = useSessionStore();
|
const session = useSessionStore();
|
||||||
@@ -26,12 +28,30 @@ const navItems = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const showUserDropdown = ref(false);
|
const showUserDropdown = ref(false);
|
||||||
|
const supportLoading = ref(false);
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
session.logout();
|
session.logout();
|
||||||
showUserDropdown.value = false;
|
showUserDropdown.value = false;
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSupportClick() {
|
||||||
|
if (!session.isLoggedIn) {
|
||||||
|
router.push({ path: "/login", query: { redirect: router.currentRoute.value.fullPath } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (supportLoading.value) return;
|
||||||
|
supportLoading.value = true;
|
||||||
|
try {
|
||||||
|
const chat = await ensureSupportChat();
|
||||||
|
router.push(`/messages/${chat.id}`);
|
||||||
|
} catch {
|
||||||
|
ElMessage.error("联系客服失败,请稍后重试");
|
||||||
|
} finally {
|
||||||
|
supportLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -63,10 +83,10 @@ function handleLogout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pc-user-actions">
|
<div class="pc-user-actions">
|
||||||
<RouterLink class="pc-icon-link service-link" to="/notifications" title="联系客服">
|
<button class="pc-icon-link service-link" type="button" title="联系客服" @click="handleSupportClick">
|
||||||
<el-icon><Service /></el-icon>
|
<el-icon><Service /></el-icon>
|
||||||
<span>客服</span>
|
<span>{{ supportLoading ? "接入中" : "客服" }}</span>
|
||||||
</RouterLink>
|
</button>
|
||||||
|
|
||||||
<RouterLink class="pc-post-link" :to="session.isLoggedIn ? '/seller/listings/create' : '/login'">
|
<RouterLink class="pc-post-link" :to="session.isLoggedIn ? '/seller/listings/create' : '/login'">
|
||||||
<el-icon><CirclePlus /></el-icon>
|
<el-icon><CirclePlus /></el-icon>
|
||||||
@@ -310,6 +330,7 @@ function handleLogout() {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const listRef = ref<HTMLElement | null>(null)
|
|||||||
const conversationID = computed(() => Number(route.params.id || 0))
|
const conversationID = computed(() => Number(route.params.id || 0))
|
||||||
const memberText = computed(() => {
|
const memberText = computed(() => {
|
||||||
const participants = conversation.value?.participants || []
|
const participants = conversation.value?.participants || []
|
||||||
if (participants.length === 0) return '订单群聊'
|
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||||
return participants.map(item => roleLabel(item.role)).join(' · ')
|
return participants.map(item => roleLabel(item.role)).join(' · ')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -138,6 +138,7 @@ function roleLabel(role: string) {
|
|||||||
renter: '租客',
|
renter: '租客',
|
||||||
owner: '号主',
|
owner: '号主',
|
||||||
support: '客服',
|
support: '客服',
|
||||||
|
customer: '咨询',
|
||||||
system: '系统',
|
system: '系统',
|
||||||
}
|
}
|
||||||
return map[role] || '成员'
|
return map[role] || '成员'
|
||||||
@@ -166,7 +167,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<span>返回</span>
|
<span>返回</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="chat-title">
|
<div class="chat-title">
|
||||||
<h2>{{ conversation?.title || '订单群聊' }}</h2>
|
<h2>{{ conversation?.title || '客服会话' }}</h2>
|
||||||
<p>{{ memberText }}</p>
|
<p>{{ memberText }}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button
|
<el-button
|
||||||
|
|||||||
@@ -51,12 +51,12 @@ function openConversation(item: ChatConversation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function roleLabel(role: string) {
|
function roleLabel(role: string) {
|
||||||
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服' }
|
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服', customer: '咨询' }
|
||||||
return map[role] || '成员'
|
return map[role] || '成员'
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewText(item: ChatConversation) {
|
function previewText(item: ChatConversation) {
|
||||||
return item.last_message_preview || '订单群聊已创建'
|
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ function previewText(item: ChatConversation) {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<p class="eyebrow">Messages</p>
|
<p class="eyebrow">Messages</p>
|
||||||
<h1>消息</h1>
|
<h1>消息</h1>
|
||||||
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊消息,与租客、号主和客服沟通。' }}</p>
|
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
|
<el-button :icon="Refresh" :loading="loading" @click="loadChats(true)">刷新</el-button>
|
||||||
@@ -77,7 +77,7 @@ function previewText(item: ChatConversation) {
|
|||||||
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
<div v-if="loading && conversations.length === 0" class="message-loading" v-loading="loading" />
|
||||||
|
|
||||||
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
<div v-else-if="!loading && conversations.length === 0" class="empty-panel">
|
||||||
<el-empty description="暂无订单群聊">
|
<el-empty description="暂无会话">
|
||||||
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
|
<el-button type="primary" :icon="Tickets" @click="router.push('/orders')">查看订单</el-button>
|
||||||
</el-empty>
|
</el-empty>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +101,7 @@ function previewText(item: ChatConversation) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="conversation-meta">
|
<div class="conversation-meta">
|
||||||
<span class="role-chip">{{ roleLabel(item.role) }}</span>
|
<span class="role-chip">{{ roleLabel(item.role) }}</span>
|
||||||
<span class="order-id">订单 #{{ item.order_id }}</span>
|
<span class="order-id">{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="conversation-preview">{{ previewText(item) }}</p>
|
<p class="conversation-preview">{{ previewText(item) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ function roleLabel(role: string) {
|
|||||||
renter: '租客',
|
renter: '租客',
|
||||||
owner: '号主',
|
owner: '号主',
|
||||||
support: '客服',
|
support: '客服',
|
||||||
|
customer: '咨询',
|
||||||
system: '系统',
|
system: '系统',
|
||||||
}
|
}
|
||||||
return map[role] || '成员'
|
return map[role] || '成员'
|
||||||
@@ -227,8 +228,8 @@ function getSupportName(item: ChatConversation) {
|
|||||||
<section class="admin-page">
|
<section class="admin-page">
|
||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h1>客服群聊</h1>
|
<h1>客服会话</h1>
|
||||||
<p>处理订单三方沟通</p>
|
<p>处理订单三方沟通和平台咨询</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head-right">
|
<div class="head-right">
|
||||||
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||||||
@@ -257,7 +258,7 @@ function getSupportName(item: ChatConversation) {
|
|||||||
<strong>{{ getConversationTitle(item) }}</strong>
|
<strong>{{ getConversationTitle(item) }}</strong>
|
||||||
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
|
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
|
||||||
<div class="row-meta">
|
<div class="row-meta">
|
||||||
<span class="support-name">{{ getSupportName(item) }}</span>
|
<span class="support-name">{{ getSupportName(item) }}</span>
|
||||||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||||||
@@ -288,7 +289,7 @@ function getSupportName(item: ChatConversation) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="head-actions">
|
<div class="head-actions">
|
||||||
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
||||||
<RouterLink :to="`/admin/orders/${active.order_id}`">
|
<RouterLink v-if="active.order_id" :to="`/admin/orders/${active.order_id}`">
|
||||||
<el-button size="small">查看订单</el-button>
|
<el-button size="small">查看订单</el-button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const listRef = ref<HTMLElement | null>(null)
|
|||||||
const conversationID = computed(() => Number(route.params.id || 0))
|
const conversationID = computed(() => Number(route.params.id || 0))
|
||||||
const memberText = computed(() => {
|
const memberText = computed(() => {
|
||||||
const participants = conversation.value?.participants || []
|
const participants = conversation.value?.participants || []
|
||||||
if (participants.length === 0) return '订单群聊'
|
if (participants.length === 0) return conversation.value?.type === 'general_support' ? '平台客服' : '订单群聊'
|
||||||
return participants.map(item => roleLabel(item.role)).join(' · ')
|
return participants.map(item => roleLabel(item.role)).join(' · ')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -123,6 +123,7 @@ function roleLabel(role: string) {
|
|||||||
renter: '租客',
|
renter: '租客',
|
||||||
owner: '号主',
|
owner: '号主',
|
||||||
support: '客服',
|
support: '客服',
|
||||||
|
customer: '咨询',
|
||||||
system: '系统',
|
system: '系统',
|
||||||
}
|
}
|
||||||
return map[role] || '成员'
|
return map[role] || '成员'
|
||||||
@@ -141,12 +142,13 @@ function senderLabel(message: ChatMessage) {
|
|||||||
<van-icon name="arrow-left" :size="20" />
|
<van-icon name="arrow-left" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
<div class="chat-title">
|
<div class="chat-title">
|
||||||
<h1>{{ conversation?.title || '订单群聊' }}</h1>
|
<h1>{{ conversation?.title || '客服会话' }}</h1>
|
||||||
<p>{{ memberText }}</p>
|
<p>{{ memberText }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="icon-btn" type="button" @click="conversation && router.push(`/m/orders/${conversation.order_id}`)">
|
<button v-if="conversation?.order_id" class="icon-btn" type="button" @click="router.push(`/m/orders/${conversation.order_id}`)">
|
||||||
<van-icon name="orders-o" :size="20" />
|
<van-icon name="orders-o" :size="20" />
|
||||||
</button>
|
</button>
|
||||||
|
<span v-else class="icon-placeholder"></span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section ref="listRef" class="message-list" :class="{ loading }">
|
<section ref="listRef" class="message-list" :class="{ loading }">
|
||||||
@@ -216,6 +218,12 @@ function senderLabel(message: ChatMessage) {
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-placeholder {
|
||||||
|
display: block;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-title {
|
.chat-title {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { RouterLink } from "vue-router";
|
import { RouterLink, useRouter } from "vue-router";
|
||||||
import { showToast } from "vant";
|
import { showToast } from "vant";
|
||||||
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
import MobileBottomNav from "@/components/MobileBottomNav.vue";
|
||||||
|
|
||||||
|
import { ensureSupportChat } from "@/api/chats";
|
||||||
import {
|
import {
|
||||||
emptyListingPublishOptions,
|
emptyListingPublishOptions,
|
||||||
type ListingPublishOptions,
|
type ListingPublishOptions,
|
||||||
@@ -36,7 +37,10 @@ import {
|
|||||||
readAssetNumber,
|
readAssetNumber,
|
||||||
readAssetString,
|
readAssetString,
|
||||||
} from "@/utils/listingDisplay";
|
} from "@/utils/listingDisplay";
|
||||||
|
import { useSessionStore } from "@/stores/session";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const session = useSessionStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadFailed = ref(false);
|
const loadFailed = ref(false);
|
||||||
const listings = ref<Listing[]>([]);
|
const listings = ref<Listing[]>([]);
|
||||||
@@ -50,6 +54,7 @@ const refreshing = ref(false);
|
|||||||
const searchValue = ref("");
|
const searchValue = ref("");
|
||||||
const announcements = ref<string[]>(defaultHomeAnnouncements);
|
const announcements = ref<string[]>(defaultHomeAnnouncements);
|
||||||
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
|
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
|
||||||
|
const supportLoading = ref(false);
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
{ key: "comprehensive", label: "综合排序" },
|
{ key: "comprehensive", label: "综合排序" },
|
||||||
@@ -81,6 +86,23 @@ const activeSortLabel = computed(
|
|||||||
"综合排序"
|
"综合排序"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
async function handleSupportClick() {
|
||||||
|
if (!session.isLoggedIn) {
|
||||||
|
router.push({ path: "/m/login", query: { redirect: router.currentRoute.value.fullPath } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (supportLoading.value) return;
|
||||||
|
supportLoading.value = true;
|
||||||
|
try {
|
||||||
|
const chat = await ensureSupportChat();
|
||||||
|
router.push(`/m/chats/${chat.id}`);
|
||||||
|
} catch {
|
||||||
|
showToast({ message: "联系客服失败,请稍后重试", icon: "cross" });
|
||||||
|
} finally {
|
||||||
|
supportLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const serverFilterOptions = computed(() =>
|
const serverFilterOptions = computed(() =>
|
||||||
uniqueOptions(
|
uniqueOptions(
|
||||||
publishOptions.value.server_options
|
publishOptions.value.server_options
|
||||||
@@ -327,7 +349,9 @@ function uniqueOptions(values: string[]) {
|
|||||||
placeholder="搜区服 / 段位"
|
placeholder="搜区服 / 段位"
|
||||||
class="home-search"
|
class="home-search"
|
||||||
/>
|
/>
|
||||||
<button class="mobile-service" type="button">客服</button>
|
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
|
||||||
|
{{ supportLoading ? "接入中" : "客服" }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 防骗提示卡片(不用 van-notice-bar) -->
|
<!-- 防骗提示卡片(不用 van-notice-bar) -->
|
||||||
@@ -498,4 +522,3 @@ function uniqueOptions(values: string[]) {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped src="./MobileHomeView.css"></style>
|
<style scoped src="./MobileHomeView.css"></style>
|
||||||
|
|
||||||
|
|||||||
@@ -62,12 +62,13 @@ function roleLabel(role: string) {
|
|||||||
renter: '租客',
|
renter: '租客',
|
||||||
owner: '号主',
|
owner: '号主',
|
||||||
support: '客服',
|
support: '客服',
|
||||||
|
customer: '咨询',
|
||||||
}
|
}
|
||||||
return map[role] || '成员'
|
return map[role] || '成员'
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewText(item: ChatConversation) {
|
function previewText(item: ChatConversation) {
|
||||||
return item.last_message_preview || '订单群聊已创建'
|
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||||
}
|
}
|
||||||
|
|
||||||
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
|
const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum + item.unread_count, 0))
|
||||||
@@ -93,7 +94,7 @@ const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum
|
|||||||
>
|
>
|
||||||
<van-empty
|
<van-empty
|
||||||
v-if="!loading && conversations.length === 0"
|
v-if="!loading && conversations.length === 0"
|
||||||
description="暂无订单群聊"
|
description="暂无会话"
|
||||||
class="empty-state"
|
class="empty-state"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum
|
|||||||
</div>
|
</div>
|
||||||
<div class="conversation-meta">
|
<div class="conversation-meta">
|
||||||
<span class="role-chip">{{ roleLabel(item.role) }}</span>
|
<span class="role-chip">{{ roleLabel(item.role) }}</span>
|
||||||
<span>订单 #{{ item.order_id }}</span>
|
<span>{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{{ previewText(item) }}</p>
|
<p>{{ previewText(item) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user