feat(im): 添加即时通讯功能模块

- 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口
- 实现了 ImSDK 核心功能,支持发送各类消息和管理会话
- 集成了 WebSocket 连接管理和自动重连机制
- 添加了本地联系人缓存并优化对话标题显示逻辑
- 实现了 HarmonyOS 平台 HTTP 客户端基础功能
这个提交包含在:
XuqmGroup 2026-04-28 16:55:12 +08:00
父节点 76aad3409f
当前提交 ebf6a389cf
共有 11 个文件被更改,包括 351 次插入27 次删除

1
.gitignore vendored
查看文件

@ -8,3 +8,4 @@ build/
*.iml
.idea/
*.log
/node_modules/

1
node_modules 符号链接
查看文件

@ -0,0 +1 @@
../XuqmGroup-Web/node_modules

查看文件

@ -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": {

查看文件

@ -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<T>(method: string, path: string, body?: unknown): Promise<T> {
function buildUrl(path: string, query?: Record<string, QueryValue>): 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<T>(
method: string,
path: string,
body?: unknown,
query?: Record<string, QueryValue>,
): Promise<T> {
const token = _tokenGetter()
const headers: Record<string, string> = { '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<T>(method: string, path: string, body?: unknown): Promise
}
export const http = {
get: <T>(path: string) => request<T>('GET', path),
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
delete: <T>(path: string) => request<T>('DELETE', path),
get: <T>(path: string, query?: Record<string, QueryValue>) => request<T>('GET', path, undefined, query),
post: <T>(path: string, body?: unknown, query?: Record<string, QueryValue>) => request<T>('POST', path, body, query),
put: <T>(path: string, body?: unknown, query?: Record<string, QueryValue>) => request<T>('PUT', path, body, query),
delete: <T>(path: string, query?: Record<string, QueryValue>) => request<T>('DELETE', path, undefined, query),
}

查看文件

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

查看文件

@ -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<K extends keyof ImEventMap> = ImEventMap[K]
@ -13,10 +13,10 @@ export class ImClient {
private listeners: { [K in keyof ImEventMap]?: Set<EventListener<K>> } = {}
on<K extends keyof ImEventMap>(event: K, handler: EventListener<K>): this {
if (!this.listeners[event]) {
(this.listeners[event] as Set<EventListener<K>>) = new Set()
}
(this.listeners[event] as Set<EventListener<K>>).add(handler)
const store = this.listeners as Record<string, Set<unknown>>
const listeners = (store[event] ?? new Set<EventListener<K>>()) as Set<EventListener<K>>
listeners.add(handler)
store[event] = listeners as Set<unknown>
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',
}
}
}

102
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<string, string | number | boolean | Date | null | undefined>) {
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<ConversationView[]> {
return http.get<ConversationView[]>('/api/im/conversations', appQuery({ page: 0, size }))
}
export function fetchHistory(toId: string, query: HistoryQuery = {}): Promise<PageResult<ImMessage>> {
return http.get<PageResult<ImMessage>>(
`/api/im/messages/history/${encodeURIComponent(toId)}`,
appQuery(normalizeHistoryQuery(query)),
)
}
export function fetchGroupHistory(groupId: string, query: HistoryQuery = {}): Promise<PageResult<ImMessage>> {
return http.get<PageResult<ImMessage>>(
`/api/im/messages/group-history/${encodeURIComponent(groupId)}`,
appQuery(normalizeHistoryQuery(query)),
)
}
export function markRead(targetId: string, chatType: ChatType = 'SINGLE'): Promise<void> {
return http.put<void>(
`/api/im/conversations/${encodeURIComponent(targetId)}/read`,
undefined,
appQuery({ chatType }),
)
}
export function setConversationPinned(targetId: string, chatType: ChatType, pinned: boolean): Promise<void> {
return http.put<void>(
`/api/im/conversations/${encodeURIComponent(targetId)}/pinned`,
undefined,
appQuery({ chatType, pinned }),
)
}
export function setConversationMuted(targetId: string, chatType: ChatType, muted: boolean): Promise<void> {
return http.put<void>(
`/api/im/conversations/${encodeURIComponent(targetId)}/muted`,
undefined,
appQuery({ chatType, muted }),
)
}
export function setDraft(targetId: string, chatType: ChatType, draft: string): Promise<void> {
return http.put<void>(
`/api/im/conversations/${encodeURIComponent(targetId)}/draft`,
undefined,
appQuery({ chatType, draft }),
)
}
export function deleteConversation(targetId: string, chatType: ChatType): Promise<void> {
return http.delete<void>(
`/api/im/conversations/${encodeURIComponent(targetId)}`,
appQuery({ chatType }),
)
}
export function getProfile(userId: string): Promise<UserProfile> {
return http.get<UserProfile>(`/api/im/accounts/${encodeURIComponent(userId)}`, appQuery())
}
export function updateProfile(
userId: string,
nickname?: string,
avatar?: string,
gender?: string,
): Promise<UserProfile> {
return http.put<UserProfile>(
`/api/im/accounts/${encodeURIComponent(userId)}`,
undefined,
appQuery({ nickname, avatar, gender }),
)
}

查看文件

@ -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<ImClient | null>(null)
const messages = ref<ImMessage[]>([])
const conversations = ref<ConversationView[]>([])
const connected = ref(false)
const error = ref<Event | null>(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<PageResult<ImMessage>> {
return fetchHistory(toId, query)
}
async function loadGroupHistory(groupId: string, query: HistoryQuery = {}): Promise<PageResult<ImMessage>> {
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,
}
}

查看文件

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

查看文件

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

查看文件

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