Pre Merge pull request !271 from cnstevenwang/customerservice/chat-core-ui

This commit is contained in:
cnstevenwang 2026-06-09 15:07:38 +00:00 committed by Gitee
commit b34b1ba123
No known key found for this signature in database
GPG Key ID: 173E9B9CA92EEF8F
45 changed files with 2652 additions and 24 deletions

View File

@ -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 = '/'

View File

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

View File

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

View File

@ -0,0 +1,38 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import type { TableDataInfo } from './conversation';
import type { AgentListQuery, AgentVO, CreateAgentRequest } from './types';
/** GET /customerservice/agent */
export const listAgent = (query?: AgentListQuery): AxiosPromise<TableDataInfo<AgentVO>> => {
return request({
url: '/customerservice/agent',
method: 'get',
params: query
});
};
/** POST /customerservice/agent — create agent from existing sys_user. */
export const createAgent = (body: CreateAgentRequest): AxiosPromise<AgentVO> => {
return request({
url: '/customerservice/agent',
method: 'post',
data: body
});
};
/** PUT /customerservice/agent/{id}/enable */
export const enableAgent = (id: number) => {
return request({
url: '/customerservice/agent/' + id + '/enable',
method: 'put'
});
};
/** PUT /customerservice/agent/{id}/disable */
export const disableAgent = (id: number) => {
return request({
url: '/customerservice/agent/' + id + '/disable',
method: 'put'
});
};

View File

@ -0,0 +1,84 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import type {
Conversation,
ConversationListItem,
ConversationListQuery,
Message,
MessageStreamQuery,
ReplyMessageRequest,
TransferRequest,
CloseRequest
} from './types';
/** Backend wraps list responses in TableDataInfo<T>. */
export interface TableDataInfo<T> {
total: number;
rows: T[];
code: number;
msg: string;
}
/** GET /customerservice/conversation?scope=... */
export const listConversation = (query: ConversationListQuery): AxiosPromise<TableDataInfo<ConversationListItem>> => {
return request({
url: '/customerservice/conversation',
method: 'get',
params: query
});
};
/** GET /customerservice/conversation/{id} */
export const getConversation = (id: number): AxiosPromise<Conversation> => {
return request({
url: '/customerservice/conversation/' + id,
method: 'get'
});
};
/** GET /customerservice/conversation/{id}/messages — customer-visible stream, desc by sentAt. */
export const getConversationMessages = (
id: number,
query?: MessageStreamQuery
): AxiosPromise<Message[]> => {
return request({
url: '/customerservice/conversation/' + id + '/messages',
method: 'get',
params: query
});
};
/** POST /customerservice/conversation/{id}/message — agent reply. */
export const sendMessage = (id: number, body: ReplyMessageRequest): AxiosPromise<Message> => {
return request({
url: '/customerservice/conversation/' + id + '/message',
method: 'post',
data: body
});
};
/** POST /customerservice/conversation/{convId}/message/{msgId}/retry — only FAILED/STUCK. */
export const retryMessage = (convId: number, msgId: number): AxiosPromise<Message> => {
return request({
url: '/customerservice/conversation/' + convId + '/message/' + msgId + '/retry',
method: 'post'
});
};
/** PUT /customerservice/conversation/{id}/close */
export const closeConversation = (id: number, body: CloseRequest = {}) => {
return request({
url: '/customerservice/conversation/' + id + '/close',
method: 'put',
data: body
});
};
/** POST /customerservice/conversation/{id}/transfer */
export const transferConversation = (id: number, body: TransferRequest) => {
return request({
url: '/customerservice/conversation/' + id + '/transfer',
method: 'post',
data: body
});
};

View File

@ -0,0 +1,11 @@
import request from '@/utils/request';
import { AxiosPromise } from 'axios';
import type { MediaUserVO } from './types';
/** GET /customerservice/media-user/{id} */
export const getMediaUser = (id: number): AxiosPromise<MediaUserVO> => {
return request({
url: '/customerservice/media-user/' + id,
method: 'get'
});
};

View File

@ -0,0 +1,5 @@
/**
* Standalone retry endpoint export (also reachable via conversation.ts).
* Kept for symmetry with the backend module split.
*/
export { retryMessage } from './conversation';

View File

@ -0,0 +1,79 @@
/**
* Frontend types mirroring backend customerservice VOs.
* Re-exports core types and adds API-specific request/response shapes.
*/
import type { ConversationListItem, Conversation } from '@/chat-core';
import type { Message } from '@/chat-core';
export type { Conversation, ConversationListItem, Message };
export type ConversationScope = 'mine' | 'unassigned';
export interface ConversationListQuery {
scope: ConversationScope;
status?: 'OPEN' | 'CLOSED';
pageNum?: number;
pageSize?: number;
}
export interface MessageStreamQuery {
before?: string;
beforeId?: number;
pageSize?: number;
}
export interface ReplyMessageRequest {
msgType: 'TEXT' | 'IMAGE' | 'VIDEO';
contentText?: string;
mediaUrl?: string;
}
export interface TransferRequest {
toAgentId: number;
reason?: string;
}
export interface CloseRequest {
reason?: string;
}
export type AgentOnlineStatus = 'ONLINE' | 'OFFLINE';
export type AgentType = 'HUMAN' | 'AI';
export type AgentStatus = 'ENABLED' | 'DISABLED';
export interface AgentVO {
id: number;
userId: number;
agentType: AgentType;
status: AgentStatus;
onlineStatus: AgentOnlineStatus;
user: {
nickname: string;
avatar?: string;
};
lastOnlineAt?: string;
}
export interface AgentListQuery {
onlineStatus?: AgentOnlineStatus;
agentType?: AgentType;
status?: AgentStatus;
pageNum?: number;
pageSize?: number;
}
export interface CreateAgentRequest {
userId: number;
agentType?: AgentType;
}
export interface MediaUserVO {
id: number;
externalUserIdInApp: string;
nickname?: string;
avatarUrl?: string;
platform: string;
appId: number;
lastContactAt?: string;
}

