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

116
frontend/src/api/contact.js Normal file
View File

@@ -0,0 +1,116 @@
/**
* 联系人模块 API
*
* 对应后端路由:/api/v1/contacts/*
* 接口文档见docs/api/frontend/contact.md
*/
import { get, post, put, del } from '@/utils/request'
/** 获取好友列表 */
const getFriendList = (groupId) => {
const params = groupId ? { group_id: groupId } : {}
return get('/api/v1/contacts', params)
}
/** 发送好友申请 */
const sendFriendRequest = (targetId, message = '') => {
return post('/api/v1/contacts/request', { target_id: targetId, message })
}
/** 接受好友申请 */
const acceptRequest = (requestId) => {
return post('/api/v1/contacts/accept', { request_id: requestId })
}
/** 拒绝好友申请 */
const rejectRequest = (requestId) => {
return post('/api/v1/contacts/reject', { request_id: requestId })
}
/** 获取待处理的好友申请 */
const getPendingRequests = () => {
return get('/api/v1/contacts/requests')
}
/** 删除好友 */
const deleteFriend = (friendId) => {
return del(`/api/v1/contacts/${friendId}`)
}
/** 更新好友备注 */
const updateRemark = (friendId, remark) => {
return put(`/api/v1/contacts/${friendId}/remark`, { remark })
}
/** 拉黑用户 */
const blockUser = (targetId) => {
return post('/api/v1/contacts/block', { target_id: targetId })
}
/** 取消拉黑 */
const unblockUser = (userId) => {
return del(`/api/v1/contacts/block/${userId}`)
}
/** 获取黑名单 */
const getBlockList = () => {
return get('/api/v1/contacts/block')
}
/** 获取分组列表 */
const getGroups = () => {
return get('/api/v1/contacts/groups')
}
/** 创建分组 */
const createGroup = (name) => {
return post('/api/v1/contacts/groups', { name })
}
/** 修改分组 */
const updateGroup = (groupId, name, sortOrder) => {
const data = { name }
if (sortOrder !== undefined) data.sort_order = sortOrder
return put(`/api/v1/contacts/groups/${groupId}`, data)
}
/** 删除分组 */
const deleteGroup = (groupId) => {
return del(`/api/v1/contacts/groups/${groupId}`)
}
/** 移动好友到分组 */
const moveToGroup = (friendId, groupId) => {
return put(`/api/v1/contacts/${friendId}/group`, { group_id: groupId })
}
/** 搜索用户 */
const searchUsers = (keyword, page = 1, pageSize = 20) => {
return get('/api/v1/users/search', { keyword, page, page_size: pageSize })
}
/** 好友推荐 */
const getRecommendFriends = () => {
return get('/api/v1/contacts/recommend')
}
export default {
getFriendList,
sendFriendRequest,
acceptRequest,
rejectRequest,
getPendingRequests,
deleteFriend,
updateRemark,
blockUser,
unblockUser,
getBlockList,
getGroups,
createGroup,
updateGroup,
deleteGroup,
moveToGroup,
searchUsers,
getRecommendFriends
}

16
frontend/src/api/user.js Normal file
View File

@@ -0,0 +1,16 @@
/**
* 用户搜索 API
*
* 对应后端路由:/api/v1/users/*
*/
import { get } from '@/utils/request'
/** 搜索用户(按用户名/昵称模糊匹配) */
const searchUsers = (keyword, page = 1, pageSize = 20) => {
return get('/api/v1/users/search', { keyword, page, page_size: pageSize })
}
export default {
searchUsers
}

View File

@@ -1,27 +1,241 @@
/** /**
* WebSocket 服务模块(占位) * WebSocket 连接管理服务
* *
* 后续阶段实现 IM 即时通讯和会议信令时使用。 * 单例模式,管理与后端的 WebSocket 长连接,提供:
* 职责: * - JWT Token 认证连接
* - 建立和管理 WebSocket 连接 * - 心跳保活30s 间隔)
* - 心跳保活机制 * - 断线自动重连(指数退避 1s→2s→4s→8s→30s max
* - 断线自动重连 * - 事件分发on(event, callback) / off(event, callback)
* - 消息收发与事件分发
* *
* 对应架构设计见docs/architecture/system-architecture.md 第 4.1 节 * 对应后端GET /ws?token=xxx
* 日志规范见docs/architecture/system-architecture.md 第 8.7 节
*/ */
// TODO: Phase 2 - IM 模块开发时实现 import { BASE_URL } from '@/utils/request'
// 预期功能: import { getToken } from '@/utils/storage'
// - connect(token) — 建立 WebSocket 连接,附带认证 Token
// - disconnect() — 主动断开连接
// - send(event, data) — 发送消息
// - on(event, callback) — 监听事件
// - off(event, callback) — 移除监听
// - 自动心跳30s 间隔)
// - 断线重连(指数退避,最大 5 次)
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

View File

