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:
i548450 2026-06-09 21:45:34 +08:00
parent fe8e294775
commit 5c8b6dc014
30 changed files with 1942 additions and 0 deletions

View 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'
});
};

View 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
});
};

View 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'
});
};

View 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';

View 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;
}

View 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();
});
});

View 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
View 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';

View 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);
}
});
}
}

View 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;
}

View 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;
}

View 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;

View 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;
}

View 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}`;
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
};
}

View 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
View 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';

View 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;
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
};
});

View 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
View 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
}
});