feat: integrate customer service chat
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user