View File

@ -0,0 +1,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'
});
};

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

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

View File

@ -0,0 +1,7 @@
export interface MediaAppVO {
id: string | number;
platform: string;
clientKey: string;
/** 0=禁用 1=启用 */
status: number;
}

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

View File

@ -0,0 +1,4 @@
export interface AuthorizeUrlQuery {
platform: string;
mediaAppId: string | number;
}

View File

@ -0,0 +1,111 @@
import { describe, expect, it, vi } from 'vitest';
import mitt from 'mitt';
import { SseSubscriber } from '@/chat-core/sse/SseSubscriber';
import { SSE_BUS_EVENT } from '@/chat-core/types/sse';
describe('SseSubscriber', () => {
it('parses customerservice event and dispatches by msgType', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const onIncoming = vi.fn();
subscriber.on('INCOMING_MESSAGE', onIncoming);
const payload = {
msgType: 'INCOMING_MESSAGE',
data: { conversationId: 1, message: { id: 99 } }
};
bus.emit(SSE_BUS_EVENT, JSON.stringify(payload));
expect(onIncoming).toHaveBeenCalledOnce();
expect(onIncoming).toHaveBeenCalledWith(payload.data);
});
it('dispatches different msgTypes to their own handlers', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const onIncoming = vi.fn();
const onClosed = vi.fn();
subscriber.on('INCOMING_MESSAGE', onIncoming);
subscriber.on('CONVERSATION_CLOSED', onClosed);
bus.emit(
SSE_BUS_EVENT,
JSON.stringify({ msgType: 'CONVERSATION_CLOSED', data: { conversationId: 7 } })
);
expect(onIncoming).not.toHaveBeenCalled();
expect(onClosed).toHaveBeenCalledWith({ conversationId: 7 });
});
it('off() removes a previously registered handler', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const handler = vi.fn();
subscriber.on('INCOMING_MESSAGE', handler);
subscriber.off('INCOMING_MESSAGE', handler);
bus.emit(SSE_BUS_EVENT, JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: {} }));
expect(handler).not.toHaveBeenCalled();
});
it('dispose() unsubscribes from the bus', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const handler = vi.fn();
subscriber.on('INCOMING_MESSAGE', handler);
subscriber.dispose();
bus.emit(SSE_BUS_EVENT, JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: {} }));
expect(handler).not.toHaveBeenCalled();
});
it('ignores invalid JSON without throwing', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const handler = vi.fn();
subscriber.on('INCOMING_MESSAGE', handler);
bus.emit(SSE_BUS_EVENT, 'not-json');
expect(handler).not.toHaveBeenCalled();
});
it('ignores payload missing msgType', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const handler = vi.fn();
subscriber.on('INCOMING_MESSAGE', handler);
bus.emit(SSE_BUS_EVENT, JSON.stringify({ data: {} }));
expect(handler).not.toHaveBeenCalled();
});
it('multiple handlers on the same msgType all fire', () => {
const bus = mitt<Record<string, string>>();
const subscriber = new SseSubscriber(bus);
const a = vi.fn();
const b = vi.fn();
subscriber.on('INCOMING_MESSAGE', a);
subscriber.on('INCOMING_MESSAGE', b);
bus.emit(
SSE_BUS_EVENT,
JSON.stringify({ msgType: 'INCOMING_MESSAGE', data: { id: 1 } })
);
expect(a).toHaveBeenCalledWith({ id: 1 });
expect(b).toHaveBeenCalledWith({ id: 1 });
});
it('onReconnect handler fires when bus emits the reconnect signal', () => {
const bus = mitt<Record<string, any>>();
const subscriber = new SseSubscriber(bus);
const onReconnect = vi.fn();
subscriber.onReconnect(onReconnect);
bus.emit('reconnect', undefined);
expect(onReconnect).toHaveBeenCalledOnce();
});
});

View File

@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { formatRelativeTime } from '@/chat-core/utils/time';
import { extractUrlsAsLinks } from '@/chat-core/utils/content';
describe('formatRelativeTime', () => {
const now = new Date('2026-06-08T15:00:00');
it('returns 刚刚 within last minute', () => {
expect(formatRelativeTime(new Date('2026-06-08T14:59:30'), now)).toBe('刚刚');
});
it('returns N 分钟前 within the hour', () => {
expect(formatRelativeTime(new Date('2026-06-08T14:55:00'), now)).toBe('5 分钟前');
});
it('returns HH:mm for earlier today (>1h ago)', () => {
expect(formatRelativeTime(new Date('2026-06-08T09:30:00'), now)).toBe('09:30');
});
it('returns 昨天 HH:mm for yesterday', () => {
expect(formatRelativeTime(new Date('2026-06-07T18:05:00'), now)).toBe('昨天 18:05');
});
it('returns MM/DD HH:mm for earlier this year', () => {
expect(formatRelativeTime(new Date('2026-03-01T10:00:00'), now)).toBe('03/01 10:00');
});
it('returns YYYY/MM/DD for prior years', () => {
expect(formatRelativeTime(new Date('2025-12-31T23:59:00'), now)).toBe('2025/12/31');
});
it('returns empty string for invalid dates', () => {
expect(formatRelativeTime('not-a-date', now)).toBe('');
});
});
describe('extractUrlsAsLinks', () => {
it('returns single text segment when no URL', () => {
expect(extractUrlsAsLinks('hello world')).toEqual([{ type: 'text', value: 'hello world' }]);
});
it('marks a single URL as link segment', () => {
expect(extractUrlsAsLinks('see https://example.com now')).toEqual([
{ type: 'text', value: 'see ' },
{ type: 'link', value: 'https://example.com' },
{ type: 'text', value: ' now' }
]);
});
it('handles multiple URLs', () => {
const out = extractUrlsAsLinks('a http://x.io b https://y.com c');
expect(out).toEqual([
{ type: 'text', value: 'a ' },
{ type: 'link', value: 'http://x.io' },
{ type: 'text', value: ' b ' },
{ type: 'link', value: 'https://y.com' },
{ type: 'text', value: ' c' }
]);
});
it('returns empty array for empty input', () => {
expect(extractUrlsAsLinks('')).toEqual([]);
});
});

