feat(frontend): WebSocket 客户端 + Contact Store + API

- services/websocket.js: 单例 WS 连接管理(心跳/重连/事件分发)
- store/websocket.js: Pinia Store 管理连接状态
- store/contact.js: 联系人 Store(好友/申请/分组/黑名单/在线状态)
- api/contact.js: 联系人 REST API 封装(17 个接口)
- api/user.js: 用户搜索 API

Made-with: Cursor
This commit is contained in:
bujinyuan
2026-03-02 17:15:21 +08:00
parent a77669ee99
commit 618c3f4409
5 changed files with 630 additions and 20 deletions

View File

@@ -1,27 +1,241 @@
/**
* WebSocket 服务模块(占位)
* WebSocket 连接管理服务
*
* 后续阶段实现 IM 即时通讯和会议信令时使用。
* 职责:
* - 建立和管理 WebSocket 连接
* - 心跳保活机制
* - 断线自动重连
* - 消息收发与事件分发
* 单例模式,管理与后端的 WebSocket 长连接,提供:
* - JWT Token 认证连接
* - 心跳保活30s 间隔)
* - 断线自动重连(指数退避 1s→2s→4s→8s→30s max
* - 事件分发on(event, callback) / off(event, callback)
*
* 对应架构设计见docs/architecture/system-architecture.md 第 4.1 节
* 日志规范见docs/architecture/system-architecture.md 第 8.7 节
* 对应后端GET /ws?token=xxx
*/
// TODO: Phase 2 - IM 模块开发时实现
// 预期功能:
// - connect(token) — 建立 WebSocket 连接,附带认证 Token
// - disconnect() — 主动断开连接
// - send(event, data) — 发送消息
// - on(event, callback) — 监听事件
// - off(event, callback) — 移除监听
// - 自动心跳30s 间隔)
// - 断线重连(指数退避,最大 5 次)
import { BASE_URL } from '@/utils/request'
import { getToken } from '@/utils/storage'
export default {
// 占位导出,后续实现时替换
const WS_BASE = BASE_URL.replace(/^http/, 'ws')
const HEARTBEAT_INTERVAL = 30000
const RECONNECT_BASE_DELAY = 1000
const RECONNECT_MAX_DELAY = 30000
const MAX_RECONNECT_ATTEMPTS = Infinity
class WebSocketService {
constructor() {
this.ws = null
this.listeners = new Map()
this.heartbeatTimer = null
this.reconnectTimer = null
this.reconnectAttempts = 0
this.isManualClose = false
this.seq = 0
}
/**
* 建立 WebSocket 连接
* @param {string} [token] - JWT Token不传则从本地存储获取
*/
connect(token) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return
}
const accessToken = token || getToken()
if (!accessToken) {
console.warn('[WS] 无 Token无法连接')
return
}
this.isManualClose = false
const url = `${WS_BASE}/ws?token=${accessToken}`
try {
// #ifdef H5
this.ws = new WebSocket(url)
// #endif
// #ifndef H5
this.ws = uni.connectSocket({
url,
complete: () => {}
})
// #endif
this._bindEvents()
} catch (err) {
console.error('[WS] 连接创建失败', err)
this._scheduleReconnect()
}
}
/** 主动断开连接 */
disconnect() {
this.isManualClose = true
this._clearTimers()
if (this.ws) {
// #ifdef H5
this.ws.close()
// #endif
// #ifndef H5
uni.closeSocket()
// #endif
this.ws = null
}
}
/**
* 发送消息
* @param {string} event - 事件类型
* @param {Object} [data] - 业务数据
* @returns {number} 消息序号
*/
send(event, data = {}) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('[WS] 连接未就绪,无法发送')
return -1
}
this.seq++
const msg = JSON.stringify({
event,
seq: this.seq,
data,
time: new Date().toISOString()
})
// #ifdef H5
this.ws.send(msg)
// #endif
// #ifndef H5
uni.sendSocketMessage({ data: msg })
// #endif
return this.seq
}
/**
* 注册事件监听
* @param {string} event - 事件类型
* @param {Function} callback - 回调函数
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set())
}
this.listeners.get(event).add(callback)
}
/**
* 移除事件监听
* @param {string} event - 事件类型
* @param {Function} callback - 回调函数
*/
off(event, callback) {
const cbs = this.listeners.get(event)
if (cbs) {
cbs.delete(callback)
}
}
/** 绑定 WebSocket 事件处理 */
_bindEvents() {
// #ifdef H5
this.ws.onopen = () => this._onOpen()
this.ws.onclose = (e) => this._onClose(e)
this.ws.onerror = (e) => this._onError(e)
this.ws.onmessage = (e) => this._onMessage(e.data)
// #endif
// #ifndef H5
uni.onSocketOpen(() => this._onOpen())
uni.onSocketClose((e) => this._onClose(e))
uni.onSocketError((e) => this._onError(e))
uni.onSocketMessage((e) => this._onMessage(e.data))
// #endif
}
_onOpen() {
console.log('[WS] 连接成功')
this.reconnectAttempts = 0
this._startHeartbeat()
this._emit('_connected')
}
_onClose(e) {
console.log('[WS] 连接关闭', e)
this._clearTimers()
this._emit('_disconnected')
if (!this.isManualClose) {
this._scheduleReconnect()
}
}
_onError(e) {
console.error('[WS] 连接错误', e)
}
_onMessage(data) {
try {
const msg = JSON.parse(data)
this._emit(msg.event, msg)
} catch (e) {
console.warn('[WS] 消息解析失败', data)
}
}
/** 分发事件给注册的监听器 */
_emit(event, data) {
const cbs = this.listeners.get(event)
if (cbs) {
cbs.forEach(cb => {
try {
cb(data)
} catch (e) {
console.error(`[WS] 事件处理器异常: ${event}`, e)
}
})
}
}
/** 启动心跳 */
_startHeartbeat() {
this._clearTimers()
this.heartbeatTimer = setInterval(() => {
this.send('heartbeat')
}, HEARTBEAT_INTERVAL)
}
/** 调度重连(指数退避) */
_scheduleReconnect() {
if (this.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error('[WS] 超过最大重连次数')
return
}
const delay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, this.reconnectAttempts),
RECONNECT_MAX_DELAY
)
console.log(`[WS] ${delay}ms 后重连 (第 ${this.reconnectAttempts + 1} 次)`)
this.reconnectTimer = setTimeout(() => {
this.reconnectAttempts++
this.connect()
}, delay)
}
_clearTimers() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer)
this.reconnectTimer = null
}
}
}
/** 全局单例 */
const wsService = new WebSocketService()
export default wsService