From ebf6a389cf6b518c4d55cd1c73232b11591751d3 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Apr 2026 16:55:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(im):=20=E6=B7=BB=E5=8A=A0=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E9=80=9A=E8=AE=AF=E5=8A=9F=E8=83=BD=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口 - 实现了 ImSDK 核心功能,支持发送各类消息和管理会话 - 集成了 WebSocket 连接管理和自动重连机制 - 添加了本地联系人缓存并优化对话标题显示逻辑 - 实现了 HarmonyOS 平台 HTTP 客户端基础功能 --- .gitignore | 1 + node_modules | 1 + package.json | 2 +- src/core/http.ts | 30 ++++++++++--- src/core/sdk.ts | 9 ++++ src/im/ImClient.ts | 53 +++++++++++++++++++---- src/im/api.ts | 102 +++++++++++++++++++++++++++++++++++++++++++++ src/im/useIm.ts | 102 ++++++++++++++++++++++++++++++++++++++++++--- src/index.ts | 19 ++++++++- src/types/index.ts | 57 +++++++++++++++++++++++-- vite.config.ts | 2 - 11 files changed, 351 insertions(+), 27 deletions(-) create mode 120000 node_modules create mode 100644 src/im/api.ts diff --git a/.gitignore b/.gitignore index 10dfc90..6bd6c37 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ build/ *.iml .idea/ *.log +/node_modules/ diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..71d3083 --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +../XuqmGroup-Web/node_modules \ No newline at end of file diff --git a/package.json b/package.json index bd8474d..690f275 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "files": ["dist"], "scripts": { "dev": "vite build --watch", - "build": "tsc --noEmit && vite build", + "build": "tsc --emitDeclarationOnly && vite build", "type-check": "tsc --noEmit" }, "peerDependencies": { diff --git a/src/core/http.ts b/src/core/http.ts index 9b4e1e2..a9ae9ef 100644 --- a/src/core/http.ts +++ b/src/core/http.ts @@ -3,17 +3,35 @@ import type { ApiResponse } from '../types' let _baseUrl = '' let _tokenGetter: (() => string | null) = () => null +type QueryValue = string | number | boolean | Date | null | undefined + export function configureHttp(baseUrl: string, tokenGetter: () => string | null) { _baseUrl = baseUrl.replace(/\/$/, '') _tokenGetter = tokenGetter } -async function request(method: string, path: string, body?: unknown): Promise { +function buildUrl(path: string, query?: Record): string { + if (!query) return `${_baseUrl}${path}` + const searchParams = new URLSearchParams() + for (const [key, value] of Object.entries(query)) { + if (value === undefined || value === null || value === '') continue + searchParams.set(key, value instanceof Date ? value.toISOString() : String(value)) + } + const queryString = searchParams.toString() + return queryString ? `${_baseUrl}${path}?${queryString}` : `${_baseUrl}${path}` +} + +async function request( + method: string, + path: string, + body?: unknown, + query?: Record, +): Promise { const token = _tokenGetter() const headers: Record = { 'Content-Type': 'application/json' } if (token) headers['Authorization'] = `Bearer ${token}` - const res = await fetch(`${_baseUrl}${path}`, { + const res = await fetch(buildUrl(path, query), { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, @@ -25,8 +43,8 @@ async function request(method: string, path: string, body?: unknown): Promise } export const http = { - get: (path: string) => request('GET', path), - post: (path: string, body?: unknown) => request('POST', path, body), - put: (path: string, body?: unknown) => request('PUT', path, body), - delete: (path: string) => request('DELETE', path), + get: (path: string, query?: Record) => request('GET', path, undefined, query), + post: (path: string, body?: unknown, query?: Record) => request('POST', path, body, query), + put: (path: string, body?: unknown, query?: Record) => request('PUT', path, body, query), + delete: (path: string, query?: Record) => request('DELETE', path, undefined, query), } diff --git a/src/core/sdk.ts b/src/core/sdk.ts index dde1515..e178a64 100644 --- a/src/core/sdk.ts +++ b/src/core/sdk.ts @@ -3,6 +3,7 @@ import { configureHttp } from './http' let _config: SDKConfig | null = null let _token: string | null = null +let _userId: string | null = null export function init(config: SDKConfig) { _config = config @@ -14,10 +15,18 @@ export function setToken(token: string | null) { _token = token } +export function setUserId(userId: string | null) { + _userId = userId +} + export function getToken(): string | null { return _token } +export function getUserId(): string | null { + return _userId +} + export function getConfig(): SDKConfig { if (!_config) throw new Error('XuqmSDK not initialized. Call init() first.') return _config diff --git a/src/im/ImClient.ts b/src/im/ImClient.ts index e907fb8..b88e2e4 100644 --- a/src/im/ImClient.ts +++ b/src/im/ImClient.ts @@ -1,5 +1,5 @@ import type { ImMessage, SendMessageParams, ImEventMap } from '../types' -import { getConfig, getToken } from '../core/sdk' +import { getConfig, getToken, getUserId } from '../core/sdk' type EventListener = ImEventMap[K] @@ -13,10 +13,10 @@ export class ImClient { private listeners: { [K in keyof ImEventMap]?: Set> } = {} on(event: K, handler: EventListener): this { - if (!this.listeners[event]) { - (this.listeners[event] as Set>) = new Set() - } - (this.listeners[event] as Set>).add(handler) + const store = this.listeners as Record> + const listeners = (store[event] ?? new Set>()) as Set> + listeners.add(handler) + store[event] = listeners as Set return this } @@ -49,7 +49,7 @@ export class ImClient { try { const frame = JSON.parse(event.data as string) if (frame.type === 'MESSAGE') { - this.emit('message', frame.payload as ImMessage) + this.emit('message', this.normalizeMessage(frame.payload as ImMessage)) } else if (frame.type === 'REVOKE') { this.emit('revoke', frame.payload as { msgId: string; operatorId: string }) } @@ -68,16 +68,37 @@ export class ImClient { } } - send(params: SendMessageParams): void { + send(params: SendMessageParams): ImMessage { + const config = getConfig() + const userId = getUserId() ?? '' + const messageId = params.messageId ?? this.generateMessageId() + const outgoing: ImMessage = { + id: messageId, + appId: config.appKey, + fromUserId: userId, + fromId: userId, + toId: params.toId, + chatType: params.chatType, + msgType: params.msgType, + content: params.content, + status: 'SENDING', + mentionedUserIds: params.mentionedUserIds, + revoked: false, + createdAt: new Date().toISOString(), + } if (this.ws?.readyState !== WebSocket.OPEN) { - throw new Error('WebSocket not connected') + return { ...outgoing, status: 'FAILED' } } this.ws.send( JSON.stringify({ destination: '/app/chat.send', - payload: params, + payload: { + ...params, + messageId, + }, }) ) + return outgoing } revoke(msgId: string): void { @@ -109,4 +130,18 @@ export class ImClient { this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY) }, this.reconnectDelay) } + + private generateMessageId(): string { + const cryptoId = globalThis.crypto?.randomUUID?.() + if (cryptoId) return cryptoId + return `msg_${Date.now()}_${Math.random().toString(16).slice(2)}` + } + + private normalizeMessage(message: ImMessage): ImMessage { + return { + ...message, + fromId: message.fromId ?? message.fromUserId, + revoked: message.revoked ?? message.status === 'REVOKED', + } + } } diff --git a/src/im/api.ts b/src/im/api.ts new file mode 100644 index 0000000..0af7d58 --- /dev/null +++ b/src/im/api.ts @@ -0,0 +1,102 @@ +import { getConfig } from '../core/sdk' +import { http } from '../core/http' +import type { + ChatType, + ConversationView, + HistoryQuery, + ImMessage, + PageResult, + UserProfile, +} from '../types' + +function appQuery(extra?: Record) { + return { + appId: getConfig().appKey, + ...extra, + } +} + +function normalizeHistoryQuery(query: HistoryQuery = {}) { + return { + msgType: query.msgType, + keyword: query.keyword, + startTime: query.startTime, + endTime: query.endTime, + page: query.page ?? 0, + size: query.size ?? 20, + } +} + +export function listConversations(size = 20): Promise { + return http.get('/api/im/conversations', appQuery({ page: 0, size })) +} + +export function fetchHistory(toId: string, query: HistoryQuery = {}): Promise> { + return http.get>( + `/api/im/messages/history/${encodeURIComponent(toId)}`, + appQuery(normalizeHistoryQuery(query)), + ) +} + +export function fetchGroupHistory(groupId: string, query: HistoryQuery = {}): Promise> { + return http.get>( + `/api/im/messages/group-history/${encodeURIComponent(groupId)}`, + appQuery(normalizeHistoryQuery(query)), + ) +} + +export function markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise { + return http.put( + `/api/im/conversations/${encodeURIComponent(targetId)}/read`, + undefined, + appQuery({ chatType }), + ) +} + +export function setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise { + return http.put( + `/api/im/conversations/${encodeURIComponent(targetId)}/pinned`, + undefined, + appQuery({ chatType, pinned }), + ) +} + +export function setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise { + return http.put( + `/api/im/conversations/${encodeURIComponent(targetId)}/muted`, + undefined, + appQuery({ chatType, muted }), + ) +} + +export function setDraft(targetId: string, chatType: ChatType, draft: string): Promise { + return http.put( + `/api/im/conversations/${encodeURIComponent(targetId)}/draft`, + undefined, + appQuery({ chatType, draft }), + ) +} + +export function deleteConversation(targetId: string, chatType: ChatType): Promise { + return http.delete( + `/api/im/conversations/${encodeURIComponent(targetId)}`, + appQuery({ chatType }), + ) +} + +export function getProfile(userId: string): Promise { + return http.get(`/api/im/accounts/${encodeURIComponent(userId)}`, appQuery()) +} + +export function updateProfile( + userId: string, + nickname?: string, + avatar?: string, + gender?: string, +): Promise { + return http.put( + `/api/im/accounts/${encodeURIComponent(userId)}`, + undefined, + appQuery({ nickname, avatar, gender }), + ) +} diff --git a/src/im/useIm.ts b/src/im/useIm.ts index 6f947be..ba6d5c3 100644 --- a/src/im/useIm.ts +++ b/src/im/useIm.ts @@ -1,25 +1,80 @@ import { ref, shallowRef, onUnmounted } from 'vue' import { ImClient } from './ImClient' -import type { ImMessage, SendMessageParams } from '../types' +import { + deleteConversation, + fetchGroupHistory, + fetchHistory, + listConversations, + markRead, + setConversationMuted, + setConversationPinned, + setDraft, +} from './api' +import type { + ConversationView, + HistoryQuery, + ImMessage, + PageResult, + SendMessageParams, + ChatType, +} from '../types' export function useIm() { const client = shallowRef(null) const messages = ref([]) + const conversations = ref([]) const connected = ref(false) const error = ref(null) + function upsertMessage(message: ImMessage) { + const index = messages.value.findIndex((item) => item.id === message.id) + if (index >= 0) { + const next = [...messages.value] + next[index] = { ...next[index], ...message } + messages.value = next + return + } + messages.value = [...messages.value, message] + } + + async function refreshConversations() { + const items = await listConversations() + conversations.value = [...items].sort((a, b) => { + if (a.isPinned !== b.isPinned) return Number(b.isPinned) - Number(a.isPinned) + return b.lastMsgTime - a.lastMsgTime + }) + } + + async function loadHistory(toId: string, query: HistoryQuery = {}): Promise> { + return fetchHistory(toId, query) + } + + async function loadGroupHistory(groupId: string, query: HistoryQuery = {}): Promise> { + return fetchGroupHistory(groupId, query) + } + function connect() { const im = new ImClient() - im.on('connected', () => { connected.value = true }) + im.on('connected', () => { + connected.value = true + void refreshConversations().catch(() => {}) + }) im.on('disconnected', () => { connected.value = false }) - im.on('message', (msg) => { messages.value = [...messages.value, msg] }) + im.on('message', (msg) => { + upsertMessage(msg) + void refreshConversations().catch(() => {}) + }) im.on('error', (e) => { error.value = e }) im.connect() client.value = im } function send(params: SendMessageParams) { - client.value?.send(params) + if (!client.value) throw new Error('IM client not connected') + const message = client.value.send(params) + upsertMessage(message) + void refreshConversations().catch(() => {}) + return message } function revoke(msgId: string) { @@ -32,7 +87,44 @@ export function useIm() { connected.value = false } + function setConversationRead(targetId: string, chatType: ChatType = 'SINGLE') { + return markRead(targetId, chatType) + } + + function setConversationPinnedState(targetId: string, chatType: ChatType, pinned: boolean) { + return setConversationPinned(targetId, chatType, pinned) + } + + function setConversationMutedState(targetId: string, chatType: ChatType, muted: boolean) { + return setConversationMuted(targetId, chatType, muted) + } + + function setConversationDraft(targetId: string, chatType: ChatType, draft: string) { + return setDraft(targetId, chatType, draft) + } + + function removeConversation(targetId: string, chatType: ChatType) { + return deleteConversation(targetId, chatType) + } + onUnmounted(disconnect) - return { connect, send, revoke, disconnect, messages, connected, error } + return { + connect, + send, + revoke, + disconnect, + messages, + conversations, + connected, + error, + refreshConversations, + loadHistory, + loadGroupHistory, + setConversationRead, + setConversationPinnedState, + setConversationMutedState, + setConversationDraft, + removeConversation, + } } diff --git a/src/index.ts b/src/index.ts index db9b659..f32ad8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,29 @@ -export { init, setToken, getToken, getConfig } from './core/sdk' +export { init, setToken, setUserId, getToken, getUserId, getConfig } from './core/sdk' export { http } from './core/http' export { ImClient } from './im/ImClient' +export { + deleteConversation, + fetchGroupHistory, + fetchHistory, + listConversations, + markRead, + getProfile, + setConversationMuted, + setConversationPinned, + setDraft, + updateProfile, +} from './im/api' export { useIm } from './im/useIm' export type { SDKConfig, MsgType, ChatType, + MsgStatus, ImMessage, + ConversationView, + HistoryQuery, + PageResult, + UserProfile, SendMessageParams, ImEventMap, ApiResponse, diff --git a/src/types/index.ts b/src/types/index.ts index 47b3db9..20d9536 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,24 +23,75 @@ export type MsgType = export type ChatType = 'SINGLE' | 'GROUP' +export type MsgStatus = 'SENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED' | 'REVOKED' + export interface ImMessage { id: string - fromId: string + appId: string + fromUserId: string + fromId?: string toId: string chatType: ChatType msgType: MsgType content: string - extra?: string - revoked: boolean + status: MsgStatus + mentionedUserIds?: string + groupReadCount?: number + revoked?: boolean createdAt: string } +export interface ConversationView { + targetId: string + chatType: ChatType + lastMsgContent?: string | null + lastMsgType?: string | null + lastMsgTime: number + unreadCount: number + isMuted: boolean + isPinned: boolean +} + +export interface PageResult { + content: T[] + totalElements: number + totalPages: number + size: number + number: number + numberOfElements: number + first: boolean + last: boolean + empty: boolean +} + +export interface HistoryQuery { + msgType?: MsgType + keyword?: string + startTime?: string | Date + endTime?: string | Date + page?: number + size?: number +} + +export interface UserProfile { + id?: string + appId?: string + userId: string + nickname?: string | null + avatar?: string | null + gender?: string | null + status?: string | null + createdAt?: string | number | null +} + export interface SendMessageParams { + messageId?: string toId: string chatType: ChatType msgType: MsgType content: string extra?: string + mentionedUserIds?: string } export interface ImEventMap { diff --git a/vite.config.ts b/vite.config.ts index 5ac2035..4b31f9e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,10 @@ import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' -import dts from 'vite-plugin-dts' import { resolve } from 'path' export default defineConfig({ plugins: [ vue(), - dts({ include: ['src/**/*.ts', 'src/**/*.vue'], rollupTypes: true }), ], build: { lib: {