feat(im): 添加即时通讯功能模块
- 添加了 IM API 接口定义,包含登录、消息、群组、好友等接口 - 实现了 ImSDK 核心功能,支持发送各类消息和管理会话 - 集成了 WebSocket 连接管理和自动重连机制 - 添加了本地联系人缓存并优化对话标题显示逻辑 - 实现了 HarmonyOS 平台 HTTP 客户端基础功能
这个提交包含在:
父节点
76aad3409f
当前提交
ebf6a389cf
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,3 +8,4 @@ build/
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
/node_modules/
|
||||
|
||||
1
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
普通文件
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 }),
|
||||
)
|
||||
}
|
||||
102
src/im/useIm.ts
102
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<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,
|
||||
}
|
||||
}
|
||||
|
||||
19
src/index.ts
19
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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户