fix: make support IM delivery recoverable
Deploy olons-server production / deploy (push) Successful in 3m38s

This commit is contained in:
Your Name
2026-10-19 17:23:34 +08:00
parent c39ec78e89
commit db18514e6d
11 changed files with 898 additions and 60 deletions
+66 -14
View File
@@ -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;