9
src/chat-core/index.ts Normal file
View File

@ -0,0 +1,9 @@
// Chat-core: framework-agnostic types + SSE subscriber + utils.
// Aim: extractable into an npm package later — keep zero Vue/Element-Plus deps.
export * from './types/message';
export * from './types/conversation';
export * from './types/sse';
export * from './sse/SseSubscriber';
export * from './utils/time';
export * from './utils/content';

View File

@ -0,0 +1,73 @@
import type { Emitter } from 'mitt';
import { SSE_BUS_EVENT, type SseHandler, type SseMsgType, type SsePayload } from '../types/sse';
/**
* Subscribes to a shared SSE event bus (sse.ts mitt) and dispatches
* customerservice payloads to msgType-keyed handlers.
*
* Does NOT create its own EventSource. The host bus is provided by
* frontend/src/utils/sseEventBus.getSseEventBus().
*/
export class SseSubscriber {
private readonly handlers: Map<SseMsgType, Set<(data: any) => void>> = new Map();
private readonly reconnectHandlers: Set<() => void> = new Set();
private readonly busListener: (raw: string) => void;
private readonly reconnectListener: () => void;
private disposed = false;
constructor(private readonly bus: Emitter<any>) {
this.busListener = (raw: string) => this.handleRaw(raw);
this.reconnectListener = () => this.reconnectHandlers.forEach((h) => h());
this.bus.on(SSE_BUS_EVENT, this.busListener);
this.bus.on('reconnect', this.reconnectListener);
}
on<K extends SseMsgType>(msgType: K, handler: SseHandler<K>): void {
if (this.disposed) return;
let set = this.handlers.get(msgType);
if (!set) {
set = new Set();
this.handlers.set(msgType, set);
}
set.add(handler as (data: any) => void);
}
off<K extends SseMsgType>(msgType: K, handler: SseHandler<K>): void {
this.handlers.get(msgType)?.delete(handler as (data: any) => void);
}
onReconnect(handler: () => void): void {
if (this.disposed) return;
this.reconnectHandlers.add(handler);
}
dispose(): void {
this.bus.off(SSE_BUS_EVENT, this.busListener);
this.bus.off('reconnect', this.reconnectListener);
this.handlers.clear();
this.reconnectHandlers.clear();
this.disposed = true;
}
private handleRaw(raw: string): void {
let envelope: SsePayload | null = null;
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && typeof parsed.msgType === 'string') {
envelope = parsed as SsePayload;
}
} catch {
return;
}
if (!envelope) return;
const set = this.handlers.get(envelope.msgType);
if (!set) return;
set.forEach((h) => {
try {
h(envelope!.data);
} catch (err) {
console.error('[SseSubscriber] handler threw for', envelope!.msgType, err);
}
});
}
}

View File

@ -0,0 +1,53 @@
/**
* Conversation-related type definitions, mirroring backend
* customerservice_conversation and ConversationVo (spec 04-api.md §2).
*/
export type ConversationStatus = 'OPEN' | 'CLOSED';
export interface MediaUserSummary {
id: number;
externalUserIdInApp?: string;
nickname?: string;
avatarUrl?: string;
}
export interface MediaAccountSummary {
id: number;
appId: number;
platform: string;
nickname?: string;
}
/**
* Item for the left-rail conversation list. Carries enough to render the
* row without an extra detail call.
*/
export interface ConversationListItem {
id: number;
status: ConversationStatus;
currentAgentId?: number;
hasSendFailure?: boolean;
lastMsgAt?: string;
lastMsgPreview?: string;
unreadCount?: number;
mediaUser: MediaUserSummary;
mediaAccount: MediaAccountSummary;
}
/**
* Detail returned by GET /customerservice/conversation/{id}.
*/
export interface Conversation {
id: number;
tenantId: string;
status: ConversationStatus;
currentAgentId?: number;
hasSendFailure?: boolean;
externalConversationId?: string;
createdAt: string;
closedAt?: string;
lastMsgAt?: string;
mediaUser: MediaUserSummary;
mediaAccount: MediaAccountSummary;
}

View File

@ -0,0 +1,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;
}

View File