@@ -0,0 +1,195 @@
/**
* 联系人状态 Store
*
* 管理好友列表、好友申请、分组、黑名单和在线状态,提供:
* - 好友列表增删改查
* - 好友申请发送/接受/拒绝
* - 好友分组管理
* - 在线状态跟踪(结合 WebSocket 推送)
*
* 对应后端 API/api/v1/contacts/*
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import contactApi from '@/api/contact'
import wsService from '@/services/websocket'
export const useContactStore = defineStore('contact', () => {
/** 好友列表 */
const friendList = ref([])
/** 待处理的好友申请列表 */
const pendingRequests = ref([])
/** 好友分组列表 */
const groups = ref([])
/** 黑名单列表 */
const blockList = ref([])
/** 在线状态映射 userID -> boolean */
const onlineMap = ref({})
/** 未读好友申请数 */
const pendingCount = computed(() => pendingRequests.value.length)
// ==================== Actions ====================
/** 获取好友列表 */
const fetchFriends = async (groupId) => {
const res = await contactApi.getFriendList(groupId)
friendList.value = res.data || []
return friendList.value
}
/** 获取待处理的好友申请 */
const fetchPendingRequests = async () => {
const res = await contactApi.getPendingRequests()
pendingRequests.value = res.data || []
return pendingRequests.value
}
/** 发送好友申请 */
const sendRequest = async (targetId, message) => {
await contactApi.sendFriendRequest(targetId, message)
}
/** 接受好友申请 */
const acceptRequest = async (requestId) => {
await contactApi.acceptRequest(requestId)
pendingRequests.value = pendingRequests.value.filter(r => r.id !== requestId)
await fetchFriends()
}
/** 拒绝好友申请 */
const rejectRequest = async (requestId) => {
await contactApi.rejectRequest(requestId)
pendingRequests.value = pendingRequests.value.filter(r => r.id !== requestId)
}
/** 删除好友 */
const deleteFriend = async (friendId) => {
await contactApi.deleteFriend(friendId)
friendList.value = friendList.value.filter(f => f.user_id !== friendId)
}
/** 更新好友备注 */
const updateRemark = async (friendId, remark) => {
await contactApi.updateRemark(friendId, remark)
const friend = friendList.value.find(f => f.user_id === friendId)
if (friend) friend.remark = remark
}
/** 拉黑用户 */
const blockUser = async (targetId) => {
await contactApi.blockUser(targetId)
friendList.value = friendList.value.filter(f => f.user_id !== targetId)
}
/** 取消拉黑 */
const unblockUser = async (userId) => {
await contactApi.unblockUser(userId)
blockList.value = blockList.value.filter(b => b.user_id !== userId)
}
/** 获取黑名单列表 */
const fetchBlockList = async () => {
const res = await contactApi.getBlockList()
blockList.value = res.data || []
return blockList.value
}
/** 获取分组列表 */
const fetchGroups = async () => {
const res = await contactApi.getGroups()
groups.value = res.data || []
return groups.value
}
/** 创建分组 */
const createGroup = async (name) => {
const res = await contactApi.createGroup(name)
groups.value.push(res.data)
return res.data
}
/** 更新分组 */
const updateGroup = async (groupId, name, sortOrder) => {
await contactApi.updateGroup(groupId, name, sortOrder)
const group = groups.value.find(g => g.id === groupId)
if (group) {
group.name = name
if (sortOrder !== undefined) group.sort_order = sortOrder
}
}
/** 删除分组 */
const deleteGroup = async (groupId) => {
await contactApi.deleteGroup(groupId)
groups.value = groups.value.filter(g => g.id !== groupId)
}
/** 移动好友到分组 */
const moveToGroup = async (friendId, groupId) => {
await contactApi.moveToGroup(friendId, groupId)
const friend = friendList.value.find(f => f.user_id === friendId)
if (friend) friend.group_id = groupId
}
/** 更新单个用户的在线状态 */
const setOnline = (userId, online) => {
onlineMap.value[userId] = online
const friend = friendList.value.find(f => f.user_id === userId)
if (friend) friend.is_online = online
}
/** 初始化 WebSocket 事件监听 */
const initWsListeners = () => {
wsService.on('notify.friend.request', () => {
fetchPendingRequests()
})
wsService.on('contact.request.accepted', () => {
fetchFriends()
})
wsService.on('user.status.online', (msg) => {
if (msg && msg.data) {
setOnline(msg.data.user_id, true)
}
})
wsService.on('user.status.offline', (msg) => {
if (msg && msg.data) {
setOnline(msg.data.user_id, false)
}
})
}
return {
friendList,
pendingRequests,
groups,
blockList,
onlineMap,
pendingCount,
fetchFriends,
fetchPendingRequests,
sendRequest,
acceptRequest,
rejectRequest,
deleteFriend,
updateRemark,
blockUser,
unblockUser,
fetchBlockList,
fetchGroups,
createGroup,
updateGroup,
deleteGroup,
moveToGroup,
setOnline,
initWsListeners
}
})

View File

@@ -0,0 +1,69 @@
/**
* WebSocket 连接状态 Store
*
* 管理 WebSocket 连接生命周期,提供:
* - 连接/断开操作
* - 连接状态跟踪connected/disconnected
* - 与 user store 联动(登录时自动连接,登出时断开)
*
* 对应服务层services/websocket.js
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import wsService from '@/services/websocket'
export const useWebSocketStore = defineStore('websocket', () => {
/** 连接状态 */
const connected = ref(false)
/** 是否已连接 */
const isConnected = computed(() => connected.value)
/**
* 初始化 WebSocket 连接
* @param {string} [token] - JWT Token
*/
const connect = (token) => {
wsService.on('_connected', () => {
connected.value = true
})
wsService.on('_disconnected', () => {
connected.value = false
})
wsService.connect(token)
}
/** 断开 WebSocket 连接 */
const disconnect = () => {
wsService.disconnect()
connected.value = false
}
/**
* 注册事件监听(代理 wsService
* @param {string} event - 事件类型
* @param {Function} callback - 回调函数
*/
const on = (event, callback) => {
wsService.on(event, callback)
}
/**
* 移除事件监听(代理 wsService
* @param {string} event - 事件类型
* @param {Function} callback - 回调函数
*/
const off = (event, callback) => {
wsService.off(event, callback)
}
return {
connected,
isConnected,
connect,
disconnect,
on,
off
}
})