mirror of
https://gitee.com/JavaLionLi/plus-ui.git
synced 2026-09-13 07:43:43 +08:00
feat(customerservice): chat-core + chat-ui + workbench 三栏
- chat-core: 类型定义(Message/Conversation/SsePayload) + SseSubscriber 订阅共享 sseEventBus 'customerservice' event,按 msgType 分发 - chat-core utils: formatRelativeTime / extractUrlsAsLinks + 19 个 vitest 单测 - chat-ui: MessageStream/MessageBubble/MessageInput/MessageStateBadge/ DateDivider/ConversationDivider 组件 + useSseConversation/useMessageStream composables;CSS variable 暴露主题色 - api/customerservice: conversation/message/agent/mediaUser HTTP 客户端 按 spec 04-api.md endpoint 形态先行,后端 Task 5-13 完成后无需改前端 - views/customerservice/workbench: 三栏布局 + Pinia store 处理 6 种 SSE payload + 自动重连后全量对齐(spec 05-sse.md §6) - vitest.config.ts: 独立 standalone 配置,跳过 dev server 的 unocss 链
This commit is contained in:
parent
fe8e294775
commit
5c8b6dc014
38
src/api/customerservice/agent.ts
Normal file
38
src/api/customerservice/agent.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import type { TableDataInfo } from './conversation';
|
||||
import type { AgentListQuery, AgentVO, CreateAgentRequest } from './types';
|
||||
|
||||
/** GET /customerservice/agent */
|
||||
export const listAgent = (query?: AgentListQuery): AxiosPromise<TableDataInfo<AgentVO>> => {
|
||||
return request({
|
||||
url: '/customerservice/agent',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/agent — create agent from existing sys_user. */
|
||||
export const createAgent = (body: CreateAgentRequest): AxiosPromise<AgentVO> => {
|
||||
return request({
|
||||
url: '/customerservice/agent',
|
||||
method: 'post',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
|
||||
/** PUT /customerservice/agent/{id}/enable */
|
||||
export const enableAgent = (id: number) => {
|
||||
return request({
|
||||
url: '/customerservice/agent/' + id + '/enable',
|
||||
method: 'put'
|
||||
});
|
||||
};
|
||||
|
||||
/** PUT /customerservice/agent/{id}/disable */
|
||||
export const disableAgent = (id: number) => {
|
||||
return request({
|
||||
url: '/customerservice/agent/' + id + '/disable',
|
||||
method: 'put'
|
||||
});
|
||||
};
|
||||
84
src/api/customerservice/conversation.ts
Normal file
84
src/api/customerservice/conversation.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationListItem,
|
||||
ConversationListQuery,
|
||||
Message,
|
||||
MessageStreamQuery,
|
||||
ReplyMessageRequest,
|
||||
TransferRequest,
|
||||
CloseRequest
|
||||
} from './types';
|
||||
|
||||
/** Backend wraps list responses in TableDataInfo<T>. */
|
||||
export interface TableDataInfo<T> {
|
||||
total: number;
|
||||
rows: T[];
|
||||
code: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/** GET /customerservice/conversation?scope=... */
|
||||
export const listConversation = (query: ConversationListQuery): AxiosPromise<TableDataInfo<ConversationListItem>> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** GET /customerservice/conversation/{id} */
|
||||
export const getConversation = (id: number): AxiosPromise<Conversation> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/** GET /customerservice/conversation/{id}/messages — customer-visible stream, desc by sentAt. */
|
||||
export const getConversationMessages = (
|
||||
id: number,
|
||||
query?: MessageStreamQuery
|
||||
): AxiosPromise<Message[]> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/messages',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/conversation/{id}/message — agent reply. */
|
||||
export const sendMessage = (id: number, body: ReplyMessageRequest): AxiosPromise<Message> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/message',
|
||||
method: 'post',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/conversation/{convId}/message/{msgId}/retry — only FAILED/STUCK. */
|
||||
export const retryMessage = (convId: number, msgId: number): AxiosPromise<Message> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + convId + '/message/' + msgId + '/retry',
|
||||
method: 'post'
|
||||
});
|
||||
};
|
||||
|
||||
/** PUT /customerservice/conversation/{id}/close */
|
||||
export const closeConversation = (id: number, body: CloseRequest = {}) => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/close',
|
||||
method: 'put',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/conversation/{id}/transfer */
|
||||
export const transferConversation = (id: number, body: TransferRequest) => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/transfer',
|
||||
method: 'post',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
11
src/api/customerservice/mediaUser.ts
Normal file
11
src/api/customerservice/mediaUser.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import type { MediaUserVO } from './types';
|
||||
|
||||
/** GET /customerservice/media-user/{id} */
|
||||
export const getMediaUser = (id: number): AxiosPromise<MediaUserVO> => {
|
||||
return request({
|
||||
url: '/customerservice/media-user/' + id,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
5
src/api/customerservice/message.ts
Normal file
5
src/api/customerservice/message.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Standalone retry endpoint export (also reachable via conversation.ts).
|
||||
* Kept for symmetry with the backend module split.
|
||||
*/
|
||||
export { retryMessage } from './conversation';
|
||||
79
src/api/customerservice/types.ts
Normal file
79
src/api/customerservice/types.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Frontend types mirroring backend customerservice VOs.
|
||||
* Re-exports core types and adds API-specific request/response shapes.
|
||||
*/
|
||||
|
||||
import type { ConversationListItem, Conversation } from '@/chat-core';
|
||||
import type { Message } from '@/chat-core';
|
||||
|
||||
export type { Conversation, ConversationListItem, Message };
|
||||
|
||||
export type ConversationScope = 'mine' | 'unassigned';
|
||||
|
||||
export interface ConversationListQuery {
|
||||
scope: ConversationScope;
|
||||
status?: 'OPEN' | 'CLOSED';
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MessageStreamQuery {
|
||||
before?: string;
|
||||
beforeId?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ReplyMessageRequest {
|
||||
msgType: 'TEXT' | 'IMAGE' | 'VIDEO';
|
||||
contentText?: string;
|
||||
mediaUrl?: string;
|
||||
}
|
||||
|
||||
export interface TransferRequest {
|
||||
toAgentId: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CloseRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type AgentOnlineStatus = 'ONLINE' | 'OFFLINE';
|
||||
export type AgentType = 'HUMAN' | 'AI';
|
||||
export type AgentStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface AgentVO {
|
||||
id: number;
|
||||
userId: number;
|
||||
agentType: AgentType;
|
||||
status: AgentStatus;
|
||||
onlineStatus: AgentOnlineStatus;
|
||||
user: {
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
};
|
||||
lastOnlineAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentListQuery {
|
||||
onlineStatus?: AgentOnlineStatus;
|
||||
agentType?: AgentType;
|
||||
status?: AgentStatus;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
userId: number;
|
||||
agentType?: AgentType;
|
||||
}
|
||||
|
||||
export interface MediaUserVO {
|
||||
id: number;
|
||||
externalUserIdInApp: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
platform: string;
|
||||
appId: number;
|
||||
lastContactAt?: string;
|
||||
}
|
||||
111
src/chat-core/__tests__/SseSubscriber.test.ts
Normal file
111
src/chat-core/__tests__/SseSubscriber.test.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import mitt from 'mitt';
|
||||
import { SseSubscriber } from '@/chat-core/sse/SseSubscriber';
|
||||
import { SSE_BUS_EVENT } from '@/chat-core/types/sse';
|
||||
|
||||
describe('SseSubscriber', () => {
|
||||
it('parses customerservice event and dispatches by msgType', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const onIncoming = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', onIncoming);
|
||||
|
||||
const payload = {
|
||||
msgType: 'INCOMING_MESSAGE',
|
||||
data: { conversationId: 1, message: { id: 99 } }
|
||||
};
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify(payload));
|
||||
|
||||
expect(onIncoming).toHaveBeenCalledOnce();
|
||||
expect(onIncoming).toHaveBeenCalledWith(payload.data);
|
||||
});
|
||||
|
||||
it('dispatches different msgTypes to their own handlers', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const onIncoming = vi.fn();
|
||||
const onClosed = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', onIncoming);
|
||||
subscriber.on('CONVERSATION_CLOSED', onClosed);
|
||||
|
||||
bus.emit(
|
||||
SSE_BUS_EVENT,
|
||||
JSON.stringify({ msgType: 'CONVERSATION_CLOSED', data: { conversationId: 7 } })
|
||||
);
|
||||
|
||||
expect(onIncoming).not.toHaveBeenCalled();
|
||||
expect(onClosed).toHaveBeenCalledWith({ conversationId: 7 });
|
||||
});
|
||||
|
||||
it('off() removes a previously registered handler', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
subscriber.off('INCOMING_MESSAGE', handler);
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: {} }));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispose() unsubscribes from the bus', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
subscriber.dispose();
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: {} }));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores invalid JSON without throwing', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, 'not-json');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores payload missing msgType', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify({ data: {} }));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multiple handlers on the same msgType all fire', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', a);
|
||||
subscriber.on('INCOMING_MESSAGE', b);
|
||||
|
||||
bus.emit(
|
||||
SSE_BUS_EVENT,
|
||||
JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: { id: 1 } })
|
||||
);
|
||||
|
||||
expect(a).toHaveBeenCalledWith({ id: 1 });
|
||||
expect(b).toHaveBeenCalledWith({ id: 1 });
|
||||
});
|
||||
|
||||
it('onReconnect handler fires when bus emits the reconnect signal', () => {
|
||||
const bus = mitt<Record<string, any>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const onReconnect = vi.fn();
|
||||
subscriber.onReconnect(onReconnect);
|
||||
|
||||
bus.emit('reconnect', undefined);
|
||||
|
||||
expect(onReconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
64
src/chat-core/__tests__/utils.test.ts
Normal file
64
src/chat-core/__tests__/utils.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatRelativeTime } from '@/chat-core/utils/time';
|
||||
import { extractUrlsAsLinks } from '@/chat-core/utils/content';
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = new Date('2026-06-08T15:00:00');
|
||||
|
||||
it('returns 刚刚 within last minute', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-08T14:59:30'), now)).toBe('刚刚');
|
||||
});
|
||||
|
||||
it('returns N 分钟前 within the hour', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-08T14:55:00'), now)).toBe('5 分钟前');
|
||||
});
|
||||
|
||||
it('returns HH:mm for earlier today (>1h ago)', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-08T09:30:00'), now)).toBe('09:30');
|
||||
});
|
||||
|
||||
it('returns 昨天 HH:mm for yesterday', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-07T18:05:00'), now)).toBe('昨天 18:05');
|
||||
});
|
||||
|
||||
it('returns MM/DD HH:mm for earlier this year', () => {
|
||||
expect(formatRelativeTime(new Date('2026-03-01T10:00:00'), now)).toBe('03/01 10:00');
|
||||
});
|
||||
|
||||
it('returns YYYY/MM/DD for prior years', () => {
|
||||
expect(formatRelativeTime(new Date('2025-12-31T23:59:00'), now)).toBe('2025/12/31');
|
||||
});
|
||||
|
||||
it('returns empty string for invalid dates', () => {
|
||||
expect(formatRelativeTime('not-a-date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUrlsAsLinks', () => {
|
||||
it('returns single text segment when no URL', () => {
|
||||
expect(extractUrlsAsLinks('hello world')).toEqual([{ type: 'text', value: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('marks a single URL as link segment', () => {
|
||||
expect(extractUrlsAsLinks('see https://example.com now')).toEqual([
|
||||
{ type: 'text', value: 'see ' },
|
||||
{ type: 'link', value: 'https://example.com' },
|
||||
{ type: 'text', value: ' now' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple URLs', () => {
|
||||
const out = extractUrlsAsLinks('a http://x.io b https://y.com c');
|
||||
expect(out).toEqual([
|
||||
{ type: 'text', value: 'a ' },
|
||||
{ type: 'link', value: 'http://x.io' },
|
||||
{ type: 'text', value: ' b ' },
|
||||
{ type: 'link', value: 'https://y.com' },
|
||||
{ type: 'text', value: ' c' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(extractUrlsAsLinks('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
9
src/chat-core/index.ts
Normal file
9
src/chat-core/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Chat-core: framework-agnostic types + SSE subscriber + utils.
|
||||
// Aim: extractable into an npm package later — keep zero Vue/Element-Plus deps.
|
||||
|
||||
export * from './types/message';
|
||||
export * from './types/conversation';
|
||||
export * from './types/sse';
|
||||
export * from './sse/SseSubscriber';
|
||||
export * from './utils/time';
|
||||
export * from './utils/content';
|
||||
73
src/chat-core/sse/SseSubscriber.ts
Normal file
73
src/chat-core/sse/SseSubscriber.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { Emitter } from 'mitt';
|
||||
import { SSE_BUS_EVENT, type SseHandler, type SseMsgType, type SsePayload } from '../types/sse';
|
||||
|
||||
/**
|
||||
* Subscribes to a shared SSE event bus (sse.ts → mitt) and dispatches
|
||||
* customerservice payloads to msgType-keyed handlers.
|
||||
*
|
||||
* Does NOT create its own EventSource. The host bus is provided by
|
||||
* frontend/src/utils/sseEventBus.getSseEventBus().
|
||||
*/
|
||||
export class SseSubscriber {
|
||||
private readonly handlers: Map<SseMsgType, Set<(data: any) => void>> = new Map();
|
||||
private readonly reconnectHandlers: Set<() => void> = new Set();
|
||||
private readonly busListener: (raw: string) => void;
|
||||
private readonly reconnectListener: () => void;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly bus: Emitter<any>) {
|
||||
this.busListener = (raw: string) => this.handleRaw(raw);
|
||||
this.reconnectListener = () => this.reconnectHandlers.forEach((h) => h());
|
||||
this.bus.on(SSE_BUS_EVENT, this.busListener);
|
||||
this.bus.on('reconnect', this.reconnectListener);
|
||||
}
|
||||
|
||||
on<K extends SseMsgType>(msgType: K, handler: SseHandler<K>): void {
|
||||
if (this.disposed) return;
|
||||
let set = this.handlers.get(msgType);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.handlers.set(msgType, set);
|
||||
}
|
||||
set.add(handler as (data: any) => void);
|
||||
}
|
||||
|
||||
off<K extends SseMsgType>(msgType: K, handler: SseHandler<K>): void {
|
||||
this.handlers.get(msgType)?.delete(handler as (data: any) => void);
|
||||
}
|
||||
|
||||
onReconnect(handler: () => void): void {
|
||||
if (this.disposed) return;
|
||||
this.reconnectHandlers.add(handler);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.bus.off(SSE_BUS_EVENT, this.busListener);
|
||||
this.bus.off('reconnect', this.reconnectListener);
|
||||
this.handlers.clear();
|
||||
this.reconnectHandlers.clear();
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private handleRaw(raw: string): void {
|
||||
let envelope: SsePayload | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.msgType === 'string') {
|
||||
envelope = parsed as SsePayload;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!envelope) return;
|
||||
const set = this.handlers.get(envelope.msgType);
|
||||
if (!set) return;
|
||||
set.forEach((h) => {
|
||||
try {
|
||||
h(envelope!.data);
|
||||
} catch (err) {
|
||||
console.error('[SseSubscriber] handler threw for', envelope!.msgType, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/chat-core/types/conversation.ts
Normal file
53
src/chat-core/types/conversation.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Conversation-related type definitions, mirroring backend
|
||||
* customerservice_conversation and ConversationVo (spec 04-api.md §2).
|
||||
*/
|
||||
|
||||
export type ConversationStatus = 'OPEN' | 'CLOSED';
|
||||
|
||||
export interface MediaUserSummary {
|
||||
id: number;
|
||||
externalUserIdInApp?: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface MediaAccountSummary {
|
||||
id: number;
|
||||
appId: number;
|
||||
platform: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item for the left-rail conversation list. Carries enough to render the
|
||||
* row without an extra detail call.
|
||||
*/
|
||||
export interface ConversationListItem {
|
||||
id: number;
|
||||
status: ConversationStatus;
|
||||
currentAgentId?: number;
|
||||
hasSendFailure?: boolean;
|
||||
lastMsgAt?: string;
|
||||
lastMsgPreview?: string;
|
||||
unreadCount?: number;
|
||||
mediaUser: MediaUserSummary;
|
||||
mediaAccount: MediaAccountSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail returned by GET /customerservice/conversation/{id}.
|
||||
*/
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
tenantId: string;
|
||||
status: ConversationStatus;
|
||||
currentAgentId?: number;
|
||||
hasSendFailure?: boolean;
|
||||
externalConversationId?: string;
|
||||
createdAt: string;
|
||||
closedAt?: string;
|
||||
lastMsgAt?: string;
|
||||
mediaUser: MediaUserSummary;
|
||||
mediaAccount: MediaAccountSummary;
|
||||
}
|
||||
68
src/chat-core/types/message.ts
Normal file
68
src/chat-core/types/message.ts
Normal file
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Message-related type definitions, mirroring backend
|
||||
* customerservice_conversation_message and MessageVo (spec 04-api.md §3).
|
||||
*/
|
||||
|
||||
export type MessageDirection = 'INBOUND' | 'OUTBOUND';
|
||||
|
||||
export type MessageType = 'TEXT' | 'IMAGE' | 'VIDEO' | 'SYSTEM' | 'UNKNOWN';
|
||||
|
||||
export type MessageActorType = 'MEDIA_USER' | 'MEDIA_ACCOUNT' | 'SYSTEM';
|
||||
|
||||
export type SendStatus = 'PENDING' | 'SENDING' | 'SENT' | 'FAILED' | 'STUCK';
|
||||
|
||||
/**
|
||||
* Reference embedded inside a Message when sender/receiver is an MEDIA_USER
|
||||
* or MEDIA_ACCOUNT.
|
||||
*/
|
||||
export interface MessageActorRef {
|
||||
id: number;
|
||||
externalId?: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator agent reference embedded for OUTBOUND or SYSTEM messages.
|
||||
*/
|
||||
export interface MessageOperatorRef {
|
||||
agentId: number;
|
||||
userId?: number;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors backend MessageVo. All optional fields reflect that the same shape
|
||||
* carries INBOUND / OUTBOUND / SYSTEM messages.
|
||||
*/
|
||||
export interface Message {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
direction: MessageDirection;
|
||||
msgType: MessageType;
|
||||
|
||||
senderType: MessageActorType;
|
||||
senderId?: number;
|
||||
senderExternalId?: string;
|
||||
sender?: MessageActorRef;
|
||||
|
||||
receiverType: MessageActorType;
|
||||
receiverId?: number;
|
||||
receiverExternalId?: string;
|
||||
receiver?: MessageActorRef;
|
||||
|
||||
operatorAgentId?: number;
|
||||
operator?: MessageOperatorRef;
|
||||
|
||||
contentText?: string;
|
||||
contentMediaUrl?: string;
|
||||
|
||||
sentAt: string;
|
||||
externalMsgId?: string;
|
||||
|
||||
sendStatus?: SendStatus;
|
||||
sendErrorCode?: string;
|
||||
sendErrorMsg?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
91
src/chat-core/types/sse.ts
Normal file
91
src/chat-core/types/sse.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Customerservice SSE payload types.
|
||||
*
|
||||
* Wire format aligns with frontend sse.ts SseEnvelope:
|
||||
* { msgType: string; data: T }
|
||||
*
|
||||
* Backend will produce the same envelope for SSE event="customerservice"
|
||||
* (spec 05-sse.md, dispatch by msgType in {INCOMING_MESSAGE, ...}).
|
||||
*/
|
||||
|
||||
import type { ConversationStatus, MediaUserSummary } from './conversation';
|
||||
import type { Message, SendStatus } from './message';
|
||||
|
||||
export const SSE_BUS_EVENT = 'customerservice' as const;
|
||||
|
||||
export type SseMsgType =
|
||||
| 'INCOMING_MESSAGE'
|
||||
| 'OUTGOING_MESSAGE_STATE'
|
||||
| 'CONVERSATION_NEW'
|
||||
| 'CONVERSATION_CLOSED'
|
||||
| 'CONVERSATION_TRANSFERRED'
|
||||
| 'AGENT_ONLINE_STATUS';
|
||||
|
||||
/** Generic envelope shared with sse.ts (msgType + data, no tenantId). */
|
||||
export interface SsePayload<T = unknown> {
|
||||
msgType: SseMsgType;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** INCOMING_MESSAGE payload. Carries full message + minimal conversation. */
|
||||
export interface IncomingMessagePayload {
|
||||
conversationId: number;
|
||||
message: Message;
|
||||
conversation: {
|
||||
id: number;
|
||||
currentAgentId?: number;
|
||||
status: ConversationStatus;
|
||||
lastMsgAt: string;
|
||||
mediaUser: MediaUserSummary;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OutgoingMessageStatePayload {
|
||||
conversationId: number;
|
||||
messageId: number;
|
||||
sendStatus: SendStatus;
|
||||
sendErrorCode?: string;
|
||||
sendErrorMsg?: string;
|
||||
sentAt?: string;
|
||||
}
|
||||
|
||||
export interface ConversationNewPayload {
|
||||
conversationId: number;
|
||||
currentAgentId?: number;
|
||||
mediaUser: MediaUserSummary;
|
||||
mediaAccountId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ConversationClosedPayload {
|
||||
conversationId: number;
|
||||
closedAt: string;
|
||||
closedByAgentId: number;
|
||||
}
|
||||
|
||||
export interface ConversationTransferredPayload {
|
||||
conversationId: number;
|
||||
fromAgentId: number;
|
||||
toAgentId: number;
|
||||
operatorId: number;
|
||||
reason?: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface AgentOnlineStatusPayload {
|
||||
agentId: number;
|
||||
onlineStatus: 'ONLINE' | 'OFFLINE';
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/** Map msgType → payload data shape. Used by SseSubscriber for typed handlers. */
|
||||
export interface SsePayloadMap {
|
||||
INCOMING_MESSAGE: IncomingMessagePayload;
|
||||
OUTGOING_MESSAGE_STATE: OutgoingMessageStatePayload;
|
||||
CONVERSATION_NEW: ConversationNewPayload;
|
||||
CONVERSATION_CLOSED: ConversationClosedPayload;
|
||||
CONVERSATION_TRANSFERRED: ConversationTransferredPayload;
|
||||
AGENT_ONLINE_STATUS: AgentOnlineStatusPayload;
|
||||
}
|
||||
|
||||
export type SseHandler<K extends SseMsgType> = (data: SsePayloadMap[K]) => void;
|
||||
32
src/chat-core/utils/content.ts
Normal file
32
src/chat-core/utils/content.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Plain-text content helpers. No DOM access — safe for SSR and tests.
|
||||
*/
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<>]+)/g;
|
||||
|
||||
export interface ContentSegment {
|
||||
type: 'text' | 'link';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into segments, marking http(s) URLs as link segments.
|
||||
* Caller renders each segment (text → span, link → anchor).
|
||||
*/
|
||||
export function extractUrlsAsLinks(text: string): ContentSegment[] {
|
||||
if (!text) return [];
|
||||
const segments: ContentSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(URL_REGEX)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex, start) });
|
||||
}
|
||||
segments.push({ type: 'link', value: match[0] });
|
||||
lastIndex = start + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
37
src/chat-core/utils/time.ts
Normal file
37
src/chat-core/utils/time.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Format a date as a chat-style relative timestamp:
|
||||
* < 60s → 刚刚
|
||||
* < 60min → N 分钟前
|
||||
* today → HH:mm
|
||||
* yesterday → 昨天 HH:mm
|
||||
* this year → MM/DD HH:mm
|
||||
* older → YYYY/MM/DD
|
||||
*
|
||||
* `now` is parameterized so tests can pin time.
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string | number, now: Date = new Date()): string {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(target.getTime())) return '';
|
||||
|
||||
const diffMs = now.getTime() - target.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 60) return '刚刚';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
|
||||
|
||||
const sameYear = target.getFullYear() === now.getFullYear();
|
||||
const targetMidnight = new Date(target.getFullYear(), target.getMonth(), target.getDate());
|
||||
const nowMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dayDiff = Math.round((nowMidnight.getTime() - targetMidnight.getTime()) / 86400000);
|
||||
|
||||
const hh = String(target.getHours()).padStart(2, '0');
|
||||
const mm = String(target.getMinutes()).padStart(2, '0');
|
||||
|
||||
if (dayDiff === 0) return `${hh}:${mm}`;
|
||||
if (dayDiff === 1) return `昨天 ${hh}:${mm}`;
|
||||
|
||||
const month = String(target.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(target.getDate()).padStart(2, '0');
|
||||
if (sameYear) return `${month}/${day} ${hh}:${mm}`;
|
||||
return `${target.getFullYear()}/${month}/${day}`;
|
||||
}
|
||||
27
src/chat-ui/components/ConversationDivider.vue
Normal file
27
src/chat-ui/components/ConversationDivider.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="chat-conversation-divider">
|
||||
<span class="chat-conversation-divider-line"></span>
|
||||
<span class="chat-conversation-divider-text">— 会话已结束 —</span>
|
||||
<span class="chat-conversation-divider-line"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-conversation-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 12px;
|
||||
gap: 8px;
|
||||
|
||||
&-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--chat-border-color);
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
src/chat-ui/components/DateDivider.vue
Normal file
23
src/chat-ui/components/DateDivider.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="chat-date-divider"><span>{{ label }}</span></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-date-divider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 12px 0;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
background: var(--chat-bg);
|
||||
padding: 2px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
169
src/chat-ui/components/MessageBubble.vue
Normal file
169
src/chat-ui/components/MessageBubble.vue
Normal file
@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="chat-bubble-row" :class="alignClass">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="chat-bubble-avatar" alt="" />
|
||||
<div class="chat-bubble-col">
|
||||
<div v-if="senderName" class="chat-bubble-name">{{ senderName }}</div>
|
||||
<div class="chat-bubble" :class="bubbleClass">
|
||||
<template v-for="(seg, i) in segments" :key="i">
|
||||
<a v-if="seg.type === 'link'" :href="seg.value" target="_blank" rel="noopener">{{ seg.value }}</a>
|
||||
<span v-else>{{ seg.value }}</span>
|
||||
</template>
|
||||
<img v-if="message.contentMediaUrl && message.msgType === 'IMAGE'" :src="message.contentMediaUrl" class="chat-bubble-media" />
|
||||
</div>
|
||||
<div class="chat-bubble-meta">
|
||||
<span class="chat-bubble-time">{{ formatRelativeTime(message.sentAt) }}</span>
|
||||
<MessageStateBadge
|
||||
v-if="message.direction === 'OUTBOUND' && message.sendStatus"
|
||||
:status="message.sendStatus"
|
||||
:error-msg="message.sendErrorMsg"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canRetry"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="chat-bubble-retry"
|
||||
@click="$emit('retry', message)"
|
||||
>
|
||||
重发
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Message } from '@/chat-core';
|
||||
import { extractUrlsAsLinks, formatRelativeTime } from '@/chat-core';
|
||||
import MessageStateBadge from './MessageStateBadge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message;
|
||||
/** Whether this bubble is the current user's (right-aligned). */
|
||||
self?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
retry: [msg: Message];
|
||||
}>();
|
||||
|
||||
const isSystem = computed(() => props.message.msgType === 'SYSTEM');
|
||||
|
||||
const alignClass = computed(() => {
|
||||
if (isSystem.value) return 'chat-bubble-row--system';
|
||||
return props.self ? 'chat-bubble-row--right' : 'chat-bubble-row--left';
|
||||
});
|
||||
|
||||
const bubbleClass = computed(() => {
|
||||
if (isSystem.value) return 'chat-bubble--system';
|
||||
return props.self ? 'chat-bubble--self' : 'chat-bubble--other';
|
||||
});
|
||||
|
||||
const senderName = computed(() => {
|
||||
if (isSystem.value) return null;
|
||||
return props.self ? props.message.operator?.nickname : props.message.sender?.nickname;
|
||||
});
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (isSystem.value) return null;
|
||||
return props.self ? props.message.operator?.avatarUrl : props.message.sender?.avatarUrl;
|
||||
});
|
||||
|
||||
const segments = computed(() => extractUrlsAsLinks(props.message.contentText ?? ''));
|
||||
|
||||
const canRetry = computed(
|
||||
() =>
|
||||
props.message.direction === 'OUTBOUND' &&
|
||||
(props.message.sendStatus === 'FAILED' || props.message.sendStatus === 'STUCK')
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-bubble-row {
|
||||
display: flex;
|
||||
margin: 8px 12px;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
&--right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&--left {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&--system {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-bubble-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.chat-bubble-name {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
|
||||
&--self {
|
||||
background: var(--chat-bubble-self-bg);
|
||||
color: var(--chat-bubble-text);
|
||||
}
|
||||
|
||||
&--other {
|
||||
background: var(--chat-bubble-other-bg);
|
||||
color: var(--chat-bubble-text);
|
||||
border: 1px solid var(--chat-border-color);
|
||||
}
|
||||
|
||||
&--system {
|
||||
background: var(--chat-bubble-system-bg);
|
||||
color: var(--chat-bubble-system-text);
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble-media {
|
||||
display: block;
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
margin-top: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-retry {
|
||||
padding: 0 4px !important;
|
||||
}
|
||||
</style>
|
||||
68
src/chat-ui/components/MessageInput.vue
Normal file
68
src/chat-ui/components/MessageInput.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
resize="none"
|
||||
@keydown.enter.exact.prevent="onSend"
|
||||
/>
|
||||
<div class="chat-input-actions">
|
||||
<span v-if="hint" class="chat-input-hint">{{ hint }}</span>
|
||||
<el-button type="primary" :disabled="!canSend" @click="onSend">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: '输入消息,回车发送',
|
||||
hint: '',
|
||||
disabled: false
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string];
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
|
||||
const canSend = computed(() => !props.disabled && text.value.trim().length > 0);
|
||||
|
||||
function onSend() {
|
||||
if (!canSend.value) return;
|
||||
emit('send', text.value.trim());
|
||||
text.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-input {
|
||||
border-top: 1px solid var(--chat-border-color);
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
</style>
|
||||
42
src/chat-ui/components/MessageStateBadge.vue
Normal file
42
src/chat-ui/components/MessageStateBadge.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<span class="chat-state-badge" :class="`chat-state-${status?.toLowerCase()}`" :title="errorMsg">
|
||||
<template v-if="status === 'PENDING' || status === 'SENDING'">⏳</template>
|
||||
<template v-else-if="status === 'SENT'">✓</template>
|
||||
<template v-else-if="status === 'FAILED'">❌</template>
|
||||
<template v-else-if="status === 'STUCK'">⚠</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SendStatus } from '@/chat-core';
|
||||
|
||||
defineProps<{
|
||||
status?: SendStatus;
|
||||
errorMsg?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-state-badge {
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
user-select: none;
|
||||
|
||||
&.chat-state-sent {
|
||||
color: var(--chat-status-sent);
|
||||
}
|
||||
|
||||
&.chat-state-failed {
|
||||
color: var(--chat-status-failed);
|
||||
}
|
||||
|
||||
&.chat-state-stuck {
|
||||
color: var(--chat-status-stuck);
|
||||
}
|
||||
|
||||
&.chat-state-pending,
|
||||
&.chat-state-sending {
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
src/chat-ui/components/MessageStream.vue
Normal file
114
src/chat-ui/components/MessageStream.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div ref="streamRef" class="chat-stream" @scroll="handleScroll">
|
||||
<div v-if="loadingMore" class="chat-stream-loading">加载中...</div>
|
||||
<div v-if="!loadingMore && noMore" class="chat-stream-no-more">没有更多消息</div>
|
||||
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<DateDivider v-if="item.kind === 'date'" :label="item.label" />
|
||||
<ConversationDivider v-else-if="item.kind === 'conversation-end'" />
|
||||
<MessageBubble
|
||||
v-else
|
||||
:message="item.message"
|
||||
:self="isSelf(item.message)"
|
||||
@retry="$emit('retry', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { Message } from '@/chat-core';
|
||||
import MessageBubble from './MessageBubble.vue';
|
||||
import DateDivider from './DateDivider.vue';
|
||||
import ConversationDivider from './ConversationDivider.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[];
|
||||
/** Operator agent id of the currently logged-in user. Bubble is self if matches. */
|
||||
selfAgentId?: number;
|
||||
loadingMore?: boolean;
|
||||
noMore?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'load-more': [];
|
||||
retry: [msg: Message];
|
||||
}>();
|
||||
|
||||
const streamRef = ref<HTMLElement | null>(null);
|
||||
|
||||
interface StreamItem {
|
||||
key: string;
|
||||
kind: 'date' | 'conversation-end' | 'message';
|
||||
label?: string;
|
||||
message?: Message;
|
||||
}
|
||||
|
||||
const items = computed<StreamItem[]>(() => {
|
||||
const out: StreamItem[] = [];
|
||||
let lastDate: string | null = null;
|
||||
let lastConvId: number | null = null;
|
||||
|
||||
for (const m of props.messages) {
|
||||
const day = m.sentAt?.slice(0, 10) ?? '';
|
||||
if (day && day !== lastDate) {
|
||||
out.push({ key: `d-${day}`, kind: 'date', label: day });
|
||||
lastDate = day;
|
||||
}
|
||||
if (lastConvId !== null && m.conversationId !== lastConvId) {
|
||||
out.push({ key: `c-end-${lastConvId}`, kind: 'conversation-end' });
|
||||
}
|
||||
out.push({ key: `m-${m.id}`, kind: 'message', message: m });
|
||||
lastConvId = m.conversationId;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function isSelf(m: Message): boolean {
|
||||
if (m.direction !== 'OUTBOUND') return false;
|
||||
if (props.selfAgentId == null) return true;
|
||||
return m.operatorAgentId === props.selfAgentId;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const el = streamRef.value;
|
||||
if (!el || props.loadingMore || props.noMore) return;
|
||||
if (el.scrollTop < 80) emit('load-more');
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom when new tail message arrives.
|
||||
let lastTailId: number | null = null;
|
||||
watch(
|
||||
() => props.messages,
|
||||
async (msgs) => {
|
||||
if (!msgs.length) return;
|
||||
const tail = msgs[msgs.length - 1].id;
|
||||
if (lastTailId !== tail) {
|
||||
lastTailId = tail;
|
||||
await nextTick();
|
||||
const el = streamRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
{ deep: false }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--chat-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-stream-loading,
|
||||
.chat-stream-no-more {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
75
src/chat-ui/composables/useMessageStream.ts
Normal file
75
src/chat-ui/composables/useMessageStream.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { ref } from 'vue';
|
||||
import type { Message } from '@/chat-core';
|
||||
|
||||
export interface MessageStreamLoader {
|
||||
/** Load the most recent page (descending by sentAt). */
|
||||
loadLatest: (pageSize: number) => Promise<Message[]>;
|
||||
/** Load older messages strictly before `before/beforeId`. */
|
||||
loadOlder: (before: string, beforeId: number, pageSize: number) => Promise<Message[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable wrapper for the chat message stream pagination contract.
|
||||
* Holds messages in ascending sentAt order so MessageStream can render top-down.
|
||||
*/
|
||||
export function useMessageStream(loader: MessageStreamLoader, pageSize = 50) {
|
||||
const messages = ref<Message[]>([]);
|
||||
const loadingMore = ref(false);
|
||||
const noMore = ref(false);
|
||||
const initialLoading = ref(false);
|
||||
|
||||
async function init() {
|
||||
initialLoading.value = true;
|
||||
noMore.value = false;
|
||||
try {
|
||||
const page = await loader.loadLatest(pageSize);
|
||||
// backend returns desc order; flip to ascending
|
||||
messages.value = [...page].reverse();
|
||||
if (page.length < pageSize) noMore.value = true;
|
||||
} finally {
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || noMore.value || messages.value.length === 0) return;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const head = messages.value[0];
|
||||
const older = await loader.loadOlder(head.sentAt, head.id, pageSize);
|
||||
if (older.length < pageSize) noMore.value = true;
|
||||
// older is desc → flip → prepend
|
||||
messages.value = [...older.reverse(), ...messages.value];
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendIncoming(msg: Message) {
|
||||
messages.value.push(msg);
|
||||
}
|
||||
|
||||
function updateMessage(predicate: (m: Message) => boolean, patch: Partial<Message>) {
|
||||
const idx = messages.value.findIndex(predicate);
|
||||
if (idx >= 0) {
|
||||
messages.value[idx] = { ...messages.value[idx], ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
messages.value = [];
|
||||
noMore.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
loadingMore,
|
||||
noMore,
|
||||
initialLoading,
|
||||
init,
|
||||
loadMore,
|
||||
appendIncoming,
|
||||
updateMessage,
|
||||
reset
|
||||
};
|
||||
}
|
||||
33
src/chat-ui/composables/useSseConversation.ts
Normal file
33
src/chat-ui/composables/useSseConversation.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { getSseEventBus } from '@/utils/sseEventBus';
|
||||
import { SseSubscriber, type SseHandler, type SseMsgType } from '@/chat-core';
|
||||
|
||||
/**
|
||||
* Wires a chat-core SseSubscriber to the host's shared SSE event bus
|
||||
* (sse.ts singleton). Auto-disposes on unmount. Pages register handlers
|
||||
* via the returned `on()` helper inside `setup()`.
|
||||
*/
|
||||
export function useSseConversation() {
|
||||
let subscriber: SseSubscriber | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
subscriber = new SseSubscriber(getSseEventBus());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subscriber?.dispose();
|
||||
subscriber = null;
|
||||
});
|
||||
|
||||
return {
|
||||
on<K extends SseMsgType>(msgType: K, handler: SseHandler<K>) {
|
||||
subscriber?.on(msgType, handler);
|
||||
},
|
||||
off<K extends SseMsgType>(msgType: K, handler: SseHandler<K>) {
|
||||
subscriber?.off(msgType, handler);
|
||||
},
|
||||
onReconnect(handler: () => void) {
|
||||
subscriber?.onReconnect(handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
13
src/chat-ui/index.ts
Normal file
13
src/chat-ui/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// chat-ui: Vue components consuming chat-core types/utilities.
|
||||
// Pure presentation, no business API calls — feature pages assemble these.
|
||||
import './styles/default.scss';
|
||||
|
||||
export { default as MessageStream } from './components/MessageStream.vue';
|
||||
export { default as MessageBubble } from './components/MessageBubble.vue';
|
||||
export { default as MessageInput } from './components/MessageInput.vue';
|
||||
export { default as MessageStateBadge } from './components/MessageStateBadge.vue';
|
||||
export { default as DateDivider } from './components/DateDivider.vue';
|
||||
export { default as ConversationDivider } from './components/ConversationDivider.vue';
|
||||
|
||||
export * from './composables/useSseConversation';
|
||||
export * from './composables/useMessageStream';
|
||||
18
src/chat-ui/styles/default.scss
Normal file
18
src/chat-ui/styles/default.scss
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Default chat-ui theme tokens. Pages can override at any container level.
|
||||
* Keep token list small — easier to swap themes later.
|
||||
*/
|
||||
:root {
|
||||
--chat-primary-color: #409eff;
|
||||
--chat-bubble-self-bg: #95ec69;
|
||||
--chat-bubble-other-bg: #ffffff;
|
||||
--chat-bubble-text: #1f1f1f;
|
||||
--chat-bubble-system-bg: #f0f0f0;
|
||||
--chat-bubble-system-text: #909399;
|
||||
--chat-border-color: #e4e7ed;
|
||||
--chat-bg: #f5f7fa;
|
||||
--chat-divider-color: #c0c4cc;
|
||||
--chat-status-failed: #f56c6c;
|
||||
--chat-status-stuck: #e6a23c;
|
||||
--chat-status-sent: #67c23a;
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="conv-list">
|
||||
<el-tabs v-model="active" @tab-change="onTabChange">
|
||||
<el-tab-pane label="我的对话" name="mine"></el-tab-pane>
|
||||
<el-tab-pane label="无主队列" name="unassigned"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="conv-list-rows">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="conv-row"
|
||||
:class="{ active: row.id === openedId, 'has-failure': row.hasSendFailure }"
|
||||
@click="$emit('open', row.id)"
|
||||
>
|
||||
<img v-if="row.mediaUser.avatarUrl" :src="row.mediaUser.avatarUrl" class="conv-row-avatar" alt="" />
|
||||
<div v-else class="conv-row-avatar conv-row-avatar-fallback">{{ initial(row.mediaUser.nickname) }}</div>
|
||||
<div class="conv-row-body">
|
||||
<div class="conv-row-line1">
|
||||
<span class="conv-row-name">{{ row.mediaUser.nickname || row.mediaUser.externalUserIdInApp }}</span>
|
||||
<span v-if="row.lastMsgAt" class="conv-row-time">{{ formatRelativeTime(row.lastMsgAt) }}</span>
|
||||
</div>
|
||||
<div class="conv-row-line2">
|
||||
<span class="conv-row-preview">{{ row.lastMsgPreview ?? '' }}</span>
|
||||
<el-badge v-if="row.unreadCount" :value="row.unreadCount" type="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rows.length === 0" class="conv-list-empty">暂无对话</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ConversationListItem } from '@/chat-core';
|
||||
import { formatRelativeTime } from '@/chat-core';
|
||||
|
||||
const props = defineProps<{
|
||||
mine: ConversationListItem[];
|
||||
unassigned: ConversationListItem[];
|
||||
openedId?: number | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
open: [id: number];
|
||||
'tab-change': [tab: 'mine' | 'unassigned'];
|
||||
}>();
|
||||
|
||||
const active = ref<'mine' | 'unassigned'>('mine');
|
||||
|
||||
const rows = computed(() => (active.value === 'mine' ? props.mine : props.unassigned));
|
||||
|
||||
function initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
active.value = name as 'mine' | 'unassigned';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--chat-border-color);
|
||||
}
|
||||
|
||||
.conv-list-rows {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conv-row {
|
||||
display: flex;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--chat-border-color);
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
&.has-failure {
|
||||
border-left: 3px solid var(--chat-status-failed);
|
||||
}
|
||||
}
|
||||
|
||||
.conv-row-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background: var(--chat-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.conv-row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-row-line1,
|
||||
.conv-row-line2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conv-row-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-row-time {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-row-preview {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conv-list-empty {
|
||||
text-align: center;
|
||||
color: var(--chat-divider-color);
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="customer-card">
|
||||
<template v-if="conversation">
|
||||
<img v-if="conversation.mediaUser.avatarUrl" :src="conversation.mediaUser.avatarUrl" class="customer-avatar" />
|
||||
<h3 class="customer-name">{{ conversation.mediaUser.nickname || conversation.mediaUser.externalUserIdInApp }}</h3>
|
||||
<dl class="customer-fields">
|
||||
<dt>外部 ID</dt>
|
||||
<dd>{{ conversation.mediaUser.externalUserIdInApp }}</dd>
|
||||
|
||||
<dt>所属账号</dt>
|
||||
<dd>{{ conversation.mediaAccount.platform }} #{{ conversation.mediaAccount.id }}</dd>
|
||||
|
||||
<dt>会话状态</dt>
|
||||
<dd>
|
||||
<el-tag :type="conversation.status === 'OPEN' ? 'success' : 'info'">
|
||||
{{ conversation.status }}
|
||||
</el-tag>
|
||||
</dd>
|
||||
|
||||
<dt>创建时间</dt>
|
||||
<dd>{{ formatRelativeTime(conversation.createdAt) }}</dd>
|
||||
|
||||
<template v-if="conversation.closedAt">
|
||||
<dt>关闭时间</dt>
|
||||
<dd>{{ formatRelativeTime(conversation.closedAt) }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
<div v-else class="customer-card-empty">未选择会话</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Conversation } from '@/chat-core';
|
||||
import { formatRelativeTime } from '@/chat-core';
|
||||
|
||||
defineProps<{ conversation: Conversation | null }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.customer-card {
|
||||
padding: 16px;
|
||||
border-left: 1px solid var(--chat-border-color);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.customer-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
text-align: center;
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.customer-fields {
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-card-empty {
|
||||
text-align: center;
|
||||
color: var(--chat-divider-color);
|
||||
padding-top: 40%;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<MessageInput
|
||||
:placeholder="placeholder"
|
||||
:hint="hint"
|
||||
:disabled="disabled"
|
||||
@send="onSend"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { MessageInput } from '@/chat-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
closed?: boolean;
|
||||
unowned?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string];
|
||||
}>();
|
||||
|
||||
const disabled = computed(() => props.closed === true);
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (props.closed) return '会话已关闭,无法回复';
|
||||
if (props.unowned) return '回复后将自动领取此会话';
|
||||
return '输入消息,回车发送';
|
||||
});
|
||||
|
||||
const hint = computed(() => (props.unowned && !props.closed ? '此会话尚无主,回复后由你接管' : ''));
|
||||
|
||||
function onSend(text: string) {
|
||||
emit('send', text);
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,186 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { Conversation, ConversationListItem, Message } from '@/chat-core';
|
||||
import {
|
||||
listConversation,
|
||||
getConversation,
|
||||
getConversationMessages,
|
||||
sendMessage as apiSendMessage,
|
||||
retryMessage as apiRetryMessage,
|
||||
closeConversation as apiCloseConversation,
|
||||
transferConversation as apiTransferConversation
|
||||
} from '@/api/customerservice/conversation';
|
||||
import type {
|
||||
AgentVO,
|
||||
ConversationScope,
|
||||
ReplyMessageRequest,
|
||||
TransferRequest
|
||||
} from '@/api/customerservice/types';
|
||||
import { listAgent } from '@/api/customerservice/agent';
|
||||
import type {
|
||||
IncomingMessagePayload,
|
||||
OutgoingMessageStatePayload,
|
||||
ConversationNewPayload,
|
||||
ConversationClosedPayload,
|
||||
ConversationTransferredPayload,
|
||||
AgentOnlineStatusPayload
|
||||
} from '@/chat-core';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Workbench store: holds the two left-rail lists (mine / unassigned),
|
||||
* the currently opened conversation + ascending message stream, and the
|
||||
* agent roster used by the transfer dialog.
|
||||
*
|
||||
* SSE handlers live here so the workbench page only wires the bus once.
|
||||
*/
|
||||
export const useConversationStore = defineStore('customerservice-conversation', () => {
|
||||
const mineList = ref<ConversationListItem[]>([]);
|
||||
const unassignedList = ref<ConversationListItem[]>([]);
|
||||
const openedConversation = ref<Conversation | null>(null);
|
||||
const messages = ref<Message[]>([]);
|
||||
const noMoreMessages = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const agents = ref<AgentVO[]>([]);
|
||||
|
||||
async function fetchList(scope: ConversationScope) {
|
||||
const res = await listConversation({ scope, status: 'OPEN', pageNum: 1, pageSize: 50 });
|
||||
const rows = (res.data as any).rows ?? [];
|
||||
if (scope === 'mine') mineList.value = rows;
|
||||
else unassignedList.value = rows;
|
||||
}
|
||||
|
||||
async function refreshLists() {
|
||||
await Promise.all([fetchList('mine'), fetchList('unassigned')]);
|
||||
}
|
||||
|
||||
async function openConversation(id: number) {
|
||||
const [detailRes, msgRes] = await Promise.all([
|
||||
getConversation(id),
|
||||
getConversationMessages(id, { pageSize: PAGE_SIZE })
|
||||
]);
|
||||
openedConversation.value = detailRes.data as any;
|
||||
const page = (msgRes.data as any) ?? [];
|
||||
messages.value = [...page].reverse();
|
||||
noMoreMessages.value = page.length < PAGE_SIZE;
|
||||
}
|
||||
|
||||
async function loadOlderMessages() {
|
||||
if (!openedConversation.value || loadingMore.value || noMoreMessages.value) return;
|
||||
if (messages.value.length === 0) return;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const head = messages.value[0];
|
||||
const res = await getConversationMessages(openedConversation.value.id, {
|
||||
before: head.sentAt,
|
||||
beforeId: head.id,
|
||||
pageSize: PAGE_SIZE
|
||||
});
|
||||
const older = ((res.data as any) ?? []) as Message[];
|
||||
if (older.length < PAGE_SIZE) noMoreMessages.value = true;
|
||||
messages.value = [...older.reverse(), ...messages.value];
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReply(body: ReplyMessageRequest) {
|
||||
if (!openedConversation.value) return;
|
||||
const res = await apiSendMessage(openedConversation.value.id, body);
|
||||
messages.value.push(res.data as any);
|
||||
}
|
||||
|
||||
async function retry(messageId: number) {
|
||||
if (!openedConversation.value) return;
|
||||
const res = await apiRetryMessage(openedConversation.value.id, messageId);
|
||||
const updated = res.data as any as Message;
|
||||
const idx = messages.value.findIndex((m) => m.id === updated.id);
|
||||
if (idx >= 0) messages.value[idx] = updated;
|
||||
}
|
||||
|
||||
async function closeOpened(reason?: string) {
|
||||
if (!openedConversation.value) return;
|
||||
await apiCloseConversation(openedConversation.value.id, { reason });
|
||||
}
|
||||
|
||||
async function transferOpened(body: TransferRequest) {
|
||||
if (!openedConversation.value) return;
|
||||
await apiTransferConversation(openedConversation.value.id, body);
|
||||
}
|
||||
|
||||
async function refreshAgents() {
|
||||
const res = await listAgent({ pageSize: 200 });
|
||||
agents.value = ((res.data as any).rows ?? []) as AgentVO[];
|
||||
}
|
||||
|
||||
// ===== SSE handlers =====
|
||||
function onIncomingMessage(p: IncomingMessagePayload) {
|
||||
if (openedConversation.value?.id === p.conversationId) {
|
||||
messages.value.push(p.message);
|
||||
}
|
||||
refreshLists();
|
||||
}
|
||||
|
||||
function onOutgoingMessageState(p: OutgoingMessageStatePayload) {
|
||||
if (openedConversation.value?.id !== p.conversationId) return;
|
||||
const idx = messages.value.findIndex((m) => m.id === p.messageId);
|
||||
if (idx >= 0) {
|
||||
messages.value[idx] = {
|
||||
...messages.value[idx],
|
||||
sendStatus: p.sendStatus,
|
||||
sendErrorCode: p.sendErrorCode,
|
||||
sendErrorMsg: p.sendErrorMsg,
|
||||
sentAt: p.sentAt ?? messages.value[idx].sentAt
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function onConversationNew(_: ConversationNewPayload) {
|
||||
refreshLists();
|
||||
}
|
||||
|
||||
function onConversationClosed(p: ConversationClosedPayload) {
|
||||
if (openedConversation.value?.id === p.conversationId) {
|
||||
openedConversation.value = { ...openedConversation.value, status: 'CLOSED', closedAt: p.closedAt };
|
||||
}
|
||||
refreshLists();
|
||||
}
|
||||
|
||||
function onConversationTransferred(p: ConversationTransferredPayload) {
|
||||
if (openedConversation.value?.id === p.conversationId) {
|
||||
openedConversation.value = { ...openedConversation.value, currentAgentId: p.toAgentId };
|
||||
}
|
||||
refreshLists();
|
||||
}
|
||||
|
||||
function onAgentOnlineStatusChanged(p: AgentOnlineStatusPayload) {
|
||||
const idx = agents.value.findIndex((a) => a.id === p.agentId);
|
||||
if (idx >= 0) agents.value[idx] = { ...agents.value[idx], onlineStatus: p.onlineStatus };
|
||||
}
|
||||
|
||||
return {
|
||||
mineList,
|
||||
unassignedList,
|
||||
openedConversation,
|
||||
messages,
|
||||
noMoreMessages,
|
||||
loadingMore,
|
||||
agents,
|
||||
fetchList,
|
||||
refreshLists,
|
||||
openConversation,
|
||||
loadOlderMessages,
|
||||
sendReply,
|
||||
retry,
|
||||
closeOpened,
|
||||
transferOpened,
|
||||
refreshAgents,
|
||||
onIncomingMessage,
|
||||
onOutgoingMessageState,
|
||||
onConversationNew,
|
||||
onConversationClosed,
|
||||
onConversationTransferred,
|
||||
onAgentOnlineStatusChanged
|
||||
};
|
||||
});
|
||||
128
src/views/customerservice/workbench/index.vue
Normal file
128
src/views/customerservice/workbench/index.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="cs-workbench">
|
||||
<div class="cs-workbench-left">
|
||||
<ConversationList
|
||||
:mine="store.mineList"
|
||||
:unassigned="store.unassignedList"
|
||||
:opened-id="store.openedConversation?.id"
|
||||
@open="onOpen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cs-workbench-center">
|
||||
<div v-if="store.openedConversation" class="cs-workbench-header">
|
||||
<span class="cs-workbench-title">
|
||||
{{ store.openedConversation.mediaUser.nickname || store.openedConversation.mediaUser.externalUserIdInApp }}
|
||||
</span>
|
||||
<div class="cs-workbench-actions">
|
||||
<!-- Task 20 will fill close / transfer buttons here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageStream
|
||||
v-if="store.openedConversation"
|
||||
:messages="store.messages"
|
||||
:loading-more="store.loadingMore"
|
||||
:no-more="store.noMoreMessages"
|
||||
@load-more="store.loadOlderMessages"
|
||||
@retry="(m) => store.retry(m.id)"
|
||||
/>
|
||||
<div v-else class="cs-workbench-empty">请选择左侧的会话开始</div>
|
||||
|
||||
<ReplyEditor
|
||||
v-if="store.openedConversation"
|
||||
:closed="store.openedConversation.status === 'CLOSED'"
|
||||
:unowned="store.openedConversation.currentAgentId == null"
|
||||
@send="onSend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cs-workbench-right">
|
||||
<CustomerCard :conversation="store.openedConversation" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { MessageStream } from '@/chat-ui';
|
||||
import { useSseConversation } from '@/chat-ui';
|
||||
import { useConversationStore } from './composables/useConversationStore';
|
||||
import ConversationList from './components/ConversationList.vue';
|
||||
import CustomerCard from './components/CustomerCard.vue';
|
||||
import ReplyEditor from './components/ReplyEditor.vue';
|
||||
|
||||
const store = useConversationStore();
|
||||
const sse = useSseConversation();
|
||||
|
||||
onMounted(async () => {
|
||||
await store.refreshLists();
|
||||
await store.refreshAgents();
|
||||
});
|
||||
|
||||
// Wire SSE handlers — chat-core SseSubscriber dispatches by msgType.
|
||||
sse.on('INCOMING_MESSAGE', store.onIncomingMessage);
|
||||
sse.on('OUTGOING_MESSAGE_STATE', store.onOutgoingMessageState);
|
||||
sse.on('CONVERSATION_NEW', store.onConversationNew);
|
||||
sse.on('CONVERSATION_CLOSED', store.onConversationClosed);
|
||||
sse.on('CONVERSATION_TRANSFERRED', store.onConversationTransferred);
|
||||
sse.on('AGENT_ONLINE_STATUS', store.onAgentOnlineStatusChanged);
|
||||
|
||||
// Re-align after SSE reconnect: refetch lists (spec 05-sse.md §6).
|
||||
sse.onReconnect(() => {
|
||||
store.refreshLists();
|
||||
if (store.openedConversation) store.openConversation(store.openedConversation.id);
|
||||
});
|
||||
|
||||
async function onOpen(id: number) {
|
||||
await store.openConversation(id);
|
||||
}
|
||||
|
||||
async function onSend(text: string) {
|
||||
await store.sendReply({ msgType: 'TEXT', contentText: text });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cs-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 280px;
|
||||
height: calc(100vh - 84px);
|
||||
background: var(--chat-bg);
|
||||
}
|
||||
|
||||
.cs-workbench-left,
|
||||
.cs-workbench-right {
|
||||
background: #fff;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cs-workbench-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--chat-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cs-workbench-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--chat-border-color);
|
||||
}
|
||||
|
||||
.cs-workbench-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cs-workbench-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
</style>
|
||||
20
vitest.config.ts
Normal file
20
vitest.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Standalone Vitest config: bypasses the heavy Vite plugin chain (unocss,
|
||||
* vue-devtools, etc.) used for the dev server. Tests are pure logic so we
|
||||
* don't need the SFC compiler or unocss transforms.
|
||||
*/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user