mirror of
https://gitee.com/JavaLionLi/plus-ui.git
synced 2026-09-13 07:43:43 +08:00
Pre Merge pull request !271 from cnstevenwang/customerservice/chat-core-ui
This commit is contained in:
commit
b34b1ba123
@ -6,7 +6,7 @@ VITE_APP_LOGO_TITLE = RuoYi-Vue-Plus
|
||||
VITE_APP_ENV = 'development'
|
||||
|
||||
# 开发环境
|
||||
VITE_APP_BASE_API = '/dev-api'
|
||||
VITE_APP_BASE_API = '/api'
|
||||
|
||||
# 应用访问路径 例如使用前缀 /admin/
|
||||
VITE_APP_CONTEXT_PATH = '/'
|
||||
|
||||
@ -15,7 +15,7 @@ VITE_APP_MONITOR_ADMIN = '/admin/applications'
|
||||
VITE_APP_SNAILJOB_ADMIN = '/snail-job'
|
||||
|
||||
# 生产环境
|
||||
VITE_APP_BASE_API = '/prod-api'
|
||||
VITE_APP_BASE_API = '/api'
|
||||
|
||||
# 是否在打包时开启压缩,支持 gzip 和 brotli
|
||||
VITE_BUILD_COMPRESS = gzip
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
"image-conversion": "2.1.1",
|
||||
"js-cookie": "3.0.5",
|
||||
"jsencrypt": "3.5.4",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "0.2.0",
|
||||
"pinia": "3.0.4",
|
||||
"screenfull": "6.0.2",
|
||||
|
||||
38
src/api/customerservice/agent.ts
Normal file
38
src/api/customerservice/agent.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import type { TableDataInfo } from './conversation';
|
||||
import type { AgentListQuery, AgentVO, CreateAgentRequest } from './types';
|
||||
|
||||
/** GET /customerservice/agent */
|
||||
export const listAgent = (query?: AgentListQuery): AxiosPromise<TableDataInfo<AgentVO>> => {
|
||||
return request({
|
||||
url: '/customerservice/agent',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/agent — create agent from existing sys_user. */
|
||||
export const createAgent = (body: CreateAgentRequest): AxiosPromise<AgentVO> => {
|
||||
return request({
|
||||
url: '/customerservice/agent',
|
||||
method: 'post',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
|
||||
/** PUT /customerservice/agent/{id}/enable */
|
||||
export const enableAgent = (id: number) => {
|
||||
return request({
|
||||
url: '/customerservice/agent/' + id + '/enable',
|
||||
method: 'put'
|
||||
});
|
||||
};
|
||||
|
||||
/** PUT /customerservice/agent/{id}/disable */
|
||||
export const disableAgent = (id: number) => {
|
||||
return request({
|
||||
url: '/customerservice/agent/' + id + '/disable',
|
||||
method: 'put'
|
||||
});
|
||||
};
|
||||
84
src/api/customerservice/conversation.ts
Normal file
84
src/api/customerservice/conversation.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import type {
|
||||
Conversation,
|
||||
ConversationListItem,
|
||||
ConversationListQuery,
|
||||
Message,
|
||||
MessageStreamQuery,
|
||||
ReplyMessageRequest,
|
||||
TransferRequest,
|
||||
CloseRequest
|
||||
} from './types';
|
||||
|
||||
/** Backend wraps list responses in TableDataInfo<T>. */
|
||||
export interface TableDataInfo<T> {
|
||||
total: number;
|
||||
rows: T[];
|
||||
code: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
/** GET /customerservice/conversation?scope=... */
|
||||
export const listConversation = (query: ConversationListQuery): AxiosPromise<TableDataInfo<ConversationListItem>> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** GET /customerservice/conversation/{id} */
|
||||
export const getConversation = (id: number): AxiosPromise<Conversation> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/** GET /customerservice/conversation/{id}/messages — customer-visible stream, desc by sentAt. */
|
||||
export const getConversationMessages = (
|
||||
id: number,
|
||||
query?: MessageStreamQuery
|
||||
): AxiosPromise<Message[]> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/messages',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/conversation/{id}/message — agent reply. */
|
||||
export const sendMessage = (id: number, body: ReplyMessageRequest): AxiosPromise<Message> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/message',
|
||||
method: 'post',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/conversation/{convId}/message/{msgId}/retry — only FAILED/STUCK. */
|
||||
export const retryMessage = (convId: number, msgId: number): AxiosPromise<Message> => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + convId + '/message/' + msgId + '/retry',
|
||||
method: 'post'
|
||||
});
|
||||
};
|
||||
|
||||
/** PUT /customerservice/conversation/{id}/close */
|
||||
export const closeConversation = (id: number, body: CloseRequest = {}) => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/close',
|
||||
method: 'put',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
|
||||
/** POST /customerservice/conversation/{id}/transfer */
|
||||
export const transferConversation = (id: number, body: TransferRequest) => {
|
||||
return request({
|
||||
url: '/customerservice/conversation/' + id + '/transfer',
|
||||
method: 'post',
|
||||
data: body
|
||||
});
|
||||
};
|
||||
11
src/api/customerservice/mediaUser.ts
Normal file
11
src/api/customerservice/mediaUser.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import type { MediaUserVO } from './types';
|
||||
|
||||
/** GET /customerservice/media-user/{id} */
|
||||
export const getMediaUser = (id: number): AxiosPromise<MediaUserVO> => {
|
||||
return request({
|
||||
url: '/customerservice/media-user/' + id,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
5
src/api/customerservice/message.ts
Normal file
5
src/api/customerservice/message.ts
Normal file
@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Standalone retry endpoint export (also reachable via conversation.ts).
|
||||
* Kept for symmetry with the backend module split.
|
||||
*/
|
||||
export { retryMessage } from './conversation';
|
||||
79
src/api/customerservice/types.ts
Normal file
79
src/api/customerservice/types.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Frontend types mirroring backend customerservice VOs.
|
||||
* Re-exports core types and adds API-specific request/response shapes.
|
||||
*/
|
||||
|
||||
import type { ConversationListItem, Conversation } from '@/chat-core';
|
||||
import type { Message } from '@/chat-core';
|
||||
|
||||
export type { Conversation, ConversationListItem, Message };
|
||||
|
||||
export type ConversationScope = 'mine' | 'unassigned';
|
||||
|
||||
export interface ConversationListQuery {
|
||||
scope: ConversationScope;
|
||||
status?: 'OPEN' | 'CLOSED';
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MessageStreamQuery {
|
||||
before?: string;
|
||||
beforeId?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface ReplyMessageRequest {
|
||||
msgType: 'TEXT' | 'IMAGE' | 'VIDEO';
|
||||
contentText?: string;
|
||||
mediaUrl?: string;
|
||||
}
|
||||
|
||||
export interface TransferRequest {
|
||||
toAgentId: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CloseRequest {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export type AgentOnlineStatus = 'ONLINE' | 'OFFLINE';
|
||||
export type AgentType = 'HUMAN' | 'AI';
|
||||
export type AgentStatus = 'ENABLED' | 'DISABLED';
|
||||
|
||||
export interface AgentVO {
|
||||
id: number;
|
||||
userId: number;
|
||||
agentType: AgentType;
|
||||
status: AgentStatus;
|
||||
onlineStatus: AgentOnlineStatus;
|
||||
user: {
|
||||
nickname: string;
|
||||
avatar?: string;
|
||||
};
|
||||
lastOnlineAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentListQuery {
|
||||
onlineStatus?: AgentOnlineStatus;
|
||||
agentType?: AgentType;
|
||||
status?: AgentStatus;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
userId: number;
|
||||
agentType?: AgentType;
|
||||
}
|
||||
|
||||
export interface MediaUserVO {
|
||||
id: number;
|
||||
externalUserIdInApp: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
platform: string;
|
||||
appId: number;
|
||||
lastContactAt?: string;
|
||||
}
|
||||
36
src/api/media/account/index.ts
Normal file
36
src/api/media/account/index.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { MediaAccountVO, MediaAccountQuery } from '@/api/media/account/types';
|
||||
|
||||
/** 分页查询媒体账号 */
|
||||
export const listMediaAccount = (query?: MediaAccountQuery): AxiosPromise<MediaAccountVO[]> => {
|
||||
return request({
|
||||
url: '/media/account/list',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
|
||||
/** 查询媒体账号详情(token 已脱敏) */
|
||||
export const getMediaAccount = (id: string | number): AxiosPromise<MediaAccountVO> => {
|
||||
return request({
|
||||
url: '/media/account/' + id,
|
||||
method: 'get'
|
||||
});
|
||||
};
|
||||
|
||||
/** 禁用媒体账号 */
|
||||
export const disableMediaAccount = (id: string | number) => {
|
||||
return request({
|
||||
url: '/media/account/' + id + '/disable',
|
||||
method: 'put'
|
||||
});
|
||||
};
|
||||
|
||||
/** 启用媒体账号 */
|
||||
export const enableMediaAccount = (id: string | number) => {
|
||||
return request({
|
||||
url: '/media/account/' + id + '/enable',
|
||||
method: 'put'
|
||||
});
|
||||
};
|
||||
27
src/api/media/account/types.ts
Normal file
27
src/api/media/account/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export interface MediaAccountVO {
|
||||
id: string | number;
|
||||
tenantId: string;
|
||||
platform: string;
|
||||
appId: string | number;
|
||||
externalId: string;
|
||||
/** ENABLED / DISABLED */
|
||||
status: string;
|
||||
/** HEALTHY / REAUTH_REQUIRED */
|
||||
authStatus: string;
|
||||
accessTokenMasked?: string;
|
||||
accessTokenExpiresAt?: string;
|
||||
refreshTokenMasked?: string;
|
||||
refreshTokenExpiresAt?: string;
|
||||
scope?: string;
|
||||
refreshRenewRemainingCount?: number;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
export interface MediaAccountQuery extends PageQuery {
|
||||
platform?: string;
|
||||
appId?: string | number;
|
||||
status?: string;
|
||||
authStatus?: string;
|
||||
externalId?: string;
|
||||
}
|
||||
12
src/api/media/app/index.ts
Normal file
12
src/api/media/app/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { MediaAppVO } from '@/api/media/app/types';
|
||||
|
||||
/** 列出可用媒体应用(按平台过滤) */
|
||||
export const listMediaApp = (platform?: string): AxiosPromise<MediaAppVO[]> => {
|
||||
return request({
|
||||
url: '/media/app/list',
|
||||
method: 'get',
|
||||
params: { platform }
|
||||
});
|
||||
};
|
||||
7
src/api/media/app/types.ts
Normal file
7
src/api/media/app/types.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface MediaAppVO {
|
||||
id: string | number;
|
||||
platform: string;
|
||||
clientKey: string;
|
||||
/** 0=禁用 1=启用 */
|
||||
status: number;
|
||||
}
|
||||
12
src/api/media/oauth/index.ts
Normal file
12
src/api/media/oauth/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import request from '@/utils/request';
|
||||
import { AxiosPromise } from 'axios';
|
||||
import { AuthorizeUrlQuery } from '@/api/media/oauth/types';
|
||||
|
||||
/** 取媒体平台 OAuth 授权 URL */
|
||||
export const getAuthorizeUrl = (query: AuthorizeUrlQuery): AxiosPromise<string> => {
|
||||
return request({
|
||||
url: '/media/oauth/authorize-url',
|
||||
method: 'get',
|
||||
params: query
|
||||
});
|
||||
};
|
||||
4
src/api/media/oauth/types.ts
Normal file
4
src/api/media/oauth/types.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface AuthorizeUrlQuery {
|
||||
platform: string;
|
||||
mediaAppId: string | number;
|
||||
}
|
||||
111
src/chat-core/__tests__/SseSubscriber.test.ts
Normal file
111
src/chat-core/__tests__/SseSubscriber.test.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import mitt from 'mitt';
|
||||
import { SseSubscriber } from '@/chat-core/sse/SseSubscriber';
|
||||
import { SSE_BUS_EVENT } from '@/chat-core/types/sse';
|
||||
|
||||
describe('SseSubscriber', () => {
|
||||
it('parses customerservice event and dispatches by msgType', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const onIncoming = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', onIncoming);
|
||||
|
||||
const payload = {
|
||||
msgType: 'INCOMING_MESSAGE',
|
||||
data: { conversationId: 1, message: { id: 99 } }
|
||||
};
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify(payload));
|
||||
|
||||
expect(onIncoming).toHaveBeenCalledOnce();
|
||||
expect(onIncoming).toHaveBeenCalledWith(payload.data);
|
||||
});
|
||||
|
||||
it('dispatches different msgTypes to their own handlers', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const onIncoming = vi.fn();
|
||||
const onClosed = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', onIncoming);
|
||||
subscriber.on('CONVERSATION_CLOSED', onClosed);
|
||||
|
||||
bus.emit(
|
||||
SSE_BUS_EVENT,
|
||||
JSON.stringify({ msgType: 'CONVERSATION_CLOSED', data: { conversationId: 7 } })
|
||||
);
|
||||
|
||||
expect(onIncoming).not.toHaveBeenCalled();
|
||||
expect(onClosed).toHaveBeenCalledWith({ conversationId: 7 });
|
||||
});
|
||||
|
||||
it('off() removes a previously registered handler', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
subscriber.off('INCOMING_MESSAGE', handler);
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: {} }));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispose() unsubscribes from the bus', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
subscriber.dispose();
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: {} }));
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores invalid JSON without throwing', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, 'not-json');
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores payload missing msgType', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const handler = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', handler);
|
||||
|
||||
bus.emit(SSE_BUS_EVENT, JSON.stringify({ data: {} }));
|
||||
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('multiple handlers on the same msgType all fire', () => {
|
||||
const bus = mitt<Record<string, string>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const a = vi.fn();
|
||||
const b = vi.fn();
|
||||
subscriber.on('INCOMING_MESSAGE', a);
|
||||
subscriber.on('INCOMING_MESSAGE', b);
|
||||
|
||||
bus.emit(
|
||||
SSE_BUS_EVENT,
|
||||
JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: { id: 1 } })
|
||||
);
|
||||
|
||||
expect(a).toHaveBeenCalledWith({ id: 1 });
|
||||
expect(b).toHaveBeenCalledWith({ id: 1 });
|
||||
});
|
||||
|
||||
it('onReconnect handler fires when bus emits the reconnect signal', () => {
|
||||
const bus = mitt<Record<string, any>>();
|
||||
const subscriber = new SseSubscriber(bus);
|
||||
const onReconnect = vi.fn();
|
||||
subscriber.onReconnect(onReconnect);
|
||||
|
||||
bus.emit('reconnect', undefined);
|
||||
|
||||
expect(onReconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
64
src/chat-core/__tests__/utils.test.ts
Normal file
64
src/chat-core/__tests__/utils.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatRelativeTime } from '@/chat-core/utils/time';
|
||||
import { extractUrlsAsLinks } from '@/chat-core/utils/content';
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = new Date('2026-06-08T15:00:00');
|
||||
|
||||
it('returns 刚刚 within last minute', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-08T14:59:30'), now)).toBe('刚刚');
|
||||
});
|
||||
|
||||
it('returns N 分钟前 within the hour', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-08T14:55:00'), now)).toBe('5 分钟前');
|
||||
});
|
||||
|
||||
it('returns HH:mm for earlier today (>1h ago)', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-08T09:30:00'), now)).toBe('09:30');
|
||||
});
|
||||
|
||||
it('returns 昨天 HH:mm for yesterday', () => {
|
||||
expect(formatRelativeTime(new Date('2026-06-07T18:05:00'), now)).toBe('昨天 18:05');
|
||||
});
|
||||
|
||||
it('returns MM/DD HH:mm for earlier this year', () => {
|
||||
expect(formatRelativeTime(new Date('2026-03-01T10:00:00'), now)).toBe('03/01 10:00');
|
||||
});
|
||||
|
||||
it('returns YYYY/MM/DD for prior years', () => {
|
||||
expect(formatRelativeTime(new Date('2025-12-31T23:59:00'), now)).toBe('2025/12/31');
|
||||
});
|
||||
|
||||
it('returns empty string for invalid dates', () => {
|
||||
expect(formatRelativeTime('not-a-date', now)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractUrlsAsLinks', () => {
|
||||
it('returns single text segment when no URL', () => {
|
||||
expect(extractUrlsAsLinks('hello world')).toEqual([{ type: 'text', value: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('marks a single URL as link segment', () => {
|
||||
expect(extractUrlsAsLinks('see https://example.com now')).toEqual([
|
||||
{ type: 'text', value: 'see ' },
|
||||
{ type: 'link', value: 'https://example.com' },
|
||||
{ type: 'text', value: ' now' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles multiple URLs', () => {
|
||||
const out = extractUrlsAsLinks('a http://x.io b https://y.com c');
|
||||
expect(out).toEqual([
|
||||
{ type: 'text', value: 'a ' },
|
||||
{ type: 'link', value: 'http://x.io' },
|
||||
{ type: 'text', value: ' b ' },
|
||||
{ type: 'link', value: 'https://y.com' },
|
||||
{ type: 'text', value: ' c' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(extractUrlsAsLinks('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
9
src/chat-core/index.ts
Normal file
9
src/chat-core/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
// Chat-core: framework-agnostic types + SSE subscriber + utils.
|
||||
// Aim: extractable into an npm package later — keep zero Vue/Element-Plus deps.
|
||||
|
||||
export * from './types/message';
|
||||
export * from './types/conversation';
|
||||
export * from './types/sse';
|
||||
export * from './sse/SseSubscriber';
|
||||
export * from './utils/time';
|
||||
export * from './utils/content';
|
||||
73
src/chat-core/sse/SseSubscriber.ts
Normal file
73
src/chat-core/sse/SseSubscriber.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import type { Emitter } from 'mitt';
|
||||
import { SSE_BUS_EVENT, type SseHandler, type SseMsgType, type SsePayload } from '../types/sse';
|
||||
|
||||
/**
|
||||
* Subscribes to a shared SSE event bus (sse.ts → mitt) and dispatches
|
||||
* customerservice payloads to msgType-keyed handlers.
|
||||
*
|
||||
* Does NOT create its own EventSource. The host bus is provided by
|
||||
* frontend/src/utils/sseEventBus.getSseEventBus().
|
||||
*/
|
||||
export class SseSubscriber {
|
||||
private readonly handlers: Map<SseMsgType, Set<(data: any) => void>> = new Map();
|
||||
private readonly reconnectHandlers: Set<() => void> = new Set();
|
||||
private readonly busListener: (raw: string) => void;
|
||||
private readonly reconnectListener: () => void;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly bus: Emitter<any>) {
|
||||
this.busListener = (raw: string) => this.handleRaw(raw);
|
||||
this.reconnectListener = () => this.reconnectHandlers.forEach((h) => h());
|
||||
this.bus.on(SSE_BUS_EVENT, this.busListener);
|
||||
this.bus.on('reconnect', this.reconnectListener);
|
||||
}
|
||||
|
||||
on<K extends SseMsgType>(msgType: K, handler: SseHandler<K>): void {
|
||||
if (this.disposed) return;
|
||||
let set = this.handlers.get(msgType);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.handlers.set(msgType, set);
|
||||
}
|
||||
set.add(handler as (data: any) => void);
|
||||
}
|
||||
|
||||
off<K extends SseMsgType>(msgType: K, handler: SseHandler<K>): void {
|
||||
this.handlers.get(msgType)?.delete(handler as (data: any) => void);
|
||||
}
|
||||
|
||||
onReconnect(handler: () => void): void {
|
||||
if (this.disposed) return;
|
||||
this.reconnectHandlers.add(handler);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.bus.off(SSE_BUS_EVENT, this.busListener);
|
||||
this.bus.off('reconnect', this.reconnectListener);
|
||||
this.handlers.clear();
|
||||
this.reconnectHandlers.clear();
|
||||
this.disposed = true;
|
||||
}
|
||||
|
||||
private handleRaw(raw: string): void {
|
||||
let envelope: SsePayload | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && typeof parsed.msgType === 'string') {
|
||||
envelope = parsed as SsePayload;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!envelope) return;
|
||||
const set = this.handlers.get(envelope.msgType);
|
||||
if (!set) return;
|
||||
set.forEach((h) => {
|
||||
try {
|
||||
h(envelope!.data);
|
||||
} catch (err) {
|
||||
console.error('[SseSubscriber] handler threw for', envelope!.msgType, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
53
src/chat-core/types/conversation.ts
Normal file
53
src/chat-core/types/conversation.ts
Normal file
@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Conversation-related type definitions, mirroring backend
|
||||
* customerservice_conversation and ConversationVo (spec 04-api.md §2).
|
||||
*/
|
||||
|
||||
export type ConversationStatus = 'OPEN' | 'CLOSED';
|
||||
|
||||
export interface MediaUserSummary {
|
||||
id: number;
|
||||
externalUserIdInApp?: string;
|
||||
nickname?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface MediaAccountSummary {
|
||||
id: number;
|
||||
appId: number;
|
||||
platform: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Item for the left-rail conversation list. Carries enough to render the
|
||||
* row without an extra detail call.
|
||||
*/
|
||||
export interface ConversationListItem {
|
||||
id: number;
|
||||
status: ConversationStatus;
|
||||
currentAgentId?: number;
|
||||
hasSendFailure?: boolean;
|
||||
lastMsgAt?: string;
|
||||
lastMsgPreview?: string;
|
||||
unreadCount?: number;
|
||||
mediaUser: MediaUserSummary;
|
||||
mediaAccount: MediaAccountSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detail returned by GET /customerservice/conversation/{id}.
|
||||
*/
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
tenantId: string;
|
||||
status: ConversationStatus;
|
||||
currentAgentId?: number;
|
||||
hasSendFailure?: boolean;
|
||||
externalConversationId?: string;
|
||||
createdAt: string;
|
||||
closedAt?: string;
|
||||
lastMsgAt?: string;
|
||||
mediaUser: MediaUserSummary;
|
||||
mediaAccount: MediaAccountSummary;
|
||||
}
|
||||
80
src/chat-core/types/message.ts
Normal file
80
src/chat-core/types/message.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Message sender type — polymorphic discriminator for (senderType, senderId).
|
||||
* INBOUND → MEDIA_USER, OUTBOUND → MEDIA_ACCOUNT, SYSTEM → SYSTEM (id is null).
|
||||
* Agent never appears here; the operator is tracked via operatorAgentId.
|
||||
*/
|
||||
export type MessageSenderType = 'MEDIA_USER' | 'MEDIA_ACCOUNT' | 'SYSTEM';
|
||||
|
||||
/**
|
||||
* Message receiver type — polymorphic discriminator for (receiverType, receiverId).
|
||||
* Mirror of sender side: INBOUND → MEDIA_ACCOUNT, OUTBOUND → MEDIA_USER,
|
||||
* SYSTEM → SYSTEM (id is null).
|
||||
*/
|
||||
export type MessageReceiverType = '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: MessageSenderType;
|
||||
senderId?: number;
|
||||
senderExternalId?: string;
|
||||
sender?: MessageActorRef;
|
||||
|
||||
receiverType: MessageReceiverType;
|
||||
receiverId?: number;
|
||||
receiverExternalId?: string;
|
||||
receiver?: MessageActorRef;
|
||||
|
||||
operatorAgentId?: number;
|
||||
operator?: MessageOperatorRef;
|
||||
|
||||
contentText?: string;
|
||||
contentMediaUrl?: string;
|
||||
|
||||
sentAt: string;
|
||||
externalMsgId?: string;
|
||||
|
||||
sendStatus?: SendStatus;
|
||||
sendErrorCode?: string;
|
||||
sendErrorMsg?: string;
|
||||
retryCount?: number;
|
||||
}
|
||||
91
src/chat-core/types/sse.ts
Normal file
91
src/chat-core/types/sse.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Customerservice SSE payload types.
|
||||
*
|
||||
* Wire format aligns with frontend sse.ts SseEnvelope:
|
||||
* { msgType: string; data: T }
|
||||
*
|
||||
* Backend will produce the same envelope for SSE event="customerservice"
|
||||
* (spec 05-sse.md, dispatch by msgType in {INCOMING_MESSAGE, ...}).
|
||||
*/
|
||||
|
||||
import type { ConversationStatus, MediaUserSummary } from './conversation';
|
||||
import type { Message, SendStatus } from './message';
|
||||
|
||||
export const SSE_BUS_EVENT = 'customerservice' as const;
|
||||
|
||||
export type SseMsgType =
|
||||
| 'INCOMING_MESSAGE'
|
||||
| 'OUTGOING_MESSAGE_STATE'
|
||||
| 'CONVERSATION_NEW'
|
||||
| 'CONVERSATION_CLOSED'
|
||||
| 'CONVERSATION_TRANSFERRED'
|
||||
| 'AGENT_ONLINE_STATUS';
|
||||
|
||||
/** Generic envelope shared with sse.ts (msgType + data, no tenantId). */
|
||||
export interface SsePayload<T = unknown> {
|
||||
msgType: SseMsgType;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** INCOMING_MESSAGE payload. Carries full message + minimal conversation. */
|
||||
export interface IncomingMessagePayload {
|
||||
conversationId: number;
|
||||
message: Message;
|
||||
conversation: {
|
||||
id: number;
|
||||
currentAgentId?: number;
|
||||
status: ConversationStatus;
|
||||
lastMsgAt: string;
|
||||
mediaUser: MediaUserSummary;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OutgoingMessageStatePayload {
|
||||
conversationId: number;
|
||||
messageId: number;
|
||||
sendStatus: SendStatus;
|
||||
sendErrorCode?: string;
|
||||
sendErrorMsg?: string;
|
||||
sentAt?: string;
|
||||
}
|
||||
|
||||
export interface ConversationNewPayload {
|
||||
conversationId: number;
|
||||
currentAgentId?: number;
|
||||
mediaUser: MediaUserSummary;
|
||||
mediaAccountId: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface ConversationClosedPayload {
|
||||
conversationId: number;
|
||||
closedAt: string;
|
||||
closedByAgentId: number;
|
||||
}
|
||||
|
||||
export interface ConversationTransferredPayload {
|
||||
conversationId: number;
|
||||
fromAgentId: number;
|
||||
toAgentId: number;
|
||||
operatorId: number;
|
||||
reason?: string;
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
export interface AgentOnlineStatusPayload {
|
||||
agentId: number;
|
||||
onlineStatus: 'ONLINE' | 'OFFLINE';
|
||||
occurredAt: string;
|
||||
}
|
||||
|
||||
/** Map msgType → payload data shape. Used by SseSubscriber for typed handlers. */
|
||||
export interface SsePayloadMap {
|
||||
INCOMING_MESSAGE: IncomingMessagePayload;
|
||||
OUTGOING_MESSAGE_STATE: OutgoingMessageStatePayload;
|
||||
CONVERSATION_NEW: ConversationNewPayload;
|
||||
CONVERSATION_CLOSED: ConversationClosedPayload;
|
||||
CONVERSATION_TRANSFERRED: ConversationTransferredPayload;
|
||||
AGENT_ONLINE_STATUS: AgentOnlineStatusPayload;
|
||||
}
|
||||
|
||||
export type SseHandler<K extends SseMsgType> = (data: SsePayloadMap[K]) => void;
|
||||
32
src/chat-core/utils/content.ts
Normal file
32
src/chat-core/utils/content.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Plain-text content helpers. No DOM access — safe for SSR and tests.
|
||||
*/
|
||||
|
||||
const URL_REGEX = /(https?:\/\/[^\s<>]+)/g;
|
||||
|
||||
export interface ContentSegment {
|
||||
type: 'text' | 'link';
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into segments, marking http(s) URLs as link segments.
|
||||
* Caller renders each segment (text → span, link → anchor).
|
||||
*/
|
||||
export function extractUrlsAsLinks(text: string): ContentSegment[] {
|
||||
if (!text) return [];
|
||||
const segments: ContentSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of text.matchAll(URL_REGEX)) {
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex, start) });
|
||||
}
|
||||
segments.push({ type: 'link', value: match[0] });
|
||||
lastIndex = start + match[0].length;
|
||||
}
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ type: 'text', value: text.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
37
src/chat-core/utils/time.ts
Normal file
37
src/chat-core/utils/time.ts
Normal file
@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Format a date as a chat-style relative timestamp:
|
||||
* < 60s → 刚刚
|
||||
* < 60min → N 分钟前
|
||||
* today → HH:mm
|
||||
* yesterday → 昨天 HH:mm
|
||||
* this year → MM/DD HH:mm
|
||||
* older → YYYY/MM/DD
|
||||
*
|
||||
* `now` is parameterized so tests can pin time.
|
||||
*/
|
||||
export function formatRelativeTime(date: Date | string | number, now: Date = new Date()): string {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
if (isNaN(target.getTime())) return '';
|
||||
|
||||
const diffMs = now.getTime() - target.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
|
||||
if (diffSec < 60) return '刚刚';
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
|
||||
|
||||
const sameYear = target.getFullYear() === now.getFullYear();
|
||||
const targetMidnight = new Date(target.getFullYear(), target.getMonth(), target.getDate());
|
||||
const nowMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const dayDiff = Math.round((nowMidnight.getTime() - targetMidnight.getTime()) / 86400000);
|
||||
|
||||
const hh = String(target.getHours()).padStart(2, '0');
|
||||
const mm = String(target.getMinutes()).padStart(2, '0');
|
||||
|
||||
if (dayDiff === 0) return `${hh}:${mm}`;
|
||||
if (dayDiff === 1) return `昨天 ${hh}:${mm}`;
|
||||
|
||||
const month = String(target.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(target.getDate()).padStart(2, '0');
|
||||
if (sameYear) return `${month}/${day} ${hh}:${mm}`;
|
||||
return `${target.getFullYear()}/${month}/${day}`;
|
||||
}
|
||||
27
src/chat-ui/components/ConversationDivider.vue
Normal file
27
src/chat-ui/components/ConversationDivider.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="chat-conversation-divider">
|
||||
<span class="chat-conversation-divider-line"></span>
|
||||
<span class="chat-conversation-divider-text">— 会话已结束 —</span>
|
||||
<span class="chat-conversation-divider-line"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-conversation-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 16px 12px;
|
||||
gap: 8px;
|
||||
|
||||
&-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--chat-border-color);
|
||||
}
|
||||
|
||||
&-text {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
23
src/chat-ui/components/DateDivider.vue
Normal file
23
src/chat-ui/components/DateDivider.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="chat-date-divider"><span>{{ label }}</span></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-date-divider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 12px 0;
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
background: var(--chat-bg);
|
||||
padding: 2px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
169
src/chat-ui/components/MessageBubble.vue
Normal file
169
src/chat-ui/components/MessageBubble.vue
Normal file
@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div class="chat-bubble-row" :class="alignClass">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="chat-bubble-avatar" alt="" />
|
||||
<div class="chat-bubble-col">
|
||||
<div v-if="senderName" class="chat-bubble-name">{{ senderName }}</div>
|
||||
<div class="chat-bubble" :class="bubbleClass">
|
||||
<template v-for="(seg, i) in segments" :key="i">
|
||||
<a v-if="seg.type === 'link'" :href="seg.value" target="_blank" rel="noopener">{{ seg.value }}</a>
|
||||
<span v-else>{{ seg.value }}</span>
|
||||
</template>
|
||||
<img v-if="message.contentMediaUrl && message.msgType === 'IMAGE'" :src="message.contentMediaUrl" class="chat-bubble-media" />
|
||||
</div>
|
||||
<div class="chat-bubble-meta">
|
||||
<span class="chat-bubble-time">{{ formatRelativeTime(message.sentAt) }}</span>
|
||||
<MessageStateBadge
|
||||
v-if="message.direction === 'OUTBOUND' && message.sendStatus"
|
||||
:status="message.sendStatus"
|
||||
:error-msg="message.sendErrorMsg"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canRetry"
|
||||
type="primary"
|
||||
link
|
||||
size="small"
|
||||
class="chat-bubble-retry"
|
||||
@click="$emit('retry', message)"
|
||||
>
|
||||
重发
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Message } from '@/chat-core';
|
||||
import { extractUrlsAsLinks, formatRelativeTime } from '@/chat-core';
|
||||
import MessageStateBadge from './MessageStateBadge.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
message: Message;
|
||||
/** Whether this bubble is the current user's (right-aligned). */
|
||||
self?: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
retry: [msg: Message];
|
||||
}>();
|
||||
|
||||
const isSystem = computed(() => props.message.msgType === 'SYSTEM');
|
||||
|
||||
const alignClass = computed(() => {
|
||||
if (isSystem.value) return 'chat-bubble-row--system';
|
||||
return props.self ? 'chat-bubble-row--right' : 'chat-bubble-row--left';
|
||||
});
|
||||
|
||||
const bubbleClass = computed(() => {
|
||||
if (isSystem.value) return 'chat-bubble--system';
|
||||
return props.self ? 'chat-bubble--self' : 'chat-bubble--other';
|
||||
});
|
||||
|
||||
const senderName = computed(() => {
|
||||
if (isSystem.value) return null;
|
||||
return props.self ? props.message.operator?.nickname : props.message.sender?.nickname;
|
||||
});
|
||||
|
||||
const avatarUrl = computed(() => {
|
||||
if (isSystem.value) return null;
|
||||
return props.self ? props.message.operator?.avatarUrl : props.message.sender?.avatarUrl;
|
||||
});
|
||||
|
||||
const segments = computed(() => extractUrlsAsLinks(props.message.contentText ?? ''));
|
||||
|
||||
const canRetry = computed(
|
||||
() =>
|
||||
props.message.direction === 'OUTBOUND' &&
|
||||
(props.message.sendStatus === 'FAILED' || props.message.sendStatus === 'STUCK')
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-bubble-row {
|
||||
display: flex;
|
||||
margin: 8px 12px;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
&--right {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
&--left {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
&--system {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-bubble-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
.chat-bubble-name {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.chat-bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
|
||||
&--self {
|
||||
background: var(--chat-bubble-self-bg);
|
||||
color: var(--chat-bubble-text);
|
||||
}
|
||||
|
||||
&--other {
|
||||
background: var(--chat-bubble-other-bg);
|
||||
color: var(--chat-bubble-text);
|
||||
border: 1px solid var(--chat-border-color);
|
||||
}
|
||||
|
||||
&--system {
|
||||
background: var(--chat-bubble-system-bg);
|
||||
color: var(--chat-bubble-system-text);
|
||||
font-size: 12px;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-bubble-media {
|
||||
display: block;
|
||||
max-width: 240px;
|
||||
max-height: 240px;
|
||||
margin-top: 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-bubble-retry {
|
||||
padding: 0 4px !important;
|
||||
}
|
||||
</style>
|
||||
68
src/chat-ui/components/MessageInput.vue
Normal file
68
src/chat-ui/components/MessageInput.vue
Normal file
@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="chat-input">
|
||||
<el-input
|
||||
v-model="text"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
resize="none"
|
||||
@keydown.enter.exact.prevent="onSend"
|
||||
/>
|
||||
<div class="chat-input-actions">
|
||||
<span v-if="hint" class="chat-input-hint">{{ hint }}</span>
|
||||
<el-button type="primary" :disabled="!canSend" @click="onSend">发送</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
placeholder: '输入消息,回车发送',
|
||||
hint: '',
|
||||
disabled: false
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string];
|
||||
}>();
|
||||
|
||||
const text = ref('');
|
||||
|
||||
const canSend = computed(() => !props.disabled && text.value.trim().length > 0);
|
||||
|
||||
function onSend() {
|
||||
if (!canSend.value) return;
|
||||
emit('send', text.value.trim());
|
||||
text.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-input {
|
||||
border-top: 1px solid var(--chat-border-color);
|
||||
padding: 8px 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chat-input-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.chat-input-hint {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
</style>
|
||||
42
src/chat-ui/components/MessageStateBadge.vue
Normal file
42
src/chat-ui/components/MessageStateBadge.vue
Normal file
@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<span class="chat-state-badge" :class="`chat-state-${status?.toLowerCase()}`" :title="errorMsg">
|
||||
<template v-if="status === 'PENDING' || status === 'SENDING'">⏳</template>
|
||||
<template v-else-if="status === 'SENT'">✓</template>
|
||||
<template v-else-if="status === 'FAILED'">❌</template>
|
||||
<template v-else-if="status === 'STUCK'">⚠</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { SendStatus } from '@/chat-core';
|
||||
|
||||
defineProps<{
|
||||
status?: SendStatus;
|
||||
errorMsg?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-state-badge {
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
user-select: none;
|
||||
|
||||
&.chat-state-sent {
|
||||
color: var(--chat-status-sent);
|
||||
}
|
||||
|
||||
&.chat-state-failed {
|
||||
color: var(--chat-status-failed);
|
||||
}
|
||||
|
||||
&.chat-state-stuck {
|
||||
color: var(--chat-status-stuck);
|
||||
}
|
||||
|
||||
&.chat-state-pending,
|
||||
&.chat-state-sending {
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
114
src/chat-ui/components/MessageStream.vue
Normal file
114
src/chat-ui/components/MessageStream.vue
Normal file
@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div ref="streamRef" class="chat-stream" @scroll="handleScroll">
|
||||
<div v-if="loadingMore" class="chat-stream-loading">加载中...</div>
|
||||
<div v-if="!loadingMore && noMore" class="chat-stream-no-more">没有更多消息</div>
|
||||
|
||||
<template v-for="item in items" :key="item.key">
|
||||
<DateDivider v-if="item.kind === 'date'" :label="item.label" />
|
||||
<ConversationDivider v-else-if="item.kind === 'conversation-end'" />
|
||||
<MessageBubble
|
||||
v-else
|
||||
:message="item.message"
|
||||
:self="isSelf(item.message)"
|
||||
@retry="$emit('retry', $event)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { Message } from '@/chat-core';
|
||||
import MessageBubble from './MessageBubble.vue';
|
||||
import DateDivider from './DateDivider.vue';
|
||||
import ConversationDivider from './ConversationDivider.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
messages: Message[];
|
||||
/** Operator agent id of the currently logged-in user. Bubble is self if matches. */
|
||||
selfAgentId?: number;
|
||||
loadingMore?: boolean;
|
||||
noMore?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'load-more': [];
|
||||
retry: [msg: Message];
|
||||
}>();
|
||||
|
||||
const streamRef = ref<HTMLElement | null>(null);
|
||||
|
||||
interface StreamItem {
|
||||
key: string;
|
||||
kind: 'date' | 'conversation-end' | 'message';
|
||||
label?: string;
|
||||
message?: Message;
|
||||
}
|
||||
|
||||
const items = computed<StreamItem[]>(() => {
|
||||
const out: StreamItem[] = [];
|
||||
let lastDate: string | null = null;
|
||||
let lastConvId: number | null = null;
|
||||
|
||||
for (const m of props.messages) {
|
||||
const day = m.sentAt?.slice(0, 10) ?? '';
|
||||
if (day && day !== lastDate) {
|
||||
out.push({ key: `d-${day}`, kind: 'date', label: day });
|
||||
lastDate = day;
|
||||
}
|
||||
if (lastConvId !== null && m.conversationId !== lastConvId) {
|
||||
out.push({ key: `c-end-${lastConvId}`, kind: 'conversation-end' });
|
||||
}
|
||||
out.push({ key: `m-${m.id}`, kind: 'message', message: m });
|
||||
lastConvId = m.conversationId;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function isSelf(m: Message): boolean {
|
||||
if (m.direction !== 'OUTBOUND') return false;
|
||||
if (props.selfAgentId == null) return true;
|
||||
return m.operatorAgentId === props.selfAgentId;
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
const el = streamRef.value;
|
||||
if (!el || props.loadingMore || props.noMore) return;
|
||||
if (el.scrollTop < 80) emit('load-more');
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom when new tail message arrives.
|
||||
let lastTailId: number | null = null;
|
||||
watch(
|
||||
() => props.messages,
|
||||
async (msgs) => {
|
||||
if (!msgs.length) return;
|
||||
const tail = msgs[msgs.length - 1].id;
|
||||
if (lastTailId !== tail) {
|
||||
lastTailId = tail;
|
||||
await nextTick();
|
||||
const el = streamRef.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
},
|
||||
{ deep: false }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.chat-stream {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--chat-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-stream-loading,
|
||||
.chat-stream-no-more {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
75
src/chat-ui/composables/useMessageStream.ts
Normal file
75
src/chat-ui/composables/useMessageStream.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { ref } from 'vue';
|
||||
import type { Message } from '@/chat-core';
|
||||
|
||||
export interface MessageStreamLoader {
|
||||
/** Load the most recent page (descending by sentAt). */
|
||||
loadLatest: (pageSize: number) => Promise<Message[]>;
|
||||
/** Load older messages strictly before `before/beforeId`. */
|
||||
loadOlder: (before: string, beforeId: number, pageSize: number) => Promise<Message[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable wrapper for the chat message stream pagination contract.
|
||||
* Holds messages in ascending sentAt order so MessageStream can render top-down.
|
||||
*/
|
||||
export function useMessageStream(loader: MessageStreamLoader, pageSize = 50) {
|
||||
const messages = ref<Message[]>([]);
|
||||
const loadingMore = ref(false);
|
||||
const noMore = ref(false);
|
||||
const initialLoading = ref(false);
|
||||
|
||||
async function init() {
|
||||
initialLoading.value = true;
|
||||
noMore.value = false;
|
||||
try {
|
||||
const page = await loader.loadLatest(pageSize);
|
||||
// backend returns desc order; flip to ascending
|
||||
messages.value = [...page].reverse();
|
||||
if (page.length < pageSize) noMore.value = true;
|
||||
} finally {
|
||||
initialLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || noMore.value || messages.value.length === 0) return;
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const head = messages.value[0];
|
||||
const older = await loader.loadOlder(head.sentAt, head.id, pageSize);
|
||||
if (older.length < pageSize) noMore.value = true;
|
||||
// older is desc → flip → prepend
|
||||
messages.value = [...older.reverse(), ...messages.value];
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendIncoming(msg: Message) {
|
||||
messages.value.push(msg);
|
||||
}
|
||||
|
||||
function updateMessage(predicate: (m: Message) => boolean, patch: Partial<Message>) {
|
||||
const idx = messages.value.findIndex(predicate);
|
||||
if (idx >= 0) {
|
||||
messages.value[idx] = { ...messages.value[idx], ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
messages.value = [];
|
||||
noMore.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
loadingMore,
|
||||
noMore,
|
||||
initialLoading,
|
||||
init,
|
||||
loadMore,
|
||||
appendIncoming,
|
||||
updateMessage,
|
||||
reset
|
||||
};
|
||||
}
|
||||
33
src/chat-ui/composables/useSseConversation.ts
Normal file
33
src/chat-ui/composables/useSseConversation.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { onBeforeUnmount, onMounted } from 'vue';
|
||||
import { getSseEventBus } from '@/utils/sseEventBus';
|
||||
import { SseSubscriber, type SseHandler, type SseMsgType } from '@/chat-core';
|
||||
|
||||
/**
|
||||
* Wires a chat-core SseSubscriber to the host's shared SSE event bus
|
||||
* (sse.ts singleton). Auto-disposes on unmount. Pages register handlers
|
||||
* via the returned `on()` helper inside `setup()`.
|
||||
*/
|
||||
export function useSseConversation() {
|
||||
let subscriber: SseSubscriber | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
subscriber = new SseSubscriber(getSseEventBus());
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
subscriber?.dispose();
|
||||
subscriber = null;
|
||||
});
|
||||
|
||||
return {
|
||||
on<K extends SseMsgType>(msgType: K, handler: SseHandler<K>) {
|
||||
subscriber?.on(msgType, handler);
|
||||
},
|
||||
off<K extends SseMsgType>(msgType: K, handler: SseHandler<K>) {
|
||||
subscriber?.off(msgType, handler);
|
||||
},
|
||||
onReconnect(handler: () => void) {
|
||||
subscriber?.onReconnect(handler);
|
||||
}
|
||||
};
|
||||
}
|
||||
13
src/chat-ui/index.ts
Normal file
13
src/chat-ui/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// chat-ui: Vue components consuming chat-core types/utilities.
|
||||
// Pure presentation, no business API calls — feature pages assemble these.
|
||||
import './styles/default.scss';
|
||||
|
||||
export { default as MessageStream } from './components/MessageStream.vue';
|
||||
export { default as MessageBubble } from './components/MessageBubble.vue';
|
||||
export { default as MessageInput } from './components/MessageInput.vue';
|
||||
export { default as MessageStateBadge } from './components/MessageStateBadge.vue';
|
||||
export { default as DateDivider } from './components/DateDivider.vue';
|
||||
export { default as ConversationDivider } from './components/ConversationDivider.vue';
|
||||
|
||||
export * from './composables/useSseConversation';
|
||||
export * from './composables/useMessageStream';
|
||||
18
src/chat-ui/styles/default.scss
Normal file
18
src/chat-ui/styles/default.scss
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Default chat-ui theme tokens. Pages can override at any container level.
|
||||
* Keep token list small — easier to swap themes later.
|
||||
*/
|
||||
:root {
|
||||
--chat-primary-color: #409eff;
|
||||
--chat-bubble-self-bg: #95ec69;
|
||||
--chat-bubble-other-bg: #ffffff;
|
||||
--chat-bubble-text: #1f1f1f;
|
||||
--chat-bubble-system-bg: #f0f0f0;
|
||||
--chat-bubble-system-text: #909399;
|
||||
--chat-border-color: #e4e7ed;
|
||||
--chat-bg: #f5f7fa;
|
||||
--chat-divider-color: #c0c4cc;
|
||||
--chat-status-failed: #f56c6c;
|
||||
--chat-status-stuck: #e6a23c;
|
||||
--chat-status-sent: #67c23a;
|
||||
}
|
||||
@ -11,7 +11,7 @@ import { usePermissionStore } from '@/store/modules/permission';
|
||||
import { ElMessage } from 'element-plus/es';
|
||||
|
||||
NProgress.configure({ showSpinner: false });
|
||||
const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*'];
|
||||
const whiteList = ['/login', '/register', '/social-callback', '/register*', '/register/*', '/media/account/oauth-result'];
|
||||
|
||||
const isWhiteList = (path: string) => {
|
||||
return whiteList.some((pattern) => isPathMatch(pattern, path));
|
||||
|
||||
@ -42,6 +42,11 @@ export const constantRoutes: RouteRecordRaw[] = [
|
||||
hidden: true,
|
||||
component: () => import('@/layout/components/SocialCallback/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/media/account/oauth-result',
|
||||
hidden: true,
|
||||
component: () => import('@/views/media/account/oauth-result/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: () => import('@/views/login.vue'),
|
||||
|
||||
102
src/utils/sse.ts
102
src/utils/sse.ts
@ -1,42 +1,102 @@
|
||||
import { getToken } from '@/utils/auth';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { useNoticeStore } from '@/store/modules/notice';
|
||||
import { getSseEventBus } from '@/utils/sseEventBus';
|
||||
|
||||
/**
|
||||
* Strongly-typed SSE envelope shared by every event:
|
||||
* { msgType: "...", data: { ...business fields... } }
|
||||
* Frontend dispatches by msgType, then casts data to the corresponding shape.
|
||||
*/
|
||||
interface SseEnvelope<T = unknown> {
|
||||
msgType: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
/** data shape under msgType in {LOGIN_WELCOME, WORKFLOW_TASK, SYSTEM_NOTICE} */
|
||||
interface NoticeData {
|
||||
title: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Business-level SSE event names forwarded to the global event bus.
|
||||
// Feature modules subscribe via getSseEventBus().on('xxx', handler).
|
||||
const FORWARD_EVENTS = ['customerservice'];
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
const RETRY_DELAY = 5000;
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let retries = 0;
|
||||
|
||||
// 初始化
|
||||
export const initSSE = (url: any) => {
|
||||
if (import.meta.env.VITE_APP_SSE === 'false') {
|
||||
return;
|
||||
}
|
||||
connect(url);
|
||||
};
|
||||
|
||||
url = url + '?Authorization=Bearer ' + getToken() + '&clientid=' + import.meta.env.VITE_APP_CLIENT_ID;
|
||||
const { data, error } = useEventSource(url, [], {
|
||||
autoReconnect: {
|
||||
retries: 5,
|
||||
delay: 5000,
|
||||
onFailed() {
|
||||
console.log('Failed to connect after 5 retries');
|
||||
}
|
||||
function parseEnvelope<T = unknown>(raw: string): SseEnvelope<T> | null {
|
||||
try {
|
||||
const env = JSON.parse(raw);
|
||||
if (env && typeof env === 'object' && typeof env.msgType === 'string') {
|
||||
return env as SseEnvelope<T>;
|
||||
}
|
||||
});
|
||||
console.warn('SSE: payload missing msgType', raw);
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn('SSE: invalid JSON envelope', raw, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
watch(error, () => {
|
||||
console.log('SSE connection error:', error.value);
|
||||
error.value = null;
|
||||
});
|
||||
function connect(url: string) {
|
||||
const fullUrl = url + '?Authorization=Bearer ' + getToken() + '&clientid=' + import.meta.env.VITE_APP_CLIENT_ID;
|
||||
eventSource = new EventSource(fullUrl);
|
||||
|
||||
watch(data, () => {
|
||||
if (!data.value) return;
|
||||
// "notice" event: login welcome / workflow task / system announcement.
|
||||
// All three share NoticeData shape; msgType differentiates the source.
|
||||
eventSource.addEventListener('notice', (e: MessageEvent) => {
|
||||
if (!e.data) return;
|
||||
const env = parseEnvelope<NoticeData>(e.data);
|
||||
if (!env) return;
|
||||
const { title, message } = env.data;
|
||||
useNoticeStore().addNotice({
|
||||
message: data.value,
|
||||
title,
|
||||
message,
|
||||
read: false,
|
||||
time: new Date().toLocaleString()
|
||||
});
|
||||
ElNotification({
|
||||
title: '消息',
|
||||
message: data.value,
|
||||
title,
|
||||
message,
|
||||
type: 'success',
|
||||
duration: 3000
|
||||
});
|
||||
data.value = null;
|
||||
});
|
||||
};
|
||||
|
||||
// Business events: forward to global bus, feature modules subscribe themselves
|
||||
const bus = getSseEventBus();
|
||||
FORWARD_EVENTS.forEach((name) => {
|
||||
eventSource!.addEventListener(name, (e: MessageEvent) => {
|
||||
bus.emit(name, e.data);
|
||||
});
|
||||
});
|
||||
|
||||
eventSource.onopen = () => {
|
||||
retries = 0;
|
||||
console.log('SSE connected');
|
||||
};
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.log('SSE connection error:', err);
|
||||
eventSource?.close();
|
||||
eventSource = null;
|
||||
if (retries < MAX_RETRIES) {
|
||||
retries += 1;
|
||||
setTimeout(() => connect(url), RETRY_DELAY);
|
||||
} else {
|
||||
console.log('SSE failed after', MAX_RETRIES, 'retries');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
21
src/utils/sseEventBus.ts
Normal file
21
src/utils/sseEventBus.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import mitt, { Emitter } from 'mitt';
|
||||
|
||||
/**
|
||||
* SSE event bus events shape: protocol-level event name -> raw data string
|
||||
* Consumers JSON.parse the payload themselves
|
||||
*/
|
||||
export type SseBusEvents = Record<string, string>;
|
||||
|
||||
let bus: Emitter<SseBusEvents> | null = null;
|
||||
|
||||
/**
|
||||
* Lazily-initialized singleton event bus
|
||||
* Used by sse.ts to forward business event names (e.g. "customerservice")
|
||||
* to feature modules without creating a second EventSource
|
||||
*/
|
||||
export function getSseEventBus(): Emitter<SseBusEvents> {
|
||||
if (!bus) {
|
||||
bus = mitt<SseBusEvents>();
|
||||
}
|
||||
return bus;
|
||||
}
|
||||
@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div class="conv-list">
|
||||
<el-tabs v-model="active" @tab-change="onTabChange">
|
||||
<el-tab-pane label="我的对话" name="mine"></el-tab-pane>
|
||||
<el-tab-pane label="无主队列" name="unassigned"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div class="conv-list-rows">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.id"
|
||||
class="conv-row"
|
||||
:class="{ active: row.id === openedId, 'has-failure': row.hasSendFailure }"
|
||||
@click="$emit('open', row.id)"
|
||||
>
|
||||
<img v-if="row.mediaUser.avatarUrl" :src="row.mediaUser.avatarUrl" class="conv-row-avatar" alt="" />
|
||||
<div v-else class="conv-row-avatar conv-row-avatar-fallback">{{ initial(row.mediaUser.nickname) }}</div>
|
||||
<div class="conv-row-body">
|
||||
<div class="conv-row-line1">
|
||||
<span class="conv-row-name">{{ row.mediaUser.nickname || row.mediaUser.externalUserIdInApp }}</span>
|
||||
<span v-if="row.lastMsgAt" class="conv-row-time">{{ formatRelativeTime(row.lastMsgAt) }}</span>
|
||||
</div>
|
||||
<div class="conv-row-line2">
|
||||
<span class="conv-row-preview">{{ row.lastMsgPreview ?? '' }}</span>
|
||||
<el-badge v-if="row.unreadCount" :value="row.unreadCount" type="danger" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="rows.length === 0" class="conv-list-empty">暂无对话</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { ConversationListItem } from '@/chat-core';
|
||||
import { formatRelativeTime } from '@/chat-core';
|
||||
|
||||
const props = defineProps<{
|
||||
mine: ConversationListItem[];
|
||||
unassigned: ConversationListItem[];
|
||||
openedId?: number | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
open: [id: number];
|
||||
'tab-change': [tab: 'mine' | 'unassigned'];
|
||||
}>();
|
||||
|
||||
const active = ref<'mine' | 'unassigned'>('mine');
|
||||
|
||||
const rows = computed(() => (active.value === 'mine' ? props.mine : props.unassigned));
|
||||
|
||||
function initial(name?: string): string {
|
||||
if (!name) return '?';
|
||||
return name.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
function onTabChange(name: string | number) {
|
||||
active.value = name as 'mine' | 'unassigned';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conv-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--chat-border-color);
|
||||
}
|
||||
|
||||
.conv-list-rows {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.conv-row {
|
||||
display: flex;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--chat-border-color);
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #ecf5ff;
|
||||
}
|
||||
|
||||
&.has-failure {
|
||||
border-left: 3px solid var(--chat-status-failed);
|
||||
}
|
||||
}
|
||||
|
||||
.conv-row-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
background: var(--chat-border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.conv-row-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conv-row-line1,
|
||||
.conv-row-line2 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.conv-row-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.conv-row-time {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conv-row-preview {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conv-list-empty {
|
||||
text-align: center;
|
||||
color: var(--chat-divider-color);
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="customer-card">
|
||||
<template v-if="conversation">
|
||||
<img v-if="conversation.mediaUser.avatarUrl" :src="conversation.mediaUser.avatarUrl" class="customer-avatar" />
|
||||
<h3 class="customer-name">{{ conversation.mediaUser.nickname || conversation.mediaUser.externalUserIdInApp }}</h3>
|
||||
<dl class="customer-fields">
|
||||
<dt>外部 ID</dt>
|
||||
<dd>{{ conversation.mediaUser.externalUserIdInApp }}</dd>
|
||||
|
||||
<dt>所属账号</dt>
|
||||
<dd>{{ conversation.mediaAccount.platform }} #{{ conversation.mediaAccount.id }}</dd>
|
||||
|
||||
<dt>会话状态</dt>
|
||||
<dd>
|
||||
<el-tag :type="conversation.status === 'OPEN' ? 'success' : 'info'">
|
||||
{{ conversation.status }}
|
||||
</el-tag>
|
||||
</dd>
|
||||
|
||||
<dt>创建时间</dt>
|
||||
<dd>{{ formatRelativeTime(conversation.createdAt) }}</dd>
|
||||
|
||||
<template v-if="conversation.closedAt">
|
||||
<dt>关闭时间</dt>
|
||||
<dd>{{ formatRelativeTime(conversation.closedAt) }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
</template>
|
||||
<div v-else class="customer-card-empty">未选择会话</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Conversation } from '@/chat-core';
|
||||
import { formatRelativeTime } from '@/chat-core';
|
||||
|
||||
defineProps<{ conversation: Conversation | null }>();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.customer-card {
|
||||
padding: 16px;
|
||||
border-left: 1px solid var(--chat-border-color);
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.customer-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.customer-name {
|
||||
text-align: center;
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.customer-fields {
|
||||
margin: 0;
|
||||
|
||||
dt {
|
||||
font-size: 12px;
|
||||
color: var(--chat-divider-color);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.customer-card-empty {
|
||||
text-align: center;
|
||||
color: var(--chat-divider-color);
|
||||
padding-top: 40%;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,36 @@
|
||||
<template>
|
||||
<MessageInput
|
||||
:placeholder="placeholder"
|
||||
:hint="hint"
|
||||
:disabled="disabled"
|
||||
@send="onSend"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { MessageInput } from '@/chat-ui';
|
||||
|
||||
const props = defineProps<{
|
||||
closed?: boolean;
|
||||
unowned?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [text: string];
|
||||
}>();
|
||||
|
||||
const disabled = computed(() => props.closed === true);
|
||||
|
||||
const placeholder = computed(() => {
|
||||
if (props.closed) return '会话已关闭,无法回复';
|
||||
if (props.unowned) return '回复后将自动领取此会话';
|
||||
return '输入消息,回车发送';
|
||||
});
|
||||
|
||||
const hint = computed(() => (props.unowned && !props.closed ? '此会话尚无主,回复后由你接管' : ''));
|
||||
|
||||
function onSend(text: string) {
|
||||
emit('send', text);
|
||||
}
|
||||
</script>
|
||||
@ -0,0 +1,212 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
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;
|
||||
|
||||
/**
|
||||
* Cap on in-memory messages for the currently opened conversation.
|
||||
* Long-lived sessions (8h shift on a chatty customer) would otherwise grow
|
||||
* unbounded — every SSE INCOMING + every loadMore prepend keeps adding.
|
||||
* When exceeded we drop the oldest half and reopen the loadMore door, so
|
||||
* the user can scroll up to fetch them back from the server.
|
||||
*/
|
||||
const MAX_MESSAGES_IN_MEMORY = 500;
|
||||
|
||||
/**
|
||||
* 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')]);
|
||||
}
|
||||
|
||||
// SSE-driven path collapses a burst of events into one refresh. Bursts of
|
||||
// INCOMING_MESSAGE on a chatty conversation would otherwise fire 2 list
|
||||
// APIs per message. onMounted / onReconnect keep using refreshLists()
|
||||
// directly — they need the data immediately, not 300ms later.
|
||||
const debouncedRefreshLists = useDebounceFn(refreshLists, 300);
|
||||
|
||||
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);
|
||||
appendMessage(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[];
|
||||
}
|
||||
|
||||
function appendMessage(msg: Message) {
|
||||
messages.value.push(msg);
|
||||
if (messages.value.length > MAX_MESSAGES_IN_MEMORY) {
|
||||
// Keep only the latest half; mark loadMore re-openable so user can
|
||||
// scroll up to refetch the dropped older slice if needed.
|
||||
messages.value = messages.value.slice(-MAX_MESSAGES_IN_MEMORY / 2);
|
||||
noMoreMessages.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== SSE handlers =====
|
||||
function onIncomingMessage(p: IncomingMessagePayload) {
|
||||
if (openedConversation.value?.id === p.conversationId) {
|
||||
appendMessage(p.message);
|
||||
}
|
||||
debouncedRefreshLists();
|
||||
}
|
||||
|
||||
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) {
|
||||
debouncedRefreshLists();
|
||||
}
|
||||
|
||||
function onConversationClosed(p: ConversationClosedPayload) {
|
||||
if (openedConversation.value?.id === p.conversationId) {
|
||||
openedConversation.value = { ...openedConversation.value, status: 'CLOSED', closedAt: p.closedAt };
|
||||
}
|
||||
debouncedRefreshLists();
|
||||
}
|
||||
|
||||
function onConversationTransferred(p: ConversationTransferredPayload) {
|
||||
if (openedConversation.value?.id === p.conversationId) {
|
||||
openedConversation.value = { ...openedConversation.value, currentAgentId: p.toAgentId };
|
||||
}
|
||||
debouncedRefreshLists();
|
||||
}
|
||||
|
||||
function onAgentOnlineStatusChanged(p: AgentOnlineStatusPayload) {
|
||||
const idx = agents.value.findIndex((a) => a.id === p.agentId);
|
||||
if (idx >= 0) agents.value[idx] = { ...agents.value[idx], onlineStatus: p.onlineStatus };
|
||||
}
|
||||
|
||||
return {
|
||||
mineList,
|
||||
unassignedList,
|
||||
openedConversation,
|
||||
messages,
|
||||
noMoreMessages,
|
||||
loadingMore,
|
||||
agents,
|
||||
fetchList,
|
||||
refreshLists,
|
||||
openConversation,
|
||||
loadOlderMessages,
|
||||
sendReply,
|
||||
retry,
|
||||
closeOpened,
|
||||
transferOpened,
|
||||
refreshAgents,
|
||||
onIncomingMessage,
|
||||
onOutgoingMessageState,
|
||||
onConversationNew,
|
||||
onConversationClosed,
|
||||
onConversationTransferred,
|
||||
onAgentOnlineStatusChanged
|
||||
};
|
||||
});
|
||||
128
src/views/customerservice/workbench/index.vue
Normal file
128
src/views/customerservice/workbench/index.vue
Normal file
@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="cs-workbench">
|
||||
<div class="cs-workbench-left">
|
||||
<ConversationList
|
||||
:mine="store.mineList"
|
||||
:unassigned="store.unassignedList"
|
||||
:opened-id="store.openedConversation?.id"
|
||||
@open="onOpen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cs-workbench-center">
|
||||
<div v-if="store.openedConversation" class="cs-workbench-header">
|
||||
<span class="cs-workbench-title">
|
||||
{{ store.openedConversation.mediaUser.nickname || store.openedConversation.mediaUser.externalUserIdInApp }}
|
||||
</span>
|
||||
<div class="cs-workbench-actions">
|
||||
<!-- Task 20 will fill close / transfer buttons here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MessageStream
|
||||
v-if="store.openedConversation"
|
||||
:messages="store.messages"
|
||||
:loading-more="store.loadingMore"
|
||||
:no-more="store.noMoreMessages"
|
||||
@load-more="store.loadOlderMessages"
|
||||
@retry="(m) => store.retry(m.id)"
|
||||
/>
|
||||
<div v-else class="cs-workbench-empty">请选择左侧的会话开始</div>
|
||||
|
||||
<ReplyEditor
|
||||
v-if="store.openedConversation"
|
||||
:closed="store.openedConversation.status === 'CLOSED'"
|
||||
:unowned="store.openedConversation.currentAgentId == null"
|
||||
@send="onSend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="cs-workbench-right">
|
||||
<CustomerCard :conversation="store.openedConversation" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import { MessageStream } from '@/chat-ui';
|
||||
import { useSseConversation } from '@/chat-ui';
|
||||
import { useConversationStore } from './composables/useConversationStore';
|
||||
import ConversationList from './components/ConversationList.vue';
|
||||
import CustomerCard from './components/CustomerCard.vue';
|
||||
import ReplyEditor from './components/ReplyEditor.vue';
|
||||
|
||||
const store = useConversationStore();
|
||||
const sse = useSseConversation();
|
||||
|
||||
onMounted(async () => {
|
||||
await store.refreshLists();
|
||||
await store.refreshAgents();
|
||||
});
|
||||
|
||||
// Wire SSE handlers — chat-core SseSubscriber dispatches by msgType.
|
||||
sse.on('INCOMING_MESSAGE', store.onIncomingMessage);
|
||||
sse.on('OUTGOING_MESSAGE_STATE', store.onOutgoingMessageState);
|
||||
sse.on('CONVERSATION_NEW', store.onConversationNew);
|
||||
sse.on('CONVERSATION_CLOSED', store.onConversationClosed);
|
||||
sse.on('CONVERSATION_TRANSFERRED', store.onConversationTransferred);
|
||||
sse.on('AGENT_ONLINE_STATUS', store.onAgentOnlineStatusChanged);
|
||||
|
||||
// Re-align after SSE reconnect: refetch lists (spec 05-sse.md §6).
|
||||
sse.onReconnect(() => {
|
||||
store.refreshLists();
|
||||
if (store.openedConversation) store.openConversation(store.openedConversation.id);
|
||||
});
|
||||
|
||||
async function onOpen(id: number) {
|
||||
await store.openConversation(id);
|
||||
}
|
||||
|
||||
async function onSend(text: string) {
|
||||
await store.sendReply({ msgType: 'TEXT', contentText: text });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.cs-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 280px;
|
||||
height: calc(100vh - 84px);
|
||||
background: var(--chat-bg);
|
||||
}
|
||||
|
||||
.cs-workbench-left,
|
||||
.cs-workbench-right {
|
||||
background: #fff;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cs-workbench-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--chat-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cs-workbench-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--chat-border-color);
|
||||
}
|
||||
|
||||
.cs-workbench-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cs-workbench-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--chat-divider-color);
|
||||
}
|
||||
</style>
|
||||
324
src/views/media/account/index.vue
Normal file
324
src/views/media/account/index.vue
Normal file
@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<div class="p-2">
|
||||
<transition :enter-active-class="proxy?.animate.searchAnimate.enter" :leave-active-class="proxy?.animate.searchAnimate.leave">
|
||||
<div v-show="showSearch" class="mb-[10px]">
|
||||
<el-card shadow="hover">
|
||||
<el-form ref="queryFormRef" :model="queryParams" :inline="true">
|
||||
<el-form-item label="平台" prop="platform">
|
||||
<el-select v-model="queryParams.platform" placeholder="请选择平台" clearable style="width: 160px">
|
||||
<el-option v-for="p in platformOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="健康度" prop="authStatus">
|
||||
<el-select v-model="queryParams.authStatus" placeholder="请选择健康度" clearable style="width: 160px">
|
||||
<el-option label="正常" value="HEALTHY" />
|
||||
<el-option label="需重新授权" value="REAUTH_REQUIRED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="外部ID" prop="externalId">
|
||||
<el-input v-model="queryParams.externalId" placeholder="open_id 等" clearable @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
|
||||
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button v-hasPermi="['media:account:bind']" type="primary" plain icon="Plus" @click="openBindDialog">新增绑定</el-button>
|
||||
</el-col>
|
||||
<right-toolbar v-model:show-search="showSearch" @query-table="getList"></right-toolbar>
|
||||
</el-row>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" border :data="accountList">
|
||||
<el-table-column label="ID" align="center" prop="id" width="80" />
|
||||
<el-table-column label="平台" align="center" prop="platform" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ scope.row.platform }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="应用 ID" align="center" prop="appId" width="100" />
|
||||
<el-table-column label="外部 ID" align="center" prop="externalId" show-overflow-tooltip />
|
||||
<el-table-column label="状态" align="center" prop="status" width="80">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.status === 'ENABLED' ? 'success' : 'info'">
|
||||
{{ scope.row.status === 'ENABLED' ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="健康度" align="center" prop="authStatus" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.authStatus === 'HEALTHY' ? 'success' : 'danger'">
|
||||
{{ scope.row.authStatus === 'HEALTHY' ? '正常' : '需重新授权' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="scope" align="center" prop="scope" show-overflow-tooltip />
|
||||
<el-table-column label="access_token" align="center" prop="accessTokenMasked" width="160" show-overflow-tooltip />
|
||||
<el-table-column label="access_token 过期" align="center" prop="accessTokenExpiresAt" width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.accessTokenExpiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="refresh_token 过期" align="center" prop="refreshTokenExpiresAt" width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.refreshTokenExpiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="续期剩余" align="center" prop="refreshRenewRemainingCount" width="100" />
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
|
||||
<template #default="scope">
|
||||
<el-tooltip content="详情" placement="top">
|
||||
<el-button v-hasPermi="['media:account:query']" link type="primary" icon="View" @click="openDetail(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="scope.row.status === 'ENABLED'" content="禁用" placement="top">
|
||||
<el-button v-hasPermi="['media:account:edit']" link type="warning" icon="CircleClose" @click="handleDisable(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-else content="启用" placement="top">
|
||||
<el-button v-hasPermi="['media:account:edit']" link type="success" icon="CircleCheck" @click="handleEnable(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="scope.row.authStatus === 'REAUTH_REQUIRED'" content="重新授权" placement="top">
|
||||
<el-button v-hasPermi="['media:account:bind']" link type="danger" icon="Refresh" @click="reauth(scope.row)"></el-button>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" :total="total" @pagination="getList" />
|
||||
</el-card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailDialog.visible" :title="`媒体账号详情 #${detailDialog.data?.id ?? ''}`" width="640px" append-to-body>
|
||||
<el-descriptions v-if="detailDialog.data" :column="2" border>
|
||||
<el-descriptions-item label="ID">{{ detailDialog.data.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="租户">{{ detailDialog.data.tenantId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ detailDialog.data.platform }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应用 ID">{{ detailDialog.data.appId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="外部 ID" :span="2">{{ detailDialog.data.externalId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ detailDialog.data.status === 'ENABLED' ? '启用' : '禁用' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="健康度">{{ detailDialog.data.authStatus === 'HEALTHY' ? '正常' : '需重新授权' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="scope" :span="2">{{ detailDialog.data.scope }}</el-descriptions-item>
|
||||
<el-descriptions-item label="access_token">{{ detailDialog.data.accessTokenMasked }}</el-descriptions-item>
|
||||
<el-descriptions-item label="access_token 过期">{{ parseTime(detailDialog.data.accessTokenExpiresAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="refresh_token">{{ detailDialog.data.refreshTokenMasked }}</el-descriptions-item>
|
||||
<el-descriptions-item label="refresh_token 过期">{{ parseTime(detailDialog.data.refreshTokenExpiresAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="续期剩余">{{ detailDialog.data.refreshRenewRemainingCount }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ parseTime(detailDialog.data.createTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间" :span="2">{{ parseTime(detailDialog.data.updateTime) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 新增绑定对话框 -->
|
||||
<el-dialog v-model="bindDialog.visible" title="新增媒体账号绑定" width="500px" append-to-body @closed="resetBindForm">
|
||||
<el-form ref="bindFormRef" :model="bindForm" :rules="bindRules" label-width="100px">
|
||||
<el-form-item label="平台" prop="platform">
|
||||
<el-select v-model="bindForm.platform" placeholder="请选择平台" style="width: 100%" @change="onPlatformChange">
|
||||
<el-option v-for="p in platformOptions" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="媒体应用" prop="mediaAppId">
|
||||
<el-select v-model="bindForm.mediaAppId" placeholder="请先选择平台" :loading="bindDialog.appLoading" style="width: 100%">
|
||||
<el-option
|
||||
v-for="app in bindDialog.appList"
|
||||
:key="app.id"
|
||||
:label="`${app.clientKey} (id=${app.id})`"
|
||||
:value="app.id"
|
||||
:disabled="app.status !== 1"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
title="点击「前往授权」会打开新窗口跳到媒体平台授权页,授权完成后回到本系统的结果页"
|
||||
show-icon
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialog.visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="bindDialog.submitting" @click="submitBind">前往授权</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup name="MediaAccount" lang="ts">
|
||||
import { listMediaAccount, getMediaAccount, disableMediaAccount, enableMediaAccount } from '@/api/media/account';
|
||||
import { MediaAccountVO, MediaAccountQuery } from '@/api/media/account/types';
|
||||
import { listMediaApp } from '@/api/media/app';
|
||||
import { MediaAppVO } from '@/api/media/app/types';
|
||||
import { getAuthorizeUrl } from '@/api/media/oauth';
|
||||
|
||||
const { proxy } = getCurrentInstance() as ComponentInternalInstance;
|
||||
|
||||
const platformOptions = [{ value: 'DOUYIN', label: '抖音' }];
|
||||
|
||||
const accountList = ref<MediaAccountVO[]>([]);
|
||||
const loading = ref(true);
|
||||
const showSearch = ref(true);
|
||||
const total = ref(0);
|
||||
const queryFormRef = ref<ElFormInstance>();
|
||||
|
||||
const initQuery: MediaAccountQuery = {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
platform: undefined,
|
||||
status: undefined,
|
||||
authStatus: undefined,
|
||||
externalId: undefined
|
||||
};
|
||||
const queryParams = ref<MediaAccountQuery>({ ...initQuery });
|
||||
|
||||
const detailDialog = reactive<{ visible: boolean; data: MediaAccountVO | null }>({
|
||||
visible: false,
|
||||
data: null
|
||||
});
|
||||
|
||||
const bindFormRef = ref<ElFormInstance>();
|
||||
const bindDialog = reactive<{
|
||||
visible: boolean;
|
||||
appLoading: boolean;
|
||||
appList: MediaAppVO[];
|
||||
submitting: boolean;
|
||||
}>({
|
||||
visible: false,
|
||||
appLoading: false,
|
||||
appList: [],
|
||||
submitting: false
|
||||
});
|
||||
const bindForm = reactive<{ platform: string; mediaAppId: string | number | undefined }>({
|
||||
platform: 'DOUYIN',
|
||||
mediaAppId: undefined
|
||||
});
|
||||
const bindRules = {
|
||||
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
|
||||
mediaAppId: [{ required: true, message: '请选择媒体应用', trigger: 'change' }]
|
||||
};
|
||||
|
||||
const parseTime = (value?: string | number | Date) => (value ? proxy?.parseTime(value) : '-');
|
||||
|
||||
const getList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await listMediaAccount(queryParams.value);
|
||||
accountList.value = res.rows;
|
||||
total.value = res.total;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = () => {
|
||||
queryParams.value.pageNum = 1;
|
||||
getList();
|
||||
};
|
||||
|
||||
const resetQuery = () => {
|
||||
queryFormRef.value?.resetFields();
|
||||
queryParams.value = { ...initQuery };
|
||||
handleQuery();
|
||||
};
|
||||
|
||||
const openDetail = async (row: MediaAccountVO) => {
|
||||
const res = await getMediaAccount(row.id);
|
||||
detailDialog.data = res.data;
|
||||
detailDialog.visible = true;
|
||||
};
|
||||
|
||||
const handleDisable = async (row: MediaAccountVO) => {
|
||||
await proxy?.$modal.confirm(`确认禁用账号 #${row.id}(${row.externalId})?`);
|
||||
await disableMediaAccount(row.id);
|
||||
proxy?.$modal.msgSuccess('禁用成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
const handleEnable = async (row: MediaAccountVO) => {
|
||||
await proxy?.$modal.confirm(`确认启用账号 #${row.id}(${row.externalId})?`);
|
||||
await enableMediaAccount(row.id);
|
||||
proxy?.$modal.msgSuccess('启用成功');
|
||||
await getList();
|
||||
};
|
||||
|
||||
const reauth = async (row: MediaAccountVO) => {
|
||||
bindForm.platform = row.platform;
|
||||
bindForm.mediaAppId = row.appId;
|
||||
await loadApps(row.platform);
|
||||
bindDialog.visible = true;
|
||||
};
|
||||
|
||||
const openBindDialog = async () => {
|
||||
bindForm.platform = 'DOUYIN';
|
||||
bindForm.mediaAppId = undefined;
|
||||
await loadApps('DOUYIN');
|
||||
bindDialog.visible = true;
|
||||
};
|
||||
|
||||
const onPlatformChange = async (value: string) => {
|
||||
bindForm.mediaAppId = undefined;
|
||||
await loadApps(value);
|
||||
};
|
||||
|
||||
const loadApps = async (platform: string) => {
|
||||
bindDialog.appLoading = true;
|
||||
try {
|
||||
const res = await listMediaApp(platform);
|
||||
bindDialog.appList = res.data || [];
|
||||
} finally {
|
||||
bindDialog.appLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetBindForm = () => {
|
||||
bindFormRef.value?.resetFields();
|
||||
bindDialog.appList = [];
|
||||
bindDialog.submitting = false;
|
||||
};
|
||||
|
||||
const submitBind = () => {
|
||||
bindFormRef.value?.validate(async (valid: boolean) => {
|
||||
if (!valid) return;
|
||||
bindDialog.submitting = true;
|
||||
try {
|
||||
const res = await getAuthorizeUrl({
|
||||
platform: bindForm.platform,
|
||||
mediaAppId: bindForm.mediaAppId as string | number
|
||||
});
|
||||
const url = res.data;
|
||||
if (!url) {
|
||||
proxy?.$modal.msgError('未获取到授权地址');
|
||||
return;
|
||||
}
|
||||
window.open(url, '_blank', 'noopener=no');
|
||||
bindDialog.visible = false;
|
||||
proxy?.$modal.msgSuccess('已在新窗口打开授权页,请完成授权后回到本页');
|
||||
} finally {
|
||||
bindDialog.submitting = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 监听 OAuth 落地页发来的完成消息,自动刷新列表
|
||||
const onOauthMessage = (event: MessageEvent) => {
|
||||
if (event.data === 'media-oauth-done') {
|
||||
getList();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', onOauthMessage);
|
||||
getList();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('message', onOauthMessage);
|
||||
});
|
||||
</script>
|
||||
139
src/views/media/account/oauth-result/index.vue
Normal file
139
src/views/media/account/oauth-result/index.vue
Normal file
@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="oauth-result-wrapper">
|
||||
<el-card class="result-card" shadow="hover">
|
||||
<div v-if="status === 'success'" class="result-success">
|
||||
<el-icon class="icon-success"><CircleCheckFilled /></el-icon>
|
||||
<h2>授权成功</h2>
|
||||
<p class="hint">媒体账号已绑定到当前租户</p>
|
||||
<el-descriptions :column="1" border class="info">
|
||||
<el-descriptions-item label="平台">{{ platform }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号 ID">{{ accountId }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
<div v-else class="result-fail">
|
||||
<el-icon class="icon-fail"><CircleCloseFilled /></el-icon>
|
||||
<h2>授权失败</h2>
|
||||
<p class="hint">{{ failMessage }}</p>
|
||||
<p class="error-code">错误码:{{ errorCode || '未知' }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button type="primary" @click="closeWindow">关闭窗口</el-button>
|
||||
<el-button @click="goAccountList">返回账号列表</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="MediaOAuthResult">
|
||||
import { CircleCheckFilled, CircleCloseFilled } from '@element-plus/icons-vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const status = computed(() => (route.query.status as string) || 'fail');
|
||||
const platform = computed(() => (route.query.platform as string) || '-');
|
||||
const accountId = computed(() => (route.query.accountId as string) || '-');
|
||||
const errorCode = computed(() => (route.query.code as string) || '');
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
USER_CANCELED: '用户取消了授权',
|
||||
STATE_INVALID: 'state 无效或解析失败,请重新发起授权',
|
||||
STATE_EXPIRED: 'state 已过期,请重新发起授权',
|
||||
STATE_PLATFORM_MISMATCH: 'state 与回调平台不匹配',
|
||||
ACCOUNT_BOUND_TO_OTHER_TENANT: '该媒体账号已被其他租户绑定',
|
||||
APP_NOT_FOUND: '找不到对应的媒体应用',
|
||||
APP_DISABLED: '媒体应用已禁用',
|
||||
SIGNATURE_FAILED: '媒体平台签名校验失败',
|
||||
INVALID_RESPONSE: '媒体平台返回数据格式异常',
|
||||
TRANSIENT_NETWORK: '调用媒体平台网络异常',
|
||||
TRANSIENT_PLATFORM_5XX: '媒体平台服务异常',
|
||||
TRANSIENT_RATE_LIMIT: '媒体平台请求被限流',
|
||||
OAUTH_REAUTH_REQUIRED: '授权失效,请重新授权',
|
||||
RENEW_QUOTA_EXHAUSTED: '续期额度已耗尽,请重新授权',
|
||||
UNKNOWN_PLATFORM: '未注册的媒体平台',
|
||||
UNSUPPORTED_TOKEN_MODE: '不支持的 token 模式'
|
||||
};
|
||||
|
||||
const failMessage = computed(() => ERROR_MESSAGES[errorCode.value] || '授权过程发生未知错误');
|
||||
|
||||
const closeWindow = () => {
|
||||
window.close();
|
||||
};
|
||||
|
||||
const goAccountList = () => {
|
||||
// 当前若是 OAuth 弹出的新窗口,opener 存在;否则按当前窗口跳转
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
} else {
|
||||
router.push('/media/account');
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 通知父窗口刷新列表
|
||||
if (status.value === 'success' && window.opener) {
|
||||
try {
|
||||
window.opener.postMessage('media-oauth-done', '*');
|
||||
} catch {
|
||||
// ignore cross-origin error
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.oauth-result-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
width: 480px;
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.icon-success,
|
||||
.icon-fail {
|
||||
font-size: 64px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.icon-success {
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.icon-fail {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #606266;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info {
|
||||
text-align: left;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
20
vitest.config.ts
Normal file
20
vitest.config.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Standalone Vitest config: bypasses the heavy Vite plugin chain (unocss,
|
||||
* vue-devtools, etc.) used for the dev server. Tests are pure logic so we
|
||||
* don't need the SFC compiler or unocss transforms.
|
||||
*/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user