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
+268
View File
@@ -0,0 +1,268 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({ instances: [] as any[] }));
vi.mock('tinode-sdk', () => {
class QueryBuilder {
private kind = '';
private since?: number;
private limit?: number;
constructor(private topic?: { maxMsgSeq?: () => number }) {}
withData(_since?: number, _before?: number, limit?: number) {
this.kind = 'initial';
this.limit = limit;
return this;
}
withLaterData(limit?: number) {
this.kind = 'later';
this.since = (this.topic?.maxMsgSeq?.() || 0) + 1;
this.limit = limit;
return this;
}
build() {
return { kind: this.kind, since: this.since, limit: this.limit };
}
}
class MockTinode {
onConnect?: () => Promise<void> | void;
onDisconnect?: () => void;
onAutoreconnectIteration?: (timeoutMs: number) => void;
_connection = { sendText: vi.fn() };
connect = vi.fn(async () => undefined);
loginToken = vi.fn(async () => ({ code: 200 }));
disconnect = vi.fn();
authorizeURL = vi.fn((value: string) => value);
topic = {
onData: undefined as (() => void) | undefined,
onAllMessagesReceived: undefined as ((count: number) => void) | undefined,
maxSeq: 0,
maxMsgSeq: vi.fn(() => this.topic.maxSeq),
startMetaQuery: () => new QueryBuilder(this.topic),
subscribe: vi.fn(async () => {
setTimeout(() => this.topic.onAllMessagesReceived?.(0), 0);
return { code: 200, params: { what: 'data', count: 0 } };
}),
getMeta: vi.fn(async () => ({ code: 200, params: { what: 'data', count: 0 } })),
leave: vi.fn(),
messages: vi.fn(),
createMessage: vi.fn(),
publishDraft: vi.fn(),
};
constructor(public options: Record<string, unknown>) {
mocks.instances.push(this);
}
getTopic() {
return this.topic;
}
}
return {
Tinode: MockTinode,
Drafty: { appendImage: vi.fn(), insertVideo: vi.fn() },
};
});
import {
CustomerServiceChat,
resolveCustomerServiceEndpoints,
type CustomerServiceSession,
} from './im';
const session = (overrides: Partial<CustomerServiceSession> = {}): CustomerServiceSession => ({
tenant_code: 'default',
api_base_url: '/v1/im',
file_url: '/v0/file',
websocket_url: '/v0/channels',
api_key: 'public-key',
token: 'token-1',
current_user_id: 'usr_member',
topic: 'p2p-topic',
...overrides,
});
describe('customer service IM endpoints', () => {
it('resolves API, file and websocket paths against the current page origin', () => {
expect(resolveCustomerServiceEndpoints(session(), 'https://www.yuexuan2.shop')).toEqual({
apiBaseURL: 'https://www.yuexuan2.shop/v1/im',
fileURL: 'https://www.yuexuan2.shop/v0/file',
websocketURL: 'wss://www.yuexuan2.shop/v0/channels',
});
});
it('does not follow legacy absolute IM hosts from a cached session response', () => {
expect(
resolveCustomerServiceEndpoints(
session({
api_base_url: 'https://im-api.yuexuan.shop/v1/im',
file_url: 'https://im-api.yuexuan.shop/v0/file',
websocket_url: 'wss://im-api.yuexuan.shop/v0/channels',
}),
'https://www.yuexuan3.shop',
),
).toEqual({
apiBaseURL: 'https://www.yuexuan3.shop/v1/im',
fileURL: 'https://www.yuexuan3.shop/v0/file',
websocketURL: 'wss://www.yuexuan3.shop/v0/channels',
});
});
});
describe('customer service reconnect', () => {
beforeEach(() => {
mocks.instances.length = 0;
});
it('reauthenticates and resubscribes with later data after reconnect', async () => {
const states: string[] = [];
const chat = new CustomerServiceChat(session(), vi.fn(), {
origin: 'https://www.yuexuan1.shop',
onConnectionState: (state) => states.push(state),
});
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.onDisconnect?.();
await tinode.onConnect?.();
expect(tinode.loginToken).toHaveBeenCalledTimes(2);
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(2);
expect(tinode.topic.subscribe.mock.calls[0][0]).toEqual({ kind: 'initial', limit: 50, since: undefined });
expect(tinode.topic.subscribe.mock.calls[1][0]).toEqual({ kind: 'later', limit: 50, since: 1 });
expect(states).toEqual(expect.arrayContaining(['connecting', 'online', 'reconnecting']));
expect(states.at(-1)).toBe('online');
});
it('does not restore a deliberately disconnected chat', async () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://olons.yuexuan.shop' });
const tinode = mocks.instances[0];
await chat.connect('token-1');
chat.disconnect();
await tinode.onConnect?.();
expect(tinode.loginToken).toHaveBeenCalledTimes(1);
expect(tinode.topic.subscribe).toHaveBeenCalledTimes(1);
});
it('reports offline without leaking a rejected reconnect callback', async () => {
const states: string[] = [];
const chat = new CustomerServiceChat(session(), vi.fn(), {
origin: 'https://www.yuexuan3.shop',
onConnectionState: (state) => states.push(state),
});
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.loginToken.mockRejectedValueOnce(new Error('network unavailable'));
tinode.onDisconnect?.();
await expect(tinode.onConnect?.()).resolves.toBeUndefined();
expect(states.at(-1)).toBe('offline');
});
it('uses rotated login tokens for reconnect and media upload', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
tinode.loginToken
.mockResolvedValueOnce({ code: 200, params: { token: 'token-2' } })
.mockResolvedValueOnce({ code: 200, params: { token: 'token-3' } });
await chat.connect('token-1');
await tinode.onConnect?.();
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
expect(tinode.loginToken.mock.calls.map((call: unknown[]) => call[0])).toEqual(['token-1', 'token-2']);
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({ Authorization: 'Token token-3' });
fetchMock.mockRestore();
});
it('paginates all messages accumulated while disconnected', async () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
const tinode = mocks.instances[0];
await chat.connect('token-1');
tinode.topic.subscribe.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 50;
tinode.topic.onAllMessagesReceived?.(50);
}, 10);
return { code: 200, params: { what: 'data', count: 50 } };
});
tinode.topic.getMeta
.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 100;
tinode.topic.onAllMessagesReceived?.(50);
}, 10);
return { code: 200, params: { what: 'data', count: 50 } };
})
.mockImplementationOnce(async () => {
setTimeout(() => {
tinode.topic.maxSeq = 120;
tinode.topic.onAllMessagesReceived?.(20);
}, 10);
return { code: 200, params: { what: 'data', count: 20 } };
});
await tinode.onConnect?.();
expect(tinode.topic.getMeta).toHaveBeenCalledTimes(2);
expect(tinode.topic.getMeta.mock.calls.map((call: unknown[]) => call[0])).toEqual([
{ kind: 'later', limit: 50, since: 51 },
{ kind: 'later', limit: 50, since: 101 },
]);
});
});
describe('customer service media upload', () => {
beforeEach(() => {
mocks.instances.length = 0;
});
it('keeps credentials in headers and out of the upload URL', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({ ctrl: { params: { url: '/v0/file/s/file-1' } } }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
);
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan1.shop' });
await (chat as any).upload(new File(['image'], 'test.png', { type: 'image/png' }));
const [url, init] = fetchMock.mock.calls[0];
expect(String(url)).toBe('https://www.yuexuan1.shop/v0/file/u/');
expect(String(url)).not.toContain('secret=');
expect(init?.headers).toEqual({
'X-MeetingIM-APIKey': 'public-key',
Authorization: 'Token token-1',
});
fetchMock.mockRestore();
});
it('rewrites historical absolute IM file references to the current frontend origin', () => {
const chat = new CustomerServiceChat(session(), vi.fn(), { origin: 'https://www.yuexuan3.shop' });
const tinode = mocks.instances[0];
tinode.authorizeURL.mockImplementation((value: string) => `${value}?auth=token-1`);
expect((chat as any).mediaURL('https://im-api.yuexuan.shop/v0/file/s/legacy-image')).toBe(
'https://www.yuexuan3.shop/v0/file/s/legacy-image?auth=token-1',
);
expect((chat as any).mediaURL('https://cdn.example.com/public/image.png')).toBe(
'https://cdn.example.com/public/image.png',
);
});
});
+164 -20
View File
@@ -25,18 +25,57 @@ export interface ChatMessage {
file_name?: string;
}
export type CustomerServiceConnectionState = 'connecting' | 'online' | 'reconnecting' | 'offline';
interface CustomerServiceChatOptions {
origin?: string;
onConnectionState?: (state: CustomerServiceConnectionState) => void;
}
export function resolveCustomerServiceEndpoints(session: CustomerServiceSession, origin: string) {
const pageOrigin = new URL(origin);
const sameOriginURL = (value: string, fallback: string) => {
const configured = new URL(value || fallback, pageOrigin);
const resolved = new URL(`${configured.pathname}${configured.search}`, pageOrigin);
return resolved.toString().replace(/\/$/, '');
};
const websocket = new URL(sameOriginURL(session.websocket_url, '/v0/channels'));
websocket.protocol = pageOrigin.protocol === 'https:' ? 'wss:' : 'ws:';
return {
apiBaseURL: sameOriginURL(session.api_base_url, '/v1/im'),
fileURL: sameOriginURL(session.file_url, '/v0/file'),
websocketURL: websocket.toString().replace(/\/$/, ''),
};
}
export class CustomerServiceChat {
private static readonly reconnectPageSize = 50;
private static readonly maxReconnectPages = 100;
private tinode: any;
private topic: any;
private currentUserID: string;
private onMessages: (messages: ChatMessage[]) => void;
private session: CustomerServiceSession;
private endpoints: ReturnType<typeof resolveCustomerServiceEndpoints>;
private token = '';
private sessionActive = false;
private intentionalDisconnect = false;
private recovery: Promise<void> | null = null;
private onConnectionState: (state: CustomerServiceConnectionState) => void;
constructor(session: CustomerServiceSession, onMessages: (messages: ChatMessage[]) => void) {
const endpoint = new URL(session.websocket_url);
constructor(
session: CustomerServiceSession,
onMessages: (messages: ChatMessage[]) => void,
options: CustomerServiceChatOptions = {},
) {
const origin = options.origin || window.location.origin;
this.endpoints = resolveCustomerServiceEndpoints(session, origin);
const endpoint = new URL(this.endpoints.websocketURL);
this.session = session;
this.token = session.token;
this.currentUserID = session.current_user_id;
this.onMessages = onMessages;
this.onConnectionState = options.onConnectionState || (() => undefined);
this.tinode = new Tinode({
appName: '悦选',
host: endpoint.host,
@@ -48,15 +87,35 @@ export class CustomerServiceChat {
this.installTenantPatch(session.tenant_code);
this.topic = this.tinode.getTopic(session.topic);
this.topic.onData = () => this.refreshMessages();
this.tinode.onDisconnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState('reconnecting');
}
};
this.tinode.onAutoreconnectIteration = (timeoutMs: number) => {
if (!this.intentionalDisconnect && this.sessionActive) {
this.onConnectionState(timeoutMs >= 0 ? 'reconnecting' : 'offline');
}
};
this.tinode.onConnect = () => {
if (!this.intentionalDisconnect && this.sessionActive) {
return this.restoreAfterReconnect();
}
};
}
async connect(token: string) {
this.intentionalDisconnect = false;
this.token = token;
this.onConnectionState('connecting');
await this.tinode.connect();
const login = await this.tinode.loginToken(token);
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话登录失败');
this.acceptRotatedToken(login);
const query = this.topic.startMetaQuery().withData(undefined, undefined, 50).build();
const subscribed = await this.topic.subscribe(query);
if (subscribed?.code >= 300) throw new Error('客服会话订阅失败');
await this.requestMessagePage(() => this.topic.subscribe(query), '客服会话订阅失败');
this.sessionActive = true;
this.onConnectionState('online');
this.refreshMessages();
}
@@ -101,6 +160,9 @@ export class CustomerServiceChat {
}
disconnect() {
this.intentionalDisconnect = true;
this.sessionActive = false;
this.onConnectionState('offline');
try {
this.topic?.leave?.(false);
} finally {
@@ -108,6 +170,85 @@ export class CustomerServiceChat {
}
}
private restoreAfterReconnect() {
if (this.recovery) return this.recovery;
this.recovery = (async () => {
this.onConnectionState('reconnecting');
const login = await this.tinode.loginToken(this.token);
if (login?.code >= 300) throw new Error('客服会话重新登录失败');
this.acceptRotatedToken(login);
await this.restoreSubscriptionAndMessages();
this.refreshMessages();
this.onConnectionState('online');
})()
.catch(() => {
this.onConnectionState('offline');
})
.finally(() => {
this.recovery = null;
});
return this.recovery;
}
private acceptRotatedToken(login: any) {
const rotated = String(login?.params?.token || '');
if (!rotated) return;
this.token = rotated;
this.session.token = rotated;
}
private async restoreSubscriptionAndMessages() {
const pageSize = CustomerServiceChat.reconnectPageSize;
const firstQuery = this.topic.startMetaQuery().withLaterData(pageSize).build();
let count = await this.requestMessagePage(() => this.topic.subscribe(firstQuery), '客服会话重新订阅失败');
for (let page = 1; count >= pageSize; page += 1) {
if (page >= CustomerServiceChat.maxReconnectPages) {
throw new Error('客服消息积压过多,请重新进入客服会话');
}
const previousMax = Number(this.topic.maxMsgSeq?.() || 0);
const query = this.topic.startMetaQuery().withLaterData(pageSize).build();
count = await this.requestMessagePage(() => this.topic.getMeta(query), '客服历史消息补拉失败');
const currentMax = Number(this.topic.maxMsgSeq?.() || 0);
if (count > 0 && currentMax <= previousMax) {
throw new Error('客服历史消息补拉未推进');
}
}
}
private async requestMessagePage(request: () => Promise<any>, errorMessage: string) {
let observedCount: number | undefined;
let resolveObserved: ((count: number) => void) | undefined;
const observed = new Promise<number>((resolve) => {
resolveObserved = resolve;
});
const previousHandler = this.topic.onAllMessagesReceived;
this.topic.onAllMessagesReceived = (count: number) => {
previousHandler?.(count);
observedCount = Number(count || 0);
resolveObserved?.(observedCount);
};
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const response = await request();
if (response?.code >= 300) throw new Error(errorMessage);
if (observedCount !== undefined) return observedCount;
return await Promise.race([
observed,
new Promise<number>((_, reject) => {
// A Tinode request promise resolves before the SDK finishes routing
// its data packets. Only this completion callback guarantees that
// maxMsgSeq and the local message cache belong to the finished page.
timeout = setTimeout(() => reject(new Error(errorMessage)), 5_000);
}),
]);
} finally {
if (timeout) clearTimeout(timeout);
this.topic.onAllMessagesReceived = previousHandler;
}
}
private installTenantPatch(tenantCode: string) {
const connection = this.tinode._connection;
if (!connection || connection.__olonsTenantPatched) return;
@@ -152,21 +293,24 @@ export class CustomerServiceChat {
}
private async upload(file: File, onProgress?: (progress: number) => void) {
const uploadURL = new URL(this.session.file_url || '/v0/file', this.session.api_base_url);
const uploadURL = new URL(this.endpoints.fileURL);
uploadURL.pathname = `${uploadURL.pathname.replace(/\/$/, '').replace(/\/u$/, '')}/u/`;
uploadURL.searchParams.set('apikey', this.session.api_key);
uploadURL.searchParams.set('auth', 'token');
uploadURL.searchParams.set('secret', this.session.token);
const form = new FormData();
form.set('id', clientMID());
form.set('file', file, file.name);
onProgress?.(0.1);
let response: Response;
try {
// Do not attach XMLHttpRequest upload progress listeners here. For a
// cross-origin IM endpoint they trigger a CORS preflight which the file
// service does not expose, while multipart/form-data itself is accepted.
response = await fetch(uploadURL.toString(), { method: 'POST', body: form });
// Keep uploads on fetch so API and token credentials stay in headers and
// never leak into the URL, browser history, or proxy access logs.
response = await fetch(uploadURL.toString(), {
method: 'POST',
headers: {
'X-MeetingIM-APIKey': this.session.api_key,
Authorization: `Token ${this.token}`,
},
body: form,
});
} catch {
throw new Error('媒体上传失败,请检查网络后重试');
}
@@ -186,15 +330,15 @@ export class CustomerServiceChat {
private mediaURL(ref?: string) {
if (!ref) return '';
const apiBase = new URL(this.session.api_base_url);
const apiBase = new URL(this.endpoints.apiBaseURL);
const resolved = new URL(ref, apiBase);
// The SDK signs relative file URLs. Also tolerate historical absolute IM
// references by converting same-origin URLs back to a signable path.
const signable = resolved.origin === apiBase.origin
? `${resolved.pathname}${resolved.search}`
: ref;
// Historical messages may contain the old absolute IM host. Route only
// known IM file paths through the current frontend origin; leave unrelated
// external media untouched.
if (!resolved.pathname.startsWith('/v0/file/') && resolved.origin !== apiBase.origin) return ref;
const signable = `${resolved.pathname}${resolved.search}`;
const authorized = this.tinode.authorizeURL(signable);
return new URL(authorized, this.session.api_base_url).toString();
return new URL(authorized, this.endpoints.apiBaseURL).toString();
}
}
+42
View File
@@ -1493,6 +1493,48 @@ body {
padding: 9px 16px;
font-size: 13px;
}
.m-chat-connection {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 36px;
padding: 8px 14px;
color: #8a5a00;
background: #fff3cd;
font-size: 13px;
}
.m-chat-connection.is-offline {
color: #9f2d2d;
background: #fde7e7;
}
.m-chat-connection button,
.m-chat-media-error button {
border: 0;
padding: 0;
color: currentColor;
background: transparent;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.m-chat-media-error {
display: flex;
min-width: 160px;
min-height: 96px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #8b3a3a;
background: #f8eaea;
border-radius: 12px;
font-size: 13px;
}
.m-chat-empty {
min-height: 530px;
padding: 100px 30px 20px;
+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;