commit 115e093ce2c9a4234233982f87c598899c0f23f6 Author: XuqmGroup Date: Tue Apr 21 22:07:29 2026 +0800 chore: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10dfc90 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.DS_Store +*.class +target/ +build/ +.gradle/ +*.iml +.idea/ +*.log diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e21d136 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +registry=https://nexus.xuqinmin.com/repository/npm-hosted/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..bd8474d --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "@xuqm/vue3-sdk", + "version": "0.1.0", + "description": "XuqmGroup Vue3 SDK — IM & platform integration for web", + "private": false, + "publishConfig": { + "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" + }, + "main": "dist/index.cjs.js", + "module": "dist/index.es.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.es.js", + "require": "./dist/index.cjs.js", + "types": "./dist/index.d.ts" + } + }, + "files": ["dist"], + "scripts": { + "dev": "vite build --watch", + "build": "tsc --noEmit && vite build", + "type-check": "tsc --noEmit" + }, + "peerDependencies": { + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "^5.6.0", + "vite": "^6.0.0", + "vite-plugin-dts": "^4.3.0", + "vue": "^3.5.13" + } +} diff --git a/src/core/http.ts b/src/core/http.ts new file mode 100644 index 0000000..9b4e1e2 --- /dev/null +++ b/src/core/http.ts @@ -0,0 +1,32 @@ +import type { ApiResponse } from '../types' + +let _baseUrl = '' +let _tokenGetter: (() => string | null) = () => null + +export function configureHttp(baseUrl: string, tokenGetter: () => string | null) { + _baseUrl = baseUrl.replace(/\/$/, '') + _tokenGetter = tokenGetter +} + +async function request(method: string, path: string, body?: unknown): Promise { + const token = _tokenGetter() + const headers: Record = { 'Content-Type': 'application/json' } + if (token) headers['Authorization'] = `Bearer ${token}` + + const res = await fetch(`${_baseUrl}${path}`, { + method, + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }) + + const json: ApiResponse = await res.json() + if (json.code !== 200) throw new Error(json.message) + return json.data +} + +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), +} diff --git a/src/core/sdk.ts b/src/core/sdk.ts new file mode 100644 index 0000000..dde1515 --- /dev/null +++ b/src/core/sdk.ts @@ -0,0 +1,24 @@ +import type { SDKConfig } from '../types' +import { configureHttp } from './http' + +let _config: SDKConfig | null = null +let _token: string | null = null + +export function init(config: SDKConfig) { + _config = config + configureHttp(config.apiBaseUrl, () => _token) + if (config.debug) console.log('[XuqmSDK] initialized', config.appKey) +} + +export function setToken(token: string | null) { + _token = token +} + +export function getToken(): string | null { + return _token +} + +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 new file mode 100644 index 0000000..e907fb8 --- /dev/null +++ b/src/im/ImClient.ts @@ -0,0 +1,112 @@ +import type { ImMessage, SendMessageParams, ImEventMap } from '../types' +import { getConfig, getToken } from '../core/sdk' + +type EventListener = ImEventMap[K] + +const MAX_RECONNECT_DELAY = 30_000 + +export class ImClient { + private ws: WebSocket | null = null + private reconnectDelay = 3_000 + private reconnectTimer: ReturnType | null = null + private destroyed = false + 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) + return this + } + + off(event: K, handler: EventListener): this { + (this.listeners[event] as Set> | undefined)?.delete(handler) + return this + } + + private emit(event: K, ...args: Parameters): void { + (this.listeners[event] as Set<(...a: unknown[]) => void> | undefined)?.forEach((h) => + h(...(args as unknown[])) + ) + } + + connect(): void { + if (this.destroyed) return + const config = getConfig() + const token = getToken() + const url = `${config.imBaseUrl}/ws/im?token=${token ?? ''}` + + this.ws = new WebSocket(url) + + this.ws.onopen = () => { + this.reconnectDelay = 3_000 + if (config.debug) console.log('[ImClient] connected') + this.emit('connected') + } + + this.ws.onmessage = (event) => { + try { + const frame = JSON.parse(event.data as string) + if (frame.type === 'MESSAGE') { + this.emit('message', frame.payload as ImMessage) + } else if (frame.type === 'REVOKE') { + this.emit('revoke', frame.payload as { msgId: string; operatorId: string }) + } + } catch { + // ignore malformed frames + } + } + + this.ws.onclose = (event) => { + this.emit('disconnected', event.code, event.reason) + if (!this.destroyed) this.scheduleReconnect() + } + + this.ws.onerror = (event) => { + this.emit('error', event) + } + } + + send(params: SendMessageParams): void { + if (this.ws?.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected') + } + this.ws.send( + JSON.stringify({ + destination: '/app/chat.send', + payload: params, + }) + ) + } + + revoke(msgId: string): void { + if (this.ws?.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected') + } + this.ws.send( + JSON.stringify({ + destination: '/app/chat.revoke', + payload: { msgId }, + }) + ) + } + + disconnect(): void { + this.destroyed = true + if (this.reconnectTimer) clearTimeout(this.reconnectTimer) + this.ws?.close() + this.ws = null + } + + private scheduleReconnect(): void { + if (this.destroyed) return + if (getConfig().debug) { + console.log(`[ImClient] reconnect in ${this.reconnectDelay}ms`) + } + this.reconnectTimer = setTimeout(() => { + this.connect() + this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY) + }, this.reconnectDelay) + } +} diff --git a/src/im/useIm.ts b/src/im/useIm.ts new file mode 100644 index 0000000..6f947be --- /dev/null +++ b/src/im/useIm.ts @@ -0,0 +1,38 @@ +import { ref, shallowRef, onUnmounted } from 'vue' +import { ImClient } from './ImClient' +import type { ImMessage, SendMessageParams } from '../types' + +export function useIm() { + const client = shallowRef(null) + const messages = ref([]) + const connected = ref(false) + const error = ref(null) + + function connect() { + const im = new ImClient() + im.on('connected', () => { connected.value = true }) + im.on('disconnected', () => { connected.value = false }) + im.on('message', (msg) => { messages.value = [...messages.value, msg] }) + im.on('error', (e) => { error.value = e }) + im.connect() + client.value = im + } + + function send(params: SendMessageParams) { + client.value?.send(params) + } + + function revoke(msgId: string) { + client.value?.revoke(msgId) + } + + function disconnect() { + client.value?.disconnect() + client.value = null + connected.value = false + } + + onUnmounted(disconnect) + + return { connect, send, revoke, disconnect, messages, connected, error } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..db9b659 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,13 @@ +export { init, setToken, getToken, getConfig } from './core/sdk' +export { http } from './core/http' +export { ImClient } from './im/ImClient' +export { useIm } from './im/useIm' +export type { + SDKConfig, + MsgType, + ChatType, + ImMessage, + SendMessageParams, + ImEventMap, + ApiResponse, +} from './types' diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..47b3db9 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,59 @@ +export interface SDKConfig { + appKey: string + appSecret: string + apiBaseUrl: string + imBaseUrl: string + debug?: boolean +} + +export type MsgType = + | 'TEXT' + | 'IMAGE' + | 'VIDEO' + | 'AUDIO' + | 'FILE' + | 'CUSTOM' + | 'LOCATION' + | 'NOTIFY' + | 'RICH_TEXT' + | 'CALL_AUDIO' + | 'CALL_VIDEO' + | 'REVOKED' + | 'FORWARD' + +export type ChatType = 'SINGLE' | 'GROUP' + +export interface ImMessage { + id: string + fromId: string + toId: string + chatType: ChatType + msgType: MsgType + content: string + extra?: string + revoked: boolean + createdAt: string +} + +export interface SendMessageParams { + toId: string + chatType: ChatType + msgType: MsgType + content: string + extra?: string +} + +export interface ImEventMap { + message: (msg: ImMessage) => void + revoke: (data: { msgId: string; operatorId: string }) => void + connected: () => void + disconnected: (code: number, reason: string) => void + error: (err: Event) => void +} + +export interface ApiResponse { + code: number + status: string + data: T + message: string +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fe8e9b2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationDir": "dist", + "outDir": "dist", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..5ac2035 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,28 @@ +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: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'XuqmVue3SDK', + formats: ['es', 'cjs'], + fileName: (format) => `index.${format}.js`, + }, + rollupOptions: { + external: ['vue'], + output: { + globals: { vue: 'Vue' }, + }, + }, + }, + resolve: { + alias: { '@': resolve(__dirname, 'src') }, + }, +})