Files
olons_server/web/src/mobile/views/SupportView.vue
T
richjohnrambo ba87d70478
Deploy olons-server production / deploy (push) Successful in 4m30s
fix: show customer chat before connection completes
2026-10-19 21:32:56 +08:00

427 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>