优化咨询客服

This commit is contained in:
yml2213
2026-05-29 10:15:21 +08:00
parent 2966b59c90
commit 2346f2e064
16 changed files with 202 additions and 32 deletions
+9 -4
View File
@@ -6,7 +6,7 @@ export interface ChatParticipant {
conversation_id: number
participant_type: 'user' | 'admin'
participant_id: number
role: 'renter' | 'owner' | 'support'
role: 'renter' | 'owner' | 'support' | 'customer'
remark: string
display_name: string
avatar_url: string
@@ -16,11 +16,11 @@ export interface ChatParticipant {
export interface ChatConversation {
id: number
order_id: number
order_id: number | null
type: string
title: string
status: string
role: 'renter' | 'owner' | 'support'
role: 'renter' | 'owner' | 'support' | 'customer'
participants?: ChatParticipant[]
last_message_id?: number
last_message_preview: string
@@ -35,7 +35,7 @@ export interface ChatMessage {
conversation_id: number
sender_type: 'user' | 'admin' | 'system'
sender_id: number
sender_role: 'renter' | 'owner' | 'support' | 'system'
sender_role: 'renter' | 'owner' | 'support' | 'customer' | 'system'
sender_name: string
sender_avatar: string
is_self: boolean
@@ -62,6 +62,11 @@ export async function fetchOrderChat(orderId: number) {
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) {
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatMessage>>>(`/chats/${id}/messages`, {
params: { page, page_size: pageSize },
+24 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import {
ArrowDown,
ChatDotRound,
@@ -13,6 +14,7 @@ import {
UserFilled,
Wallet,
} from "@element-plus/icons-vue";
import { ensureSupportChat } from "@/api/chats";
import { useSessionStore } from "@/stores/session";
const session = useSessionStore();
@@ -26,12 +28,30 @@ const navItems = [
];
const showUserDropdown = ref(false);
const supportLoading = ref(false);
function handleLogout() {
session.logout();
showUserDropdown.value = false;
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>
<template>
@@ -63,10 +83,10 @@ function handleLogout() {
</div>
<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>
<span>客服</span>
</RouterLink>
<span>{{ supportLoading ? "接入中" : "客服" }}</span>
</button>
<RouterLink class="pc-post-link" :to="session.isLoggedIn ? '/seller/listings/create' : '/login'">
<el-icon><CirclePlus /></el-icon>
@@ -310,6 +330,7 @@ function handleLogout() {
font-size: 14px;
font-weight: 700;
text-decoration: none;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
+3 -2
View File
@@ -28,7 +28,7 @@ const listRef = ref<HTMLElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const memberText = computed(() => {
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(' · ')
})
@@ -138,6 +138,7 @@ function roleLabel(role: string) {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
@@ -166,7 +167,7 @@ function handleKeydown(e: KeyboardEvent) {
<span>返回</span>
</button>
<div class="chat-title">
<h2>{{ conversation?.title || '订单群聊' }}</h2>
<h2>{{ conversation?.title || '客服会话' }}</h2>
<p>{{ memberText }}</p>
</div>
<el-button
+5 -5
View File
@@ -51,12 +51,12 @@ function openConversation(item: ChatConversation) {
}
function roleLabel(role: string) {
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服' }
const map: Record<string, string> = { renter: '租客', owner: '号主', support: '客服', customer: '咨询' }
return map[role] || '成员'
}
function previewText(item: ChatConversation) {
return item.last_message_preview || '订单群聊已创建'
return item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
}
</script>
@@ -66,7 +66,7 @@ function previewText(item: ChatConversation) {
<div class="page-header">
<p class="eyebrow">Messages</p>
<h1>消息</h1>
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊消息,与租客、号主和客服沟通。' }}</p>
<p>{{ unreadTotal > 0 ? `${unreadTotal} 条未读` : '查看订单群聊和平台客服消息。' }}</p>
</div>
<div class="header-actions">
<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-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-empty>
</div>
@@ -101,7 +101,7 @@ function previewText(item: ChatConversation) {
</div>
<div class="conversation-meta">
<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>
<p class="conversation-preview">{{ previewText(item) }}</p>
</div>
+5 -4
View File
@@ -200,6 +200,7 @@ function roleLabel(role: string) {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
@@ -227,8 +228,8 @@ function getSupportName(item: ChatConversation) {
<section class="admin-page">
<div class="page-head">
<div>
<h1>客服群聊</h1>
<p>处理订单三方沟通</p>
<h1>客服会话</h1>
<p>处理订单三方沟通和平台咨询</p>
</div>
<div class="head-right">
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
@@ -257,7 +258,7 @@ function getSupportName(item: ChatConversation) {
<strong>{{ getConversationTitle(item) }}</strong>
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
</div>
<p>{{ item.last_message_preview || '订单群聊已创建' }}</p>
<p>{{ item.last_message_preview || (item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建') }}</p>
<div class="row-meta">
<span class="support-name">{{ getSupportName(item) }}</span>
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
@@ -288,7 +289,7 @@ function getSupportName(item: ChatConversation) {
</div>
<div class="head-actions">
<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>
</RouterLink>
</div>
+11 -3
View File
@@ -27,7 +27,7 @@ const listRef = ref<HTMLElement | null>(null)
const conversationID = computed(() => Number(route.params.id || 0))
const memberText = computed(() => {
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(' · ')
})
@@ -123,6 +123,7 @@ function roleLabel(role: string) {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
system: '系统',
}
return map[role] || '成员'
@@ -141,12 +142,13 @@ function senderLabel(message: ChatMessage) {
<van-icon name="arrow-left" :size="20" />
</button>
<div class="chat-title">
<h1>{{ conversation?.title || '订单群聊' }}</h1>
<h1>{{ conversation?.title || '客服会话' }}</h1>
<p>{{ memberText }}</p>
</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" />
</button>
<span v-else class="icon-placeholder"></span>
</header>
<section ref="listRef" class="message-list" :class="{ loading }">
@@ -216,6 +218,12 @@ function senderLabel(message: ChatMessage) {
color: #374151;
}
.icon-placeholder {
display: block;
width: 44px;
height: 44px;
}
.chat-title {
min-width: 0;
text-align: center;
+26 -3
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { RouterLink } from "vue-router";
import { RouterLink, useRouter } from "vue-router";
import { showToast } from "vant";
import MobileBottomNav from "@/components/MobileBottomNav.vue";
import { ensureSupportChat } from "@/api/chats";
import {
emptyListingPublishOptions,
type ListingPublishOptions,
@@ -36,7 +37,10 @@ import {
readAssetNumber,
readAssetString,
} from "@/utils/listingDisplay";
import { useSessionStore } from "@/stores/session";
const router = useRouter();
const session = useSessionStore();
const loading = ref(false);
const loadFailed = ref(false);
const listings = ref<Listing[]>([]);
@@ -50,6 +54,7 @@ const refreshing = ref(false);
const searchValue = ref("");
const announcements = ref<string[]>(defaultHomeAnnouncements);
const bannerSlides = ref<HomeBannerSlide[]>(defaultHomeBanners);
const supportLoading = ref(false);
const sortOptions = [
{ 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(() =>
uniqueOptions(
publishOptions.value.server_options
@@ -327,7 +349,9 @@ function uniqueOptions(values: string[]) {
placeholder="搜区服 / 段位"
class="home-search"
/>
<button class="mobile-service" type="button">客服</button>
<button class="mobile-service" type="button" :disabled="supportLoading" @click="handleSupportClick">
{{ supportLoading ? "接入中" : "客服" }}
</button>
</div>
<!-- 防骗提示卡片不用 van-notice-bar -->
@@ -498,4 +522,3 @@ function uniqueOptions(values: string[]) {
</template>
<style scoped src="./MobileHomeView.css"></style>
@@ -62,12 +62,13 @@ function roleLabel(role: string) {
renter: '租客',
owner: '号主',
support: '客服',
customer: '咨询',
}
return map[role] || '成员'
}
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))
@@ -93,7 +94,7 @@ const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum
>
<van-empty
v-if="!loading && conversations.length === 0"
description="暂无订单群聊"
description="暂无会话"
class="empty-state"
/>
@@ -116,7 +117,7 @@ const unreadTotal = computed(() => conversations.value.reduce((sum, item) => sum
</div>
<div class="conversation-meta">
<span class="role-chip">{{ roleLabel(item.role) }}</span>
<span>订单 #{{ item.order_id }}</span>
<span>{{ item.order_id ? `订单 #${item.order_id}` : '平台客服' }}</span>
</div>
<p>{{ previewText(item) }}</p>
</div>