@ -0,0 +1,91 @@
/**
* Customerservice SSE payload types.
*
* Wire format aligns with frontend sse.ts SseEnvelope:
* { msgType: string; data: T }
*
* Backend will produce the same envelope for SSE event="customerservice"
* (spec 05-sse.md, dispatch by msgType in {INCOMING_MESSAGE, ...}).
*/
import type { ConversationStatus, MediaUserSummary } from './conversation';
import type { Message, SendStatus } from './message';
export const SSE_BUS_EVENT = 'customerservice' as const;
export type SseMsgType =
| 'INCOMING_MESSAGE'
| 'OUTGOING_MESSAGE_STATE'
| 'CONVERSATION_NEW'
| 'CONVERSATION_CLOSED'
| 'CONVERSATION_TRANSFERRED'
| 'AGENT_ONLINE_STATUS';
/** Generic envelope shared with sse.ts (msgType + data, no tenantId). */
export interface SsePayload<T = unknown> {
msgType: SseMsgType;
data: T;
}
/** INCOMING_MESSAGE payload. Carries full message + minimal conversation. */
export interface IncomingMessagePayload {
conversationId: number;
message: Message;
conversation: {
id: number;
currentAgentId?: number;
status: ConversationStatus;
lastMsgAt: string;
mediaUser: MediaUserSummary;
};
}
export interface OutgoingMessageStatePayload {
conversationId: number;
messageId: number;
sendStatus: SendStatus;
sendErrorCode?: string;
sendErrorMsg?: string;
sentAt?: string;
}
export interface ConversationNewPayload {
conversationId: number;
currentAgentId?: number;
mediaUser: MediaUserSummary;
mediaAccountId: number;
createdAt: string;
}
export interface ConversationClosedPayload {
conversationId: number;
closedAt: string;
closedByAgentId: number;
}
export interface ConversationTransferredPayload {
conversationId: number;
fromAgentId: number;
toAgentId: number;
operatorId: number;
reason?: string;
occurredAt: string;
}
export interface AgentOnlineStatusPayload {
agentId: number;
onlineStatus: 'ONLINE' | 'OFFLINE';
occurredAt: string;
}
/** Map msgType → payload data shape. Used by SseSubscriber for typed handlers. */
export interface SsePayloadMap {
INCOMING_MESSAGE: IncomingMessagePayload;
OUTGOING_MESSAGE_STATE: OutgoingMessageStatePayload;
CONVERSATION_NEW: ConversationNewPayload;
CONVERSATION_CLOSED: ConversationClosedPayload;
CONVERSATION_TRANSFERRED: ConversationTransferredPayload;
AGENT_ONLINE_STATUS: AgentOnlineStatusPayload;
}
export type SseHandler<K extends SseMsgType> = (data: SsePayloadMap[K]) => void;

View File

@ -0,0 +1,32 @@
/**
* Plain-text content helpers. No DOM access safe for SSR and tests.
*/
const URL_REGEX = /(https?:\/\/[^\s<>]+)/g;
export interface ContentSegment {
type: 'text' | 'link';
value: string;
}
/**
* Split text into segments, marking http(s) URLs as link segments.
* Caller renders each segment (text span, link anchor).
*/
export function extractUrlsAsLinks(text: string): ContentSegment[] {
if (!text) return [];
const segments: ContentSegment[] = [];
let lastIndex = 0;
for (const match of text.matchAll(URL_REGEX)) {
const start = match.index ?? 0;
if (start > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, start) });
}
segments.push({ type: 'link', value: match[0] });
lastIndex = start + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) });
}
return segments;
}

View File

@ -0,0 +1,37 @@
/**
* Format a date as a chat-style relative timestamp:
* < 60s
* < 60min N
* today HH:mm
* yesterday HH:mm
* this year MM/DD HH:mm
* older YYYY/MM/DD
*
* `now` is parameterized so tests can pin time.
*/
export function formatRelativeTime(date: Date | string | number, now: Date = new Date()): string {
const target = date instanceof Date ? date : new Date(date);
if (isNaN(target.getTime())) return '';
const diffMs = now.getTime() - target.getTime();
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return '刚刚';
if (diffSec < 3600) return `${Math.floor(diffSec / 60)} 分钟前`;
const sameYear = target.getFullYear() === now.getFullYear();
const targetMidnight = new Date(target.getFullYear(), target.getMonth(), target.getDate());
const nowMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const dayDiff = Math.round((nowMidnight.getTime() - targetMidnight.getTime()) / 86400000);
const hh = String(target.getHours()).padStart(2, '0');
const mm = String(target.getMinutes()).padStart(2, '0');
if (dayDiff === 0) return `${hh}:${mm}`;
if (dayDiff === 1) return `昨天 ${hh}:${mm}`;
const month = String(target.getMonth() + 1).padStart(2, '0');
const day = String(target.getDate()).padStart(2, '0');
if (sameYear) return `${month}/${day} ${hh}:${mm}`;
return `${target.getFullYear()}/${month}/${day}`;
}

View File

