From 5c8b6dc0149773a7ff8419d4595d17597c4de29d Mon Sep 17 00:00:00 2001 From: i548450 Date: Tue, 9 Jun 2026 21:45:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(customerservice):=20chat-core=20+=20chat-u?= =?UTF-8?q?i=20+=20workbench=20=E4=B8=89=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 链 --- src/api/customerservice/agent.ts | 38 ++++ src/api/customerservice/conversation.ts | 84 ++++++++ src/api/customerservice/mediaUser.ts | 11 ++ src/api/customerservice/message.ts | 5 + src/api/customerservice/types.ts | 79 ++++++++ src/chat-core/__tests__/SseSubscriber.test.ts | 111 +++++++++++ src/chat-core/__tests__/utils.test.ts | 64 ++++++ src/chat-core/index.ts | 9 + src/chat-core/sse/SseSubscriber.ts | 73 +++++++ src/chat-core/types/conversation.ts | 53 +++++ src/chat-core/types/message.ts | 68 +++++++ src/chat-core/types/sse.ts | 91 +++++++++ src/chat-core/utils/content.ts | 32 +++ src/chat-core/utils/time.ts | 37 ++++ .../components/ConversationDivider.vue | 27 +++ src/chat-ui/components/DateDivider.vue | 23 +++ src/chat-ui/components/MessageBubble.vue | 169 ++++++++++++++++ src/chat-ui/components/MessageInput.vue | 68 +++++++ src/chat-ui/components/MessageStateBadge.vue | 42 ++++ src/chat-ui/components/MessageStream.vue | 114 +++++++++++ src/chat-ui/composables/useMessageStream.ts | 75 +++++++ src/chat-ui/composables/useSseConversation.ts | 33 ++++ src/chat-ui/index.ts | 13 ++ src/chat-ui/styles/default.scss | 18 ++ .../workbench/components/ConversationList.vue | 152 ++++++++++++++ .../workbench/components/CustomerCard.vue | 83 ++++++++ .../workbench/components/ReplyEditor.vue | 36 ++++ .../composables/useConversationStore.ts | 186 ++++++++++++++++++ src/views/customerservice/workbench/index.vue | 128 ++++++++++++ vitest.config.ts | 20 ++ 30 files changed, 1942 insertions(+) create mode 100644 src/api/customerservice/agent.ts create mode 100644 src/api/customerservice/conversation.ts create mode 100644 src/api/customerservice/mediaUser.ts create mode 100644 src/api/customerservice/message.ts create mode 100644 src/api/customerservice/types.ts create mode 100644 src/chat-core/__tests__/SseSubscriber.test.ts create mode 100644 src/chat-core/__tests__/utils.test.ts create mode 100644 src/chat-core/index.ts create mode 100644 src/chat-core/sse/SseSubscriber.ts create mode 100644 src/chat-core/types/conversation.ts create mode 100644 src/chat-core/types/message.ts create mode 100644 src/chat-core/types/sse.ts create mode 100644 src/chat-core/utils/content.ts create mode 100644 src/chat-core/utils/time.ts create mode 100644 src/chat-ui/components/ConversationDivider.vue create mode 100644 src/chat-ui/components/DateDivider.vue create mode 100644 src/chat-ui/components/MessageBubble.vue create mode 100644 src/chat-ui/components/MessageInput.vue create mode 100644 src/chat-ui/components/MessageStateBadge.vue create mode 100644 src/chat-ui/components/MessageStream.vue create mode 100644 src/chat-ui/composables/useMessageStream.ts create mode 100644 src/chat-ui/composables/useSseConversation.ts create mode 100644 src/chat-ui/index.ts create mode 100644 src/chat-ui/styles/default.scss create mode 100644 src/views/customerservice/workbench/components/ConversationList.vue create mode 100644 src/views/customerservice/workbench/components/CustomerCard.vue create mode 100644 src/views/customerservice/workbench/components/ReplyEditor.vue create mode 100644 src/views/customerservice/workbench/composables/useConversationStore.ts create mode 100644 src/views/customerservice/workbench/index.vue create mode 100644 vitest.config.ts diff --git a/src/api/customerservice/agent.ts b/src/api/customerservice/agent.ts new file mode 100644 index 00000000..198ff956 --- /dev/null +++ b/src/api/customerservice/agent.ts @@ -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> => { + return request({ + url: '/customerservice/agent', + method: 'get', + params: query + }); +}; + +/** POST /customerservice/agent — create agent from existing sys_user. */ +export const createAgent = (body: CreateAgentRequest): AxiosPromise => { + 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' + }); +}; diff --git a/src/api/customerservice/conversation.ts b/src/api/customerservice/conversation.ts new file mode 100644 index 00000000..7d95c592 --- /dev/null +++ b/src/api/customerservice/conversation.ts @@ -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. */ +export interface TableDataInfo { + total: number; + rows: T[]; + code: number; + msg: string; +} + +/** GET /customerservice/conversation?scope=... */ +export const listConversation = (query: ConversationListQuery): AxiosPromise> => { + return request({ + url: '/customerservice/conversation', + method: 'get', + params: query + }); +}; + +/** GET /customerservice/conversation/{id} */ +export const getConversation = (id: number): AxiosPromise => { + 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 => { + 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 => { + 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 => { + 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 + }); +}; diff --git a/src/api/customerservice/mediaUser.ts b/src/api/customerservice/mediaUser.ts new file mode 100644 index 00000000..5ce61913 --- /dev/null +++ b/src/api/customerservice/mediaUser.ts @@ -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 => { + return request({ + url: '/customerservice/media-user/' + id, + method: 'get' + }); +}; diff --git a/src/api/customerservice/message.ts b/src/api/customerservice/message.ts new file mode 100644 index 00000000..f58ba9de --- /dev/null +++ b/src/api/customerservice/message.ts @@ -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'; diff --git a/src/api/customerservice/types.ts b/src/api/customerservice/types.ts new file mode 100644 index 00000000..d04cae3e --- /dev/null +++ b/src/api/customerservice/types.ts @@ -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; +} diff --git a/src/chat-core/__tests__/SseSubscriber.test.ts b/src/chat-core/__tests__/SseSubscriber.test.ts new file mode 100644 index 00000000..9e4f632b --- /dev/null +++ b/src/chat-core/__tests__/SseSubscriber.test.ts @@ -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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + 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>(); + const subscriber = new SseSubscriber(bus); + const onReconnect = vi.fn(); + subscriber.onReconnect(onReconnect); + + bus.emit('reconnect', undefined); + + expect(onReconnect).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/chat-core/__tests__/utils.test.ts b/src/chat-core/__tests__/utils.test.ts new file mode 100644 index 00000000..44c97a57 --- /dev/null +++ b/src/chat-core/__tests__/utils.test.ts @@ -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([]); + }); +}); diff --git a/src/chat-core/index.ts b/src/chat-core/index.ts new file mode 100644 index 00000000..a8ec146d --- /dev/null +++ b/src/chat-core/index.ts @@ -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'; diff --git a/src/chat-core/sse/SseSubscriber.ts b/src/chat-core/sse/SseSubscriber.ts new file mode 100644 index 00000000..d9780b6c --- /dev/null +++ b/src/chat-core/sse/SseSubscriber.ts @@ -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 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) { + 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(msgType: K, handler: SseHandler): 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(msgType: K, handler: SseHandler): 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); + } + }); + } +} diff --git a/src/chat-core/types/conversation.ts b/src/chat-core/types/conversation.ts new file mode 100644 index 00000000..bf7029a8 --- /dev/null +++ b/src/chat-core/types/conversation.ts @@ -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; +} diff --git a/src/chat-core/types/message.ts b/src/chat-core/types/message.ts new file mode 100644 index 00000000..66a6569a --- /dev/null +++ b/src/chat-core/types/message.ts @@ -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; +} diff --git a/src/chat-core/types/sse.ts b/src/chat-core/types/sse.ts new file mode 100644 index 00000000..2a6e6d04 --- /dev/null +++ b/src/chat-core/types/sse.ts @@ -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 { + 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 = (data: SsePayloadMap[K]) => void; diff --git a/src/chat-core/utils/content.ts b/src/chat-core/utils/content.ts new file mode 100644 index 00000000..75c8d638 --- /dev/null +++ b/src/chat-core/utils/content.ts @@ -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; +} diff --git a/src/chat-core/utils/time.ts b/src/chat-core/utils/time.ts new file mode 100644 index 00000000..284d2035 --- /dev/null +++ b/src/chat-core/utils/time.ts @@ -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}`; +} diff --git a/src/chat-ui/components/ConversationDivider.vue b/src/chat-ui/components/ConversationDivider.vue new file mode 100644 index 00000000..3b73ab76 --- /dev/null +++ b/src/chat-ui/components/ConversationDivider.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/chat-ui/components/DateDivider.vue b/src/chat-ui/components/DateDivider.vue new file mode 100644 index 00000000..24aec25e --- /dev/null +++ b/src/chat-ui/components/DateDivider.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/chat-ui/components/MessageBubble.vue b/src/chat-ui/components/MessageBubble.vue new file mode 100644 index 00000000..6fd3c560 --- /dev/null +++ b/src/chat-ui/components/MessageBubble.vue @@ -0,0 +1,169 @@ + + + + + diff --git a/src/chat-ui/components/MessageInput.vue b/src/chat-ui/components/MessageInput.vue new file mode 100644 index 00000000..d6a5782f --- /dev/null +++ b/src/chat-ui/components/MessageInput.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/src/chat-ui/components/MessageStateBadge.vue b/src/chat-ui/components/MessageStateBadge.vue new file mode 100644 index 00000000..030e4ef9 --- /dev/null +++ b/src/chat-ui/components/MessageStateBadge.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/src/chat-ui/components/MessageStream.vue b/src/chat-ui/components/MessageStream.vue new file mode 100644 index 00000000..7a6f11c5 --- /dev/null +++ b/src/chat-ui/components/MessageStream.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/chat-ui/composables/useMessageStream.ts b/src/chat-ui/composables/useMessageStream.ts new file mode 100644 index 00000000..5ab673e3 --- /dev/null +++ b/src/chat-ui/composables/useMessageStream.ts @@ -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; + /** Load older messages strictly before `before/beforeId`. */ + loadOlder: (before: string, beforeId: number, pageSize: number) => Promise; +} + +/** + * 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([]); + 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) { + 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 + }; +} diff --git a/src/chat-ui/composables/useSseConversation.ts b/src/chat-ui/composables/useSseConversation.ts new file mode 100644 index 00000000..5dcd8d55 --- /dev/null +++ b/src/chat-ui/composables/useSseConversation.ts @@ -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(msgType: K, handler: SseHandler) { + subscriber?.on(msgType, handler); + }, + off(msgType: K, handler: SseHandler) { + subscriber?.off(msgType, handler); + }, + onReconnect(handler: () => void) { + subscriber?.onReconnect(handler); + } + }; +} diff --git a/src/chat-ui/index.ts b/src/chat-ui/index.ts new file mode 100644 index 00000000..a0edf041 --- /dev/null +++ b/src/chat-ui/index.ts @@ -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'; diff --git a/src/chat-ui/styles/default.scss b/src/chat-ui/styles/default.scss new file mode 100644 index 00000000..84a01086 --- /dev/null +++ b/src/chat-ui/styles/default.scss @@ -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; +} diff --git a/src/views/customerservice/workbench/components/ConversationList.vue b/src/views/customerservice/workbench/components/ConversationList.vue new file mode 100644 index 00000000..7c5c578e --- /dev/null +++ b/src/views/customerservice/workbench/components/ConversationList.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/views/customerservice/workbench/components/CustomerCard.vue b/src/views/customerservice/workbench/components/CustomerCard.vue new file mode 100644 index 00000000..278c4b5c --- /dev/null +++ b/src/views/customerservice/workbench/components/CustomerCard.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/src/views/customerservice/workbench/components/ReplyEditor.vue b/src/views/customerservice/workbench/components/ReplyEditor.vue new file mode 100644 index 00000000..1547dffa --- /dev/null +++ b/src/views/customerservice/workbench/components/ReplyEditor.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/views/customerservice/workbench/composables/useConversationStore.ts b/src/views/customerservice/workbench/composables/useConversationStore.ts new file mode 100644 index 00000000..c5fa4dae --- /dev/null +++ b/src/views/customerservice/workbench/composables/useConversationStore.ts @@ -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([]); + const unassignedList = ref([]); + const openedConversation = ref(null); + const messages = ref([]); + const noMoreMessages = ref(false); + const loadingMore = ref(false); + const agents = ref([]); + + 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 + }; +}); diff --git a/src/views/customerservice/workbench/index.vue b/src/views/customerservice/workbench/index.vue new file mode 100644 index 00000000..bae3db0a --- /dev/null +++ b/src/views/customerservice/workbench/index.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..f732aef9 --- /dev/null +++ b/vitest.config.ts @@ -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 + } +});