feat: integrate customer service chat

This commit is contained in:
richjohnrambo
2026-10-14 16:37:12 +08:00
parent f3f59fe7c3
commit 48b3564ac9
22 changed files with 1788 additions and 64 deletions
+274 -32
View File
@@ -37,64 +37,198 @@
<section v-else class="m-chat">
<PageHeader :title="current?.name || '在线客服'" />
<div class="m-chat-time">会话内容由客服系统提供</div>
<div class="m-chat-empty">
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
<div v-if="chatLoading" class="m-chat-empty">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>正在连接客服</h2>
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
<a
v-if="safeServiceUrl"
:href="safeServiceUrl"
target="_blank"
rel="noopener noreferrer"
class="m-primary-btn m-btn-link"
>进入客服会话</a
>
<p v-else class="m-form-error">客服会话地址尚未配置</p>
<p>首次连接会自动建立好友关系</p>
</div>
<div v-else-if="chatError" class="m-chat-empty">
<n-icon :component="ChatbubbleEllipsesOutline" />
<h2>连接失败</h2>
<p class="m-form-error">{{ chatError }}</p>
<button class="m-primary-btn" @click="connectChat">重新连接</button>
</div>
<div v-else 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' }]">
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></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' }]">
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></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="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
<button
v-if="draft.trim()"
class="m-chat-send"
type="submit"
:disabled="chatLoading || Boolean(chatError) || 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="mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button>
<button type="button" :disabled="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>
<div class="m-chat-input"
><button aria-label="语音"><img :src="mobileAssets.chatVoice" alt="" /></button
><input disabled placeholder="请先连接客服会话" /><button aria-label="更多"
><img :src="mobileAssets.chatMore" alt="" /></button
></div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
import {
ChatbubbleEllipsesOutline,
HappyOutline,
HeadsetOutline,
ImageOutline,
VideocamOutline,
} from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { errorText, request, safeImage } from '../api';
import { deviceID, errorText, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
import { CustomerServiceChat, type ChatMessage, 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 safeServiceUrl = computed(() => {
const value = current.value?.service_url;
if (!value) return '';
try {
const u = new URL(value);
return u.protocol === 'https:' ? u.href : '';
} catch {
return '';
}
});
const chatLoading = ref(false);
const chatError = ref('');
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 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;
async function load() {
loading.value = true;
error.value = '';
try {
services.value = await request<any[]>({ url: '/v1/customer-services' });
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;
if (isChat.value) await connectChat();
} catch (err) {
error.value = errorText(err);
const message = errorText(err);
error.value = message;
if (isChat.value) chatError.value = message;
services.value = [];
} finally {
loading.value = false;
@@ -105,5 +239,113 @@
if (status === 'busy') return '忙碌';
return '离线留言';
}
async function connectChat() {
const serviceID = Number(route.params.id);
if (!serviceID || chatLoading.value) return;
chat?.disconnect();
chat = null;
chatLoading.value = true;
chatError.value = '';
messages.value = [];
historyBoundarySeq.value = 0;
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' },
});
chat = new CustomerServiceChat(session, (rows) => {
messages.value = rows;
void scrollToBottom();
});
await chat.connect(session.token);
// 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));
historyBoundarySeq.value = messages.value.reduce((max, message) => Math.max(max, message.seq), 0);
sessionStartedAt.value = new Date().toISOString();
} catch (err) {
chat?.disconnect();
chat = null;
chatError.value = errorText(err);
} finally {
chatLoading.value = false;
}
}
async function sendMessage() {
if (!chat || sending.value || !draft.value.trim()) return;
const value = draft.value;
draft.value = '';
toolsOpen.value = false;
emojiOpen.value = false;
sending.value = true;
try {
await chat.send(value);
await scrollToBottom();
} catch (err) {
draft.value = value;
chatError.value = errorText(err);
} finally {
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 || mediaSending.value) return;
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 chat.sendMedia(file, type, (progress) => {
uploadProgress.value = Math.min(99, Math.round(progress * 100));
});
uploadProgress.value = 100;
await scrollToBottom();
} catch (err) {
window.$message?.error(errorText(err));
} finally {
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);
}
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));
}
onMounted(load);
onBeforeUnmount(() => chat?.disconnect());
</script>