@ -0,0 +1,27 @@
<template>
<div class="chat-conversation-divider">
<span class="chat-conversation-divider-line"></span>
<span class="chat-conversation-divider-text"> 会话已结束 </span>
<span class="chat-conversation-divider-line"></span>
</div>
</template>
<style scoped lang="scss">
.chat-conversation-divider {
display: flex;
align-items: center;
margin: 16px 12px;
gap: 8px;
&-line {
flex: 1;
height: 1px;
background: var(--chat-border-color);
}
&-text {
font-size: 12px;
color: var(--chat-divider-color);
}
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<div class="chat-date-divider"><span>{{ label }}</span></div>
</template>
<script setup lang="ts">
defineProps<{ label: string }>();
</script>
<style scoped lang="scss">
.chat-date-divider {
display: flex;
justify-content: center;
margin: 12px 0;
span {
font-size: 12px;
color: var(--chat-divider-color);
background: var(--chat-bg);
padding: 2px 12px;
border-radius: 12px;
}
}
</style>

View File

@ -0,0 +1,169 @@
<template>
<div class="chat-bubble-row" :class="alignClass">
<img v-if="avatarUrl" :src="avatarUrl" class="chat-bubble-avatar" alt="" />
<div class="chat-bubble-col">
<div v-if="senderName" class="chat-bubble-name">{{ senderName }}</div>
<div class="chat-bubble" :class="bubbleClass">
<template v-for="(seg, i) in segments" :key="i">
<a v-if="seg.type === 'link'" :href="seg.value" target="_blank" rel="noopener">{{ seg.value }}</a>
<span v-else>{{ seg.value }}</span>
</template>
<img v-if="message.contentMediaUrl && message.msgType === 'IMAGE'" :src="message.contentMediaUrl" class="chat-bubble-media" />
</div>
<div class="chat-bubble-meta">
<span class="chat-bubble-time">{{ formatRelativeTime(message.sentAt) }}</span>
<MessageStateBadge
v-if="message.direction === 'OUTBOUND' && message.sendStatus"
:status="message.sendStatus"
:error-msg="message.sendErrorMsg"
/>
<el-button
v-if="canRetry"
type="primary"
link
size="small"
class="chat-bubble-retry"
@click="$emit('retry', message)"
>
重发
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Message } from '@/chat-core';
import { extractUrlsAsLinks, formatRelativeTime } from '@/chat-core';
import MessageStateBadge from './MessageStateBadge.vue';
const props = defineProps<{
message: Message;
/** Whether this bubble is the current user's (right-aligned). */
self?: boolean;
}>();
defineEmits<{
retry: [msg: Message];
}>();
const isSystem = computed(() => props.message.msgType === 'SYSTEM');
const alignClass = computed(() => {
if (isSystem.value) return 'chat-bubble-row--system';
return props.self ? 'chat-bubble-row--right' : 'chat-bubble-row--left';
});
const bubbleClass = computed(() => {
if (isSystem.value) return 'chat-bubble--system';
return props.self ? 'chat-bubble--self' : 'chat-bubble--other';
});
const senderName = computed(() => {
if (isSystem.value) return null;
return props.self ? props.message.operator?.nickname : props.message.sender?.nickname;
});
const avatarUrl = computed(() => {
if (isSystem.value) return null;
return props.self ? props.message.operator?.avatarUrl : props.message.sender?.avatarUrl;
});
const segments = computed(() => extractUrlsAsLinks(props.message.contentText ?? ''));
const canRetry = computed(
() =>
props.message.direction === 'OUTBOUND' &&
(props.message.sendStatus === 'FAILED' || props.message.sendStatus === 'STUCK')
);
</script>
<style scoped lang="scss">
.chat-bubble-row {
display: flex;
margin: 8px 12px;
align-items: flex-start;
gap: 8px;
&--right {
flex-direction: row-reverse;
}
&--left {
flex-direction: row;
}
&--system {
justify-content: center;
}
}
.chat-bubble-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.chat-bubble-col {
display: flex;
flex-direction: column;
max-width: 60%;
}
.chat-bubble-name {
font-size: 12px;
color: var(--chat-divider-color);
margin-bottom: 2px;
}
.chat-bubble {
padding: 8px 12px;
border-radius: 8px;
word-wrap: break-word;
white-space: pre-wrap;
line-height: 1.5;
&--self {
background: var(--chat-bubble-self-bg);
color: var(--chat-bubble-text);
}
&--other {
background: var(--chat-bubble-other-bg);
color: var(--chat-bubble-text);
border: 1px solid var(--chat-border-color);
}
&--system {
background: var(--chat-bubble-system-bg);
color: var(--chat-bubble-system-text);
font-size: 12px;
padding: 4px 12px;
border-radius: 12px;
}
}
.chat-bubble-media {
display: block;
max-width: 240px;
max-height: 240px;
margin-top: 6px;
border-radius: 4px;
}
.chat-bubble-meta {
display: flex;
align-items: center;
margin-top: 2px;
font-size: 12px;
color: var(--chat-divider-color);
gap: 4px;
}
.chat-bubble-retry {
padding: 0 4px !important;
}
</style>

View File

@ -0,0 +1,68 @@
<template>
<div class="chat-input">
<el-input
v-model="text"
type="textarea"
:rows="3"
:placeholder="placeholder"
:disabled="disabled"
resize="none"
@keydown.enter.exact.prevent="onSend"
/>
<div class="chat-input-actions">
<span v-if="hint" class="chat-input-hint">{{ hint }}</span>
<el-button type="primary" :disabled="!canSend" @click="onSend">发送</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
const props = withDefaults(
defineProps<{
placeholder?: string;
hint?: string;
disabled?: boolean;
}>(),
{
placeholder: '输入消息,回车发送',
hint: '',
disabled: false
}
);
const emit = defineEmits<{
send: [text: string];
}>();
const text = ref('');
const canSend = computed(() => !props.disabled && text.value.trim().length > 0);
function onSend() {
if (!canSend.value) return;
emit('send', text.value.trim());
text.value = '';
}
</script>
<style scoped lang="scss">
.chat-input {
border-top: 1px solid var(--chat-border-color);
padding: 8px 12px;
background: #fff;
}
.chat-input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
}
.chat-input-hint {
font-size: 12px;
color: var(--chat-divider-color);
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<span class="chat-state-badge" :class="`chat-state-${status?.toLowerCase()}`" :title="errorMsg">
<template v-if="status === 'PENDING' || status === 'SENDING'"></template>
<template v-else-if="status === 'SENT'"></template>
<template v-else-if="status === 'FAILED'"></template>
<template v-else-if="status === 'STUCK'"></template>
</span>
</template>
<script setup lang="ts">
import type { SendStatus } from '@/chat-core';
defineProps<{
status?: SendStatus;
errorMsg?: string;
}>();
</script>
<style scoped lang="scss">
.chat-state-badge {
font-size: 12px;
margin-left: 4px;
user-select: none;
&.chat-state-sent {
color: var(--chat-status-sent);
}
&.chat-state-failed {
color: var(--chat-status-failed);
}
&.chat-state-stuck {
color: var(--chat-status-stuck);
}
&.chat-state-pending,
&.chat-state-sending {
color: var(--chat-divider-color);
}
}
</style>

View File

@ -0,0 +1,114 @@
<template>
<div ref="streamRef" class="chat-stream" @scroll="handleScroll">
<div v-if="loadingMore" class="chat-stream-loading">加载中...</div>
<div v-if="!loadingMore && noMore" class="chat-stream-no-more">没有更多消息</div>
<template v-for="item in items" :key="item.key">
<DateDivider v-if="item.kind === 'date'" :label="item.label" />
<ConversationDivider v-else-if="item.kind === 'conversation-end'" />
<MessageBubble
v-else
:message="item.message"
:self="isSelf(item.message)"
@retry="$emit('retry', $event)"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import type { Message } from '@/chat-core';
import MessageBubble from './MessageBubble.vue';
import DateDivider from './DateDivider.vue';
import ConversationDivider from './ConversationDivider.vue';
const props = defineProps<{
messages: Message[];
/** Operator agent id of the currently logged-in user. Bubble is self if matches. */
selfAgentId?: number;
loadingMore?: boolean;
noMore?: boolean;
}>();
const emit = defineEmits<{
'load-more': [];
retry: [msg: Message];
}>();
const streamRef = ref<HTMLElement | null>(null);
interface StreamItem {
key: string;
kind: 'date' | 'conversation-end' | 'message';
label?: string;
message?: Message;
}
const items = computed<StreamItem[]>(() => {
const out: StreamItem[] = [];
let lastDate: string | null = null;
let lastConvId: number | null = null;
for (const m of props.messages) {
const day = m.sentAt?.slice(0, 10) ?? '';
if (day && day !== lastDate) {
out.push({ key: `d-${day}`, kind: 'date', label: day });
lastDate = day;
}
if (lastConvId !== null && m.conversationId !== lastConvId) {
out.push({ key: `c-end-${lastConvId}`, kind: 'conversation-end' });
}
out.push({ key: `m-${m.id}`, kind: 'message', message: m });
lastConvId = m.conversationId;
}
return out;
});
function isSelf(m: Message): boolean {
if (m.direction !== 'OUTBOUND') return false;
if (props.selfAgentId == null) return true;
return m.operatorAgentId === props.selfAgentId;
}
function handleScroll() {
const el = streamRef.value;
if (!el || props.loadingMore || props.noMore) return;
if (el.scrollTop < 80) emit('load-more');
}
// Auto-scroll to bottom when new tail message arrives.
let lastTailId: number | null = null;
watch(
() => props.messages,
async (msgs) => {
if (!msgs.length) return;
const tail = msgs[msgs.length - 1].id;
if (lastTailId !== tail) {
lastTailId = tail;
await nextTick();
const el = streamRef.value;
if (el) el.scrollTop = el.scrollHeight;
}
},
{ deep: false }
);
</script>
<style scoped lang="scss">
.chat-stream {
flex: 1;
overflow-y: auto;
background: var(--chat-bg);
display: flex;
flex-direction: column;
}
.chat-stream-loading,
.chat-stream-no-more {
text-align: center;
font-size: 12px;
color: var(--chat-divider-color);
padding: 8px;
}
</style>

View File

@ -0,0 +1,75 @@
import { ref } from 'vue';
import type { Message } from '@/chat-core';
export interface MessageStreamLoader {
/** Load the most recent page (descending by sentAt). */
loadLatest: (pageSize: number) => Promise<Message[]>;
/** Load older messages strictly before `before/beforeId`. */
loadOlder: (before: string, beforeId: number, pageSize: number) => Promise<Message[]>;
}
/**
* Composable wrapper for the chat message stream pagination contract.
* Holds messages in ascending sentAt order so MessageStream can render top-down.
*/
export function useMessageStream(loader: MessageStreamLoader, pageSize = 50) {
const messages = ref<Message[]>([]);
const loadingMore = ref(false);
const noMore = ref(false);
const initialLoading = ref(false);
async function init() {
initialLoading.value = true;
noMore.value = false;
try {
const page = await loader.loadLatest(pageSize);
// backend returns desc order; flip to ascending
messages.value = [...page].reverse();
if (page.length < pageSize) noMore.value = true;
} finally {
initialLoading.value = false;
}
}
async function loadMore() {
if (loadingMore.value || noMore.value || messages.value.length === 0) return;
loadingMore.value = true;
try {
const head = messages.value[0];
const older = await loader.loadOlder(head.sentAt, head.id, pageSize);
if (older.length < pageSize) noMore.value = true;
// older is desc → flip → prepend
messages.value = [...older.reverse(), ...messages.value];
} finally {
loadingMore.value = false;
}
}
function appendIncoming(msg: Message) {
messages.value.push(msg);
}
function updateMessage(predicate: (m: Message) => boolean, patch: Partial<Message>) {
const idx = messages.value.findIndex(predicate);
if (idx >= 0) {
messages.value[idx] = { ...messages.value[idx], ...patch };
}
}
function reset() {
messages.value = [];
noMore.value = false;
}
return {
messages,
loadingMore,
noMore,
initialLoading,
init,
loadMore,
appendIncoming,
updateMessage,
reset
};
}

View File

@ -0,0 +1,33 @@
import { onBeforeUnmount, onMounted } from 'vue';
import { getSseEventBus } from '@/utils/sseEventBus';
import { SseSubscriber, type SseHandler, type SseMsgType } from '@/chat-core';
/**
* Wires a chat-core SseSubscriber to the host's shared SSE event bus
* (sse.ts singleton). Auto-disposes on unmount. Pages register handlers
* via the returned `on()` helper inside `setup()`.
*/
export function useSseConversation() {
let subscriber: SseSubscriber | null = null;
onMounted(() => {
subscriber = new SseSubscriber(getSseEventBus());
});
onBeforeUnmount(() => {
subscriber?.dispose();
subscriber = null;
});
return {
on<K extends SseMsgType>(msgType: K, handler: SseHandler<K>) {
subscriber?.on(msgType, handler);
},
off<K extends SseMsgType>(msgType: K, handler: SseHandler<K>) {
subscriber?.off(msgType, handler);
},
onReconnect(handler: () => void) {
subscriber?.onReconnect(handler);
}
};
}

13
src/chat-ui/index.ts Normal file
View File

@ -0,0 +1,13 @@
// chat-ui: Vue components consuming chat-core types/utilities.
// Pure presentation, no business API calls — feature pages assemble these.
import './styles/default.scss';
export { default as MessageStream } from './components/MessageStream.vue';
export { default as MessageBubble } from './components/MessageBubble.vue';
export { default as MessageInput } from './components/MessageInput.vue';
export { default as MessageStateBadge } from './components/MessageStateBadge.vue';
export { default as DateDivider } from './components/DateDivider.vue';
export { default as ConversationDivider } from './components/ConversationDivider.vue';
export * from './composables/useSseConversation';
export * from './composables/useMessageStream';

View File

@ -0,0 +1,18 @@
/**
* Default chat-ui theme tokens. Pages can override at any container level.
* Keep token list small easier to swap themes later.
*/
:root {
--chat-primary-color: #409eff;
--chat-bubble-self-bg: #95ec69;
--chat-bubble-other-bg: #ffffff;
--chat-bubble-text: #1f1f1f;
--chat-bubble-system-bg: #f0f0f0;
--chat-bubble-system-text: #909399;
--chat-border-color: #e4e7ed;
--chat-bg: #f5f7fa;
--chat-divider-color: #c0c4cc;
--chat-status-failed: #f56c6c;
--chat-status-stuck: #e6a23c;
--chat-status-sent: #67c23a;
}

View File

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

View File

@ -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'),

View File

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

View File

@ -0,0 +1,152 @@
<template>
<div class="conv-list">
<el-tabs v-model="active" @tab-change="onTabChange">
<el-tab-pane label="我的对话" name="mine"></el-tab-pane>
<el-tab-pane label="无主队列" name="unassigned"></el-tab-pane>
</el-tabs>
<div class="conv-list-rows">
<div
v-for="row in rows"
:key="row.id"
class="conv-row"
:class="{ active: row.id === openedId, 'has-failure': row.hasSendFailure }"
@click="$emit('open', row.id)"
>
<img v-if="row.mediaUser.avatarUrl" :src="row.mediaUser.avatarUrl" class="conv-row-avatar" alt="" />
<div v-else class="conv-row-avatar conv-row-avatar-fallback">{{ initial(row.mediaUser.nickname) }}</div>
<div class="conv-row-body">
<div class="conv-row-line1">
<span class="conv-row-name">{{ row.mediaUser.nickname || row.mediaUser.externalUserIdInApp }}</span>
<span v-if="row.lastMsgAt" class="conv-row-time">{{ formatRelativeTime(row.lastMsgAt) }}</span>
</div>
<div class="conv-row-line2">
<span class="conv-row-preview">{{ row.lastMsgPreview ?? '' }}</span>
<el-badge v-if="row.unreadCount" :value="row.unreadCount" type="danger" />
</div>
</div>
</div>
<div v-if="rows.length === 0" class="conv-list-empty">暂无对话</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { ConversationListItem } from '@/chat-core';
import { formatRelativeTime } from '@/chat-core';
const props = defineProps<{
mine: ConversationListItem[];
unassigned: ConversationListItem[];
openedId?: number | null;
}>();
defineEmits<{
open: [id: number];
'tab-change': [tab: 'mine' | 'unassigned'];
}>();
const active = ref<'mine' | 'unassigned'>('mine');
const rows = computed(() => (active.value === 'mine' ? props.mine : props.unassigned));
function initial(name?: string): string {
if (!name) return '?';
return name.charAt(0).toUpperCase();
}
function onTabChange(name: string | number) {
active.value = name as 'mine' | 'unassigned';
}
</script>
<style scoped lang="scss">
.conv-list {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--chat-border-color);
}
.conv-list-rows {
flex: 1;
overflow-y: auto;
}
.conv-row {
display: flex;
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid var(--chat-border-color);
gap: 8px;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
}
&.has-failure {
border-left: 3px solid var(--chat-status-failed);
}
}
.conv-row-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
background: var(--chat-border-color);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: bold;
}
.conv-row-body {
flex: 1;
min-width: 0;
}
.conv-row-line1,
.conv-row-line2 {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.conv-row-name {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-row-time {
font-size: 12px;
color: var(--chat-divider-color);
flex-shrink: 0;
}
.conv-row-preview {
font-size: 12px;
color: var(--chat-divider-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.conv-list-empty {
text-align: center;
color: var(--chat-divider-color);
padding: 24px;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<div class="customer-card">
<template v-if="conversation">
<img v-if="conversation.mediaUser.avatarUrl" :src="conversation.mediaUser.avatarUrl" class="customer-avatar" />
<h3 class="customer-name">{{ conversation.mediaUser.nickname || conversation.mediaUser.externalUserIdInApp }}</h3>
<dl class="customer-fields">
<dt>外部 ID</dt>
<dd>{{ conversation.mediaUser.externalUserIdInApp }}</dd>
<dt>所属账号</dt>
<dd>{{ conversation.mediaAccount.platform }} #{{ conversation.mediaAccount.id }}</dd>
<dt>会话状态</dt>
<dd>
<el-tag :type="conversation.status === 'OPEN' ? 'success' : 'info'">
{{ conversation.status }}
</el-tag>
</dd>
<dt>创建时间</dt>
<dd>{{ formatRelativeTime(conversation.createdAt) }}</dd>
<template v-if="conversation.closedAt">
<dt>关闭时间</dt>
<dd>{{ formatRelativeTime(conversation.closedAt) }}</dd>
</template>
</dl>
</template>
<div v-else class="customer-card-empty">未选择会话</div>
</div>
</template>
<script setup lang="ts">
import type { Conversation } from '@/chat-core';
import { formatRelativeTime } from '@/chat-core';
defineProps<{ conversation: Conversation | null }>();
</script>
<style scoped lang="scss">
.customer-card {
padding: 16px;
border-left: 1px solid var(--chat-border-color);
height: 100%;
overflow-y: auto;
}
.customer-avatar {
width: 64px;
height: 64px;
border-radius: 50%;
display: block;
margin: 0 auto 8px;
}
.customer-name {
text-align: center;
margin: 0 0 16px;
font-size: 16px;
}
.customer-fields {
margin: 0;
dt {
font-size: 12px;
color: var(--chat-divider-color);
margin-top: 8px;
}
dd {
margin: 0;
font-size: 13px;
word-break: break-all;
}
}
.customer-card-empty {
text-align: center;
color: var(--chat-divider-color);
padding-top: 40%;
}
</style>

View File

@ -0,0 +1,36 @@
<template>
<MessageInput
:placeholder="placeholder"
:hint="hint"
:disabled="disabled"
@send="onSend"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { MessageInput } from '@/chat-ui';
const props = defineProps<{
closed?: boolean;
unowned?: boolean;
}>();
const emit = defineEmits<{
send: [text: string];
}>();
const disabled = computed(() => props.closed === true);
const placeholder = computed(() => {
if (props.closed) return '会话已关闭,无法回复';
if (props.unowned) return '回复后将自动领取此会话';
return '输入消息,回车发送';
});
const hint = computed(() => (props.unowned && !props.closed ? '此会话尚无主,回复后由你接管' : ''));
function onSend(text: string) {
emit('send', text);
}
</script>

View File

@ -0,0 +1,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
};
});

View File

@ -0,0 +1,128 @@
<template>
<div class="cs-workbench">
<div class="cs-workbench-left">
<ConversationList
:mine="store.mineList"
:unassigned="store.unassignedList"
:opened-id="store.openedConversation?.id"
@open="onOpen"
/>
</div>
<div class="cs-workbench-center">
<div v-if="store.openedConversation" class="cs-workbench-header">
<span class="cs-workbench-title">
{{ store.openedConversation.mediaUser.nickname || store.openedConversation.mediaUser.externalUserIdInApp }}
</span>
<div class="cs-workbench-actions">
<!-- Task 20 will fill close / transfer buttons here -->
</div>
</div>
<MessageStream
v-if="store.openedConversation"
:messages="store.messages"
:loading-more="store.loadingMore"
:no-more="store.noMoreMessages"
@load-more="store.loadOlderMessages"
@retry="(m) => store.retry(m.id)"
/>
<div v-else class="cs-workbench-empty">请选择左侧的会话开始</div>
<ReplyEditor
v-if="store.openedConversation"
:closed="store.openedConversation.status === 'CLOSED'"
:unowned="store.openedConversation.currentAgentId == null"
@send="onSend"
/>
</div>
<div class="cs-workbench-right">
<CustomerCard :conversation="store.openedConversation" />
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { MessageStream } from '@/chat-ui';
import { useSseConversation } from '@/chat-ui';
import { useConversationStore } from './composables/useConversationStore';
import ConversationList from './components/ConversationList.vue';
import CustomerCard from './components/CustomerCard.vue';
import ReplyEditor from './components/ReplyEditor.vue';
const store = useConversationStore();
const sse = useSseConversation();
onMounted(async () => {
await store.refreshLists();
await store.refreshAgents();
});
// Wire SSE handlers chat-core SseSubscriber dispatches by msgType.
sse.on('INCOMING_MESSAGE', store.onIncomingMessage);
sse.on('OUTGOING_MESSAGE_STATE', store.onOutgoingMessageState);
sse.on('CONVERSATION_NEW', store.onConversationNew);
sse.on('CONVERSATION_CLOSED', store.onConversationClosed);
sse.on('CONVERSATION_TRANSFERRED', store.onConversationTransferred);
sse.on('AGENT_ONLINE_STATUS', store.onAgentOnlineStatusChanged);
// Re-align after SSE reconnect: refetch lists (spec 05-sse.md §6).
sse.onReconnect(() => {
store.refreshLists();
if (store.openedConversation) store.openConversation(store.openedConversation.id);
});
async function onOpen(id: number) {
await store.openConversation(id);
}
async function onSend(text: string) {
await store.sendReply({ msgType: 'TEXT', contentText: text });
}
</script>
<style scoped lang="scss">
.cs-workbench {
display: grid;
grid-template-columns: 320px 1fr 280px;
height: calc(100vh - 84px);
background: var(--chat-bg);
}
.cs-workbench-left,
.cs-workbench-right {
background: #fff;
height: 100%;
overflow: hidden;
}
.cs-workbench-center {
display: flex;
flex-direction: column;
background: var(--chat-bg);
min-width: 0;
}
.cs-workbench-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
background: #fff;
border-bottom: 1px solid var(--chat-border-color);
}
.cs-workbench-title {
font-weight: 500;
}
.cs-workbench-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--chat-divider-color);
}
</style>

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

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

@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
import path from 'path';
/**
* Standalone Vitest config: bypasses the heavy Vite plugin chain (unocss,
* vue-devtools, etc.) used for the dev server. Tests are pure logic so we
* don't need the SFC compiler or unocss transforms.
*/
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
globals: false
}
});