427 lines
19 KiB
Vue
427 lines
19 KiB
Vue
<template>
|
||
<section v-if="!isChat" class="m-support">
|
||
<div class="m-support-hero" :style="{ backgroundImage: `url(${mobileAssets.supportHero})` }">
|
||
<PageHeader title="客服中心" :back="false" />
|
||
<div><h1>在线客服</h1><p>账户与业务问题请联系在线客服,全程跟进处理</p></div>
|
||
</div>
|
||
<div class="m-support-body">
|
||
<AsyncState
|
||
:loading="loading"
|
||
:error="error"
|
||
:empty="!services.length"
|
||
empty-text="在线客服暂未开放"
|
||
@retry="load"
|
||
>
|
||
<button
|
||
v-for="item in services"
|
||
:key="item.id"
|
||
class="m-service-card"
|
||
@click="$router.push(`/app/support/${item.id}`)"
|
||
>
|
||
<div class="m-service-avatar"
|
||
><img
|
||
v-if="safeImage(item.avatar_url)"
|
||
:src="safeImage(item.avatar_url)"
|
||
:alt="item.name" /><n-icon v-else :component="HeadsetOutline"
|
||
/></div>
|
||
<div
|
||
><h2>{{ item.name }}</h2
|
||
><p>工作时间:{{ item.work_time || '以客服在线状态为准' }}</p
|
||
><small v-if="item.backup_contact">{{ item.backup_contact }}</small></div
|
||
>
|
||
<span :class="`is-${item.online_status}`">{{ statusText(item.online_status) }}</span>
|
||
</button>
|
||
</AsyncState>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else class="m-chat">
|
||
<PageHeader :title="current?.name || '在线客服'" />
|
||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||
<div v-if="connectionState !== 'online' || chatError" :class="['m-chat-connection', `is-${connectionState}`]" role="status" aria-live="polite">
|
||
<span>{{ chatError || connectionStatusText }}</span>
|
||
<button v-if="connectionState === 'offline'" type="button" @click="connectChat">重新连接</button>
|
||
</div>
|
||
<div ref="messageList" class="m-chat-messages">
|
||
<div v-for="message in historicalMessages" :key="`history-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
|
||
<div class="m-chat-avatar" aria-hidden="true">
|
||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||
<span v-else-if="message.mine">👨🏻💼</span>
|
||
<span v-else>👩🏻💼</span>
|
||
</div>
|
||
<div class="m-chat-message">
|
||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||
<span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
|
||
</div>
|
||
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
|
||
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
|
||
<p v-if="message.text">{{ message.text }}</p>
|
||
</div>
|
||
<small>
|
||
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
<div v-if="historicalMessages.length" class="m-chat-history-divider"><span>以上是历史会话</span></div>
|
||
<div class="m-chat-session-tip">[ {{ current?.name || '在线客服' }} ] 客服会话已打开</div>
|
||
<div class="m-chat-system-row">
|
||
<div class="m-chat-avatar m-chat-avatar--agent" aria-hidden="true">
|
||
<img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||
<span v-else>👩🏻💼</span>
|
||
</div>
|
||
<div class="m-chat-system-message">
|
||
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
|
||
<p><b>【系统消息】</b>您好,我是{{ current?.name || '在线客服' }}。您可以先输入要咨询的问题,正在为您连接客服。</p>
|
||
</div>
|
||
</div>
|
||
<div v-for="message in liveMessages" :key="`live-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
|
||
<div class="m-chat-avatar" aria-hidden="true">
|
||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||
<span v-else-if="message.mine">👨🏻💼</span>
|
||
<span v-else>👩🏻💼</span>
|
||
</div>
|
||
<div class="m-chat-message">
|
||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||
<div v-if="message.type !== 'text' && mediaFailed(message)" class="m-chat-media-error">
|
||
<span>媒体加载失败</span><button type="button" @click="retryMedia(message)">重新加载</button>
|
||
</div>
|
||
<img v-else-if="message.type === 'image' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" @error="markMediaFailed(message)" />
|
||
<video v-else-if="message.type === 'video' && message.media_url" :key="mediaRenderKey(message)" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline @error="markMediaFailed(message)"></video>
|
||
<p v-if="message.text">{{ message.text }}</p>
|
||
</div>
|
||
<small>
|
||
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||
</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
|
||
<form class="m-chat-input" @submit.prevent="sendMessage">
|
||
<input v-model="draft" :disabled="sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||
<button
|
||
v-if="draft.trim()"
|
||
class="m-chat-send"
|
||
type="submit"
|
||
:disabled="!chat || connectionState !== 'online' || sending || mediaSending"
|
||
>{{ sending ? '发送中' : '发送' }}</button>
|
||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||
<img :src="mobileAssets.chatMore" alt="" />
|
||
</button>
|
||
</form>
|
||
<div v-if="mediaSending" class="m-chat-uploading"><progress max="100" :value="uploadProgress"></progress><b>正在上传 {{ uploadProgress }}%</b></div>
|
||
<div v-if="toolsOpen && !emojiOpen" class="m-chat-tools">
|
||
<button type="button" @click="emojiOpen = true"><n-icon :component="HappyOutline" /><span>表情</span></button>
|
||
<button type="button" :disabled="!chat || connectionState !== 'online' || mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button>
|
||
<button type="button" :disabled="!chat || connectionState !== 'online' || mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><span>视频</span></button>
|
||
<input ref="photoInput" class="m-chat-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" @change="sendMedia('image', $event)" />
|
||
<input ref="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" />
|
||
</div>
|
||
<div v-if="toolsOpen && emojiOpen" class="m-chat-emoji-panel">
|
||
<div class="m-chat-emoji-tabs" role="tablist" aria-label="表情分类">
|
||
<button
|
||
v-for="category in emojiCategories"
|
||
:key="category.key"
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="activeEmojiCategory === category.key"
|
||
:class="{ 'is-active': activeEmojiCategory === category.key }"
|
||
@click="activeEmojiCategory = category.key"
|
||
>{{ category.label }}</button>
|
||
</div>
|
||
<div class="m-chat-emojis">
|
||
<button v-for="emoji in visibleEmojis" :key="emoji" type="button" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||
</div>
|
||
<div v-if="!visibleEmojis.length" class="m-chat-emoji-empty">暂无最近使用的表情</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||
import { useRoute } from 'vue-router';
|
||
import {
|
||
HappyOutline,
|
||
HeadsetOutline,
|
||
ImageOutline,
|
||
VideocamOutline,
|
||
} from '@vicons/ionicons5';
|
||
import PageHeader from '../components/PageHeader.vue';
|
||
import AsyncState from '../components/AsyncState.vue';
|
||
import { deviceID, errorText, request, safeImage } from '../api';
|
||
import { mobileAssets } from '../assets';
|
||
import {
|
||
CustomerServiceChat,
|
||
type ChatMessage,
|
||
type CustomerServiceConnectionState,
|
||
type CustomerServiceSession,
|
||
} from '../im';
|
||
const route = useRoute();
|
||
const isChat = computed(() => Boolean(route.params.id));
|
||
const loading = ref(true);
|
||
const error = ref('');
|
||
const services = ref<any[]>([]);
|
||
const me = ref<any>();
|
||
const current = computed(
|
||
() =>
|
||
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
||
services.value[0]
|
||
);
|
||
const chatError = ref('');
|
||
const connectionState = ref<CustomerServiceConnectionState>('offline');
|
||
const connectionStatusText = computed(() => {
|
||
if (connectionState.value === 'connecting') return '正在连接客服…';
|
||
if (connectionState.value === 'reconnecting') return '连接已断开,正在重新连接并同步消息…';
|
||
return '连接已断开,请重新连接';
|
||
});
|
||
const messages = ref<ChatMessage[]>([]);
|
||
const historyBoundarySeq = ref(0);
|
||
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||
const liveMessages = computed(() => messages.value.filter((message) => message.seq > historyBoundarySeq.value));
|
||
const draft = ref('');
|
||
const sending = ref(false);
|
||
const mediaSending = ref(false);
|
||
const uploadProgress = ref(0);
|
||
const failedMediaKeys = ref(new Set<string>());
|
||
const mediaRetries = ref<Record<string, number>>({});
|
||
const photoInput = ref<HTMLInputElement | null>(null);
|
||
const videoInput = ref<HTMLInputElement | null>(null);
|
||
const toolsOpen = ref(false);
|
||
const emojiOpen = ref(false);
|
||
type EmojiCategory = 'recent' | 'face' | 'gesture' | 'object';
|
||
const emojiCategories: Array<{ key: EmojiCategory; label: string }> = [
|
||
{ key: 'recent', label: '最近' },
|
||
{ key: 'face', label: '表情' },
|
||
{ key: 'gesture', label: '手势' },
|
||
{ key: 'object', label: '物品' },
|
||
];
|
||
const emojiGroups: Record<Exclude<EmojiCategory, 'recent'>, string[]> = {
|
||
face: [
|
||
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭',
|
||
'😡', '🥳', '😱', '🤯', '🥰', '😇', '😮', '😉', '😆', '😅', '☺️', '😋',
|
||
'😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷',
|
||
'🤧', '😏', '😪', '🙄',
|
||
],
|
||
gesture: [
|
||
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '👏', '🙌', '👐', '🤲', '🙏',
|
||
'👋', '🤝', '💪', '✍️', '☝️', '👆', '👇', '👉', '👈', '🫶', '💅', '🤙',
|
||
],
|
||
object: [
|
||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🌹', '🌸', '🎉', '🎁',
|
||
'⭐', '✨', '🔥', '💯', '🎂', '🍰', '☕', '🍻', '⚽', '🏆', '🚀', '💡',
|
||
],
|
||
};
|
||
const recentEmojis = ref<string[]>([]);
|
||
const activeEmojiCategory = ref<EmojiCategory>('face');
|
||
const visibleEmojis = computed(() =>
|
||
activeEmojiCategory.value === 'recent' ? recentEmojis.value : emojiGroups[activeEmojiCategory.value]
|
||
);
|
||
const sessionStartedAt = ref(new Date().toISOString());
|
||
const messageList = ref<HTMLElement | null>(null);
|
||
let chat: CustomerServiceChat | null = null;
|
||
let connectionVersion = 0;
|
||
async function load() {
|
||
loading.value = true;
|
||
error.value = '';
|
||
try {
|
||
const [serviceRows, profile] = await Promise.all([
|
||
request<any[]>({ url: '/v1/customer-services' }),
|
||
request<any>({ url: '/v1/me' }).catch(() => null),
|
||
]);
|
||
services.value = serviceRows;
|
||
me.value = profile;
|
||
} catch (err) {
|
||
const message = errorText(err);
|
||
error.value = message;
|
||
if (isChat.value) chatError.value = message;
|
||
services.value = [];
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
function statusText(status: string) {
|
||
if (status === 'online') return '在线咨询';
|
||
if (status === 'busy') return '忙碌';
|
||
return '离线留言';
|
||
}
|
||
function disconnectChat() {
|
||
connectionVersion += 1;
|
||
chat?.disconnect();
|
||
chat = null;
|
||
messages.value = [];
|
||
historyBoundarySeq.value = 0;
|
||
chatError.value = '';
|
||
connectionState.value = 'offline';
|
||
draft.value = '';
|
||
sending.value = false;
|
||
mediaSending.value = false;
|
||
uploadProgress.value = 0;
|
||
toolsOpen.value = false;
|
||
emojiOpen.value = false;
|
||
}
|
||
async function connectChat() {
|
||
const serviceID = Number(route.params.id);
|
||
disconnectChat();
|
||
if (!serviceID) return;
|
||
const version = connectionVersion;
|
||
connectionState.value = 'connecting';
|
||
chatError.value = '';
|
||
sessionStartedAt.value = new Date().toISOString();
|
||
try {
|
||
const session = await request<CustomerServiceSession>({
|
||
url: `/v1/customer-services/${serviceID}/session`,
|
||
method: 'POST',
|
||
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||
});
|
||
if (version !== connectionVersion) return;
|
||
const nextChat = new CustomerServiceChat(
|
||
session,
|
||
(rows) => {
|
||
if (version !== connectionVersion) return;
|
||
messages.value = rows;
|
||
void scrollToBottom();
|
||
},
|
||
{
|
||
onConnectionState: (state) => {
|
||
if (version === connectionVersion) connectionState.value = state;
|
||
},
|
||
},
|
||
);
|
||
chat = nextChat;
|
||
await nextChat.connect(session.token);
|
||
if (version !== connectionVersion) {
|
||
nextChat.disconnect();
|
||
return;
|
||
}
|
||
// Tinode delivers cached topic history just after subscribe resolves.
|
||
// Give that initial batch a brief window so it can be separated from the new session.
|
||
await new Promise<void>((resolve) => window.setTimeout(resolve, 250));
|
||
if (version !== connectionVersion) return;
|
||
historyBoundarySeq.value = messages.value.reduce((max, message) => Math.max(max, message.seq), 0);
|
||
sessionStartedAt.value = new Date().toISOString();
|
||
} catch (err) {
|
||
if (version !== connectionVersion) return;
|
||
chat?.disconnect();
|
||
chat = null;
|
||
chatError.value = errorText(err);
|
||
connectionState.value = 'offline';
|
||
}
|
||
}
|
||
async function sendMessage() {
|
||
if (!chat || connectionState.value !== 'online' || sending.value || !draft.value.trim()) return;
|
||
const activeChat = chat;
|
||
const version = connectionVersion;
|
||
const value = draft.value;
|
||
draft.value = '';
|
||
toolsOpen.value = false;
|
||
emojiOpen.value = false;
|
||
sending.value = true;
|
||
try {
|
||
await activeChat.send(value);
|
||
if (version !== connectionVersion) return;
|
||
await scrollToBottom();
|
||
} catch (err) {
|
||
if (version !== connectionVersion) return;
|
||
draft.value = value;
|
||
chatError.value = errorText(err);
|
||
} finally {
|
||
if (version === connectionVersion) sending.value = false;
|
||
}
|
||
}
|
||
async function sendMedia(type: 'image' | 'video', event: Event) {
|
||
const input = event.target as HTMLInputElement;
|
||
const file = input.files?.[0];
|
||
input.value = '';
|
||
if (!file || !chat || connectionState.value !== 'online' || mediaSending.value) return;
|
||
const activeChat = chat;
|
||
const version = connectionVersion;
|
||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||
if (!file.type.startsWith(`${type}/`)) {
|
||
window.$message?.error(type === 'image' ? '请选择照片文件' : '请选择视频文件');
|
||
return;
|
||
}
|
||
if (file.size > maxSize) {
|
||
window.$message?.error(type === 'image' ? '照片不能超过 10MB' : '视频不能超过 50MB');
|
||
return;
|
||
}
|
||
toolsOpen.value = false;
|
||
emojiOpen.value = false;
|
||
mediaSending.value = true;
|
||
uploadProgress.value = 0;
|
||
try {
|
||
await activeChat.sendMedia(file, type, (progress) => {
|
||
if (version !== connectionVersion) return;
|
||
uploadProgress.value = Math.min(99, Math.round(progress * 100));
|
||
});
|
||
if (version !== connectionVersion) return;
|
||
uploadProgress.value = 100;
|
||
await scrollToBottom();
|
||
} catch (err) {
|
||
if (version !== connectionVersion) return;
|
||
window.$message?.error(errorText(err));
|
||
} finally {
|
||
if (version === connectionVersion) {
|
||
mediaSending.value = false;
|
||
uploadProgress.value = 0;
|
||
}
|
||
}
|
||
}
|
||
function toggleTools() {
|
||
toolsOpen.value = !toolsOpen.value;
|
||
if (!toolsOpen.value) emojiOpen.value = false;
|
||
}
|
||
function insertEmoji(emoji: string) {
|
||
draft.value += emoji;
|
||
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
|
||
}
|
||
function mediaIdentity(message: ChatMessage) {
|
||
return `${message.from}:${message.seq}:${message.media_url || ''}`;
|
||
}
|
||
function mediaFailed(message: ChatMessage) {
|
||
return failedMediaKeys.value.has(mediaIdentity(message));
|
||
}
|
||
function mediaRenderKey(message: ChatMessage) {
|
||
const key = mediaIdentity(message);
|
||
return `${key}:${mediaRetries.value[key] || 0}`;
|
||
}
|
||
function markMediaFailed(message: ChatMessage) {
|
||
failedMediaKeys.value = new Set([...failedMediaKeys.value, mediaIdentity(message)]);
|
||
}
|
||
function retryMedia(message: ChatMessage) {
|
||
const key = mediaIdentity(message);
|
||
const nextFailures = new Set(failedMediaKeys.value);
|
||
nextFailures.delete(key);
|
||
failedMediaKeys.value = nextFailures;
|
||
mediaRetries.value = { ...mediaRetries.value, [key]: (mediaRetries.value[key] || 0) + 1 };
|
||
}
|
||
async function scrollToBottom() {
|
||
await nextTick();
|
||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||
}
|
||
function messageTime(value: string) {
|
||
if (!value) return '';
|
||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
|
||
}
|
||
function messageDateTime(value: string) {
|
||
if (!value) return '';
|
||
return new Intl.DateTimeFormat('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false,
|
||
}).format(new Date(value));
|
||
}
|
||
watch(() => route.params.id, (serviceID) => {
|
||
if (serviceID) void connectChat();
|
||
else disconnectChat();
|
||
}, { immediate: true, flush: 'sync' });
|
||
onMounted(load);
|
||
onBeforeUnmount(disconnectChat);
|
||
</script>
|