fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s
Deploy olons-server production / deploy (push) Successful in 3m38s
This commit is contained in:
@@ -38,6 +38,10 @@
|
||||
<section v-else class="m-chat">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||
<div v-if="!chatLoading && connectionState !== 'online'" :class="['m-chat-connection', `is-${connectionState}`]">
|
||||
<span>{{ connectionStatusText }}</span>
|
||||
<button v-if="connectionState === 'offline'" type="button" @click="connectChat">重新连接</button>
|
||||
</div>
|
||||
<div v-if="chatLoading" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
@@ -59,8 +63,11 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -90,8 +97,11 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -103,12 +113,12 @@
|
||||
</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="请输入消息" />
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || connectionState !== 'online' || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<button
|
||||
v-if="draft.trim()"
|
||||
class="m-chat-send"
|
||||
type="submit"
|
||||
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||
:disabled="chatLoading || Boolean(chatError) || connectionState !== 'online' || sending || mediaSending"
|
||||
>{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||
<img :src="mobileAssets.chatMore" alt="" />
|
||||
@@ -157,7 +167,12 @@
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { deviceID, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
|
||||
import {
|
||||
CustomerServiceChat,
|
||||
type ChatMessage,
|
||||
type CustomerServiceConnectionState,
|
||||
type CustomerServiceSession,
|
||||
} from '../im';
|
||||
const route = useRoute();
|
||||
const isChat = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
@@ -171,6 +186,12 @@
|
||||
);
|
||||
const chatLoading = ref(false);
|
||||
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));
|
||||
@@ -179,6 +200,8 @@
|
||||
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);
|
||||
@@ -247,6 +270,7 @@
|
||||
historyBoundarySeq.value = 0;
|
||||
chatLoading.value = false;
|
||||
chatError.value = '';
|
||||
connectionState.value = 'offline';
|
||||
draft.value = '';
|
||||
sending.value = false;
|
||||
mediaSending.value = false;
|
||||
@@ -267,11 +291,19 @@
|
||||
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();
|
||||
});
|
||||
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) {
|
||||
@@ -294,7 +326,7 @@
|
||||
}
|
||||
}
|
||||
async function sendMessage() {
|
||||
if (!chat || sending.value || !draft.value.trim()) return;
|
||||
if (!chat || connectionState.value !== 'online' || sending.value || !draft.value.trim()) return;
|
||||
const activeChat = chat;
|
||||
const version = connectionVersion;
|
||||
const value = draft.value;
|
||||
@@ -318,7 +350,7 @@
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !chat || mediaSending.value) return;
|
||||
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;
|
||||
@@ -360,6 +392,26 @@
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user