Compare commits
9 Commits
7c12697579
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 94dfa907f9 | |||
| e1faf45683 | |||
| b85c3cbd60 | |||
| dd97f9cb70 | |||
| 7b3d31a57c | |||
| 3f6d3f2761 | |||
| 112ab3312d | |||
| 9e36c91e13 | |||
| 45f4d69446 |
@@ -9,7 +9,7 @@ import { watch } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { ElConfigProvider } from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { useSse } from '@/common/hooks/useSse'
|
||||
import { useSse } from '@/common/sse'
|
||||
import { useAccountStore } from '@/stores/modules/account'
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
@@ -53,11 +53,15 @@ export interface LoginResponse {
|
||||
* 使用用户名和密码进行登录认证,成功返回双 Token。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* 注意:设置 __skipRefresh=true,避免密码错误返回 HTTP 401 时触发无意义的刷新流程。
|
||||
*
|
||||
* @param req - 登录请求参数
|
||||
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||
*/
|
||||
export function login(req: LoginRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/login', req)
|
||||
return requestClient.post<LoginResponse>('/account/login', req, {
|
||||
__skipRefresh: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,11 +71,15 @@ export function login(req: LoginRequest) {
|
||||
* 注册成功后自动登录并返回双 Token。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* 注意:设置 __skipRefresh=true,避免意外 401 时触发无意义的刷新流程。
|
||||
*
|
||||
* @param req - 注册请求参数
|
||||
* @returns 包含 accessToken 和 refreshToken 的响应
|
||||
*/
|
||||
export function register(req: RegisterRequest) {
|
||||
return requestClient.post<LoginResponse>('/account/register', req)
|
||||
return requestClient.post<LoginResponse>('/account/register', req, {
|
||||
__skipRefresh: true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,15 +12,12 @@ import type {
|
||||
/**
|
||||
* 获取指定游戏的房间列表
|
||||
*
|
||||
* 返回某个游戏下所有房间(桌台)的信息,支持按状态筛选。
|
||||
* 该接口无需登录即可调用。
|
||||
*
|
||||
* @param params - 查询参数,包含 gameId 和可选的 status 筛选
|
||||
* @returns 房间列表
|
||||
*/
|
||||
export function getRoomList(params: GetRoomListRequest) {
|
||||
return requestClient.get<GetRoomListResponse>(
|
||||
'/game/room/list',
|
||||
'/room/list',
|
||||
params as unknown as Record<string, unknown>
|
||||
)
|
||||
}
|
||||
@@ -28,39 +25,29 @@ export function getRoomList(params: GetRoomListRequest) {
|
||||
/**
|
||||
* 创建房间
|
||||
*
|
||||
* 根据当前游戏和选定的参数创建新房间。
|
||||
* 房间名称留空时由后端自动生成"桌 XX"编号。
|
||||
* 密码留空时房间为公开房间。
|
||||
*
|
||||
* @param data - 创建房间参数
|
||||
* @returns 新建的房间信息
|
||||
*/
|
||||
export function createRoom(data: CreateRoomRequest) {
|
||||
return requestClient.post<CreateRoomResponse>('/game/room/create', data)
|
||||
return requestClient.post<CreateRoomResponse>('/room/create', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间
|
||||
*
|
||||
* 玩家通过此接口加入指定房间,成为房间内的玩家。
|
||||
* 公开房间无需传密码。加入成功后需通过 WebSocket 进行后续交互。
|
||||
*
|
||||
* @param data - 加入房间参数(roomId 和可选的 password)
|
||||
* @returns 更新后的房间信息
|
||||
*/
|
||||
export function joinRoom(data: JoinRoomRequest) {
|
||||
return requestClient.post<RoomItem>('/game/room/join', data)
|
||||
return requestClient.post<RoomItem>('/room/join', data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间详情
|
||||
*
|
||||
* 用于进入房间页时获取初始快照(棋盘状态、玩家信息等)。
|
||||
* 无需登录即可调用(观战者也需访问)。
|
||||
*
|
||||
* @param roomId 房间 ID
|
||||
* @returns 房间详情
|
||||
*/
|
||||
export function getRoomDetail(roomId: string) {
|
||||
return requestClient.get<RoomDetailResponse>(`/game/room/${roomId}`)
|
||||
return requestClient.get<RoomDetailResponse>(`/room/${roomId}`)
|
||||
}
|
||||
|
||||
@@ -65,8 +65,6 @@ export interface RoomItem {
|
||||
mode: string
|
||||
/** 房主昵称 */
|
||||
creatorName: string
|
||||
/** 是否允许观战 */
|
||||
allowSpectate: boolean
|
||||
}
|
||||
|
||||
/** 获取游戏列表 — 无请求参数 */
|
||||
@@ -101,8 +99,6 @@ export interface CreateRoomRequest {
|
||||
stakes: number
|
||||
/** 最大玩家数 */
|
||||
maxPlayers: number
|
||||
/** 是否允许观战 */
|
||||
allowSpectate?: boolean
|
||||
}
|
||||
|
||||
/** 创建房间响应 */
|
||||
@@ -124,7 +120,6 @@ export interface RoomDetailResponse {
|
||||
status: RoomStatus
|
||||
creatorId: string
|
||||
creatorName: string
|
||||
allowSpectate: boolean
|
||||
players: RoomPlayerDetail[]
|
||||
/** 棋盘状态(对局中才有) */
|
||||
boardState?: number[][]
|
||||
@@ -185,8 +180,8 @@ export type WsOutboundType =
|
||||
| 'game_over'
|
||||
| 'player_join'
|
||||
| 'player_leave'
|
||||
| 'player_disconnected'
|
||||
| 'player_reconnected'
|
||||
| 'ready_change'
|
||||
| 'spectator_join'
|
||||
| 'spectator_leave'
|
||||
| 'move_history'
|
||||
| 'error'
|
||||
|
||||
@@ -34,32 +34,17 @@ export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig)
|
||||
config
|
||||
|
||||
/**
|
||||
* 将 401 业务错误转换为类 AxiosError 结构,统一由 rejected 处理
|
||||
* 透传成功响应,HTTP 401 统一由 onRejected 处理
|
||||
* <p>
|
||||
* 后端改造后,认证错误(BAD_CREDENTIALS、REFRESH_TOKEN_INVALID 等)
|
||||
* 返回 HTTP 401 而非 HTTP 200 + code=401,因此无需在此将业务码转换为 HTTP 错误。
|
||||
*/
|
||||
async function onFulfilled(response: AxiosResponse<ApiResponse>) {
|
||||
const data = response.data
|
||||
// 前一个拦截器在 code !== 401 时已将 AxiosResponse 解包为 ApiResponse,
|
||||
// 此时 response.data 是业务数据本身,可能为 null(如 updateStatus 返回 data: null)
|
||||
if (!data || data.code !== 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
const reqConfig = response.config as InternalRequestConfig
|
||||
// 跳过刷新或已重试过 → 终端 401,直接重新认证
|
||||
if (reqConfig.__isRetryRequest || reqConfig.__skipRefresh) {
|
||||
await doReAuthenticate()
|
||||
return Promise.reject(data)
|
||||
}
|
||||
// 构造类 AxiosError,统一由 rejected 走刷新流程
|
||||
const fakeError = {
|
||||
config: response.config,
|
||||
response: { status: 401, data }
|
||||
} as AxiosError
|
||||
return onRejected(fakeError)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 HTTP 401(以及业务 401 转换后的类 AxiosError)
|
||||
* 处理 HTTP 401 错误,启动队列式 Token 刷新机制
|
||||
*/
|
||||
async function onRejected(error: AxiosError) {
|
||||
const { config: reqConfig, response } = error
|
||||
|
||||
@@ -58,6 +58,7 @@ class RequestClient {
|
||||
this.setupInterceptors()
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
/** 注册请求和响应拦截器 */
|
||||
private setupInterceptors(): void {
|
||||
// === 请求拦截器 ===
|
||||
@@ -86,8 +87,8 @@ class RequestClient {
|
||||
)
|
||||
|
||||
// === 通用响应拦截器 ===
|
||||
// 职责:解包 ApiResponse + 发布非 401 的错误事件。
|
||||
// 401 全部透传,由 refreshInterceptor 统一处理。
|
||||
// 职责:解包 ApiResponse + 按 HTTP 状态码分类处理错误。
|
||||
// HTTP 401 全部透传,由 refreshInterceptor 统一处理。
|
||||
this.instance.interceptors.response.use(
|
||||
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
|
||||
(response) => {
|
||||
@@ -97,30 +98,41 @@ class RequestClient {
|
||||
return data
|
||||
}
|
||||
|
||||
// 401 透传给刷新拦截器,此处不处理
|
||||
if (data.code === 401) {
|
||||
return response
|
||||
}
|
||||
|
||||
// code !== 0 但 HTTP 2xx — 未改造端点或特殊场景,按业务错误处理
|
||||
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||
type: 'business',
|
||||
code: data.code,
|
||||
httpStatus: response.status,
|
||||
message: data.message || '请求失败'
|
||||
})
|
||||
return Promise.reject(data)
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
|
||||
// HTTP 401 透传给刷新拦截器
|
||||
if (error.response?.status === 401) {
|
||||
if (status === 401) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// HTTP 400/403/404/409/405/500 — 从 body 提取 ApiResponse
|
||||
const body = error.response?.data as ApiResponse | undefined
|
||||
if (body && status) {
|
||||
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||
type: 'business',
|
||||
code: body.code,
|
||||
httpStatus: status,
|
||||
message: body.message || `请求错误 (${status})`
|
||||
})
|
||||
return Promise.reject(body)
|
||||
}
|
||||
|
||||
// 网络异常(无 response)
|
||||
eventBus.emit(AppEvents.REQUEST_ERROR, {
|
||||
type: error.response ? 'http' : 'network',
|
||||
code: error.response?.status,
|
||||
message: error.response
|
||||
? `请求错误 (${error.response.status})`
|
||||
: '网络异常,请检查网络连接'
|
||||
code: status,
|
||||
httpStatus: status,
|
||||
message: error.response ? `请求错误 (${status})` : '网络异常,请检查网络连接'
|
||||
})
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -150,7 +162,7 @@ class RequestClient {
|
||||
},
|
||||
refreshTokenQueue: this.refreshTokenQueue
|
||||
})
|
||||
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
|
||||
// 拦截器解包 axiosResponse → ApiResponse
|
||||
this.instance.interceptors.response.use(onFulfilled, onRejected)
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface RequestErrorEvent {
|
||||
type: 'business' | 'http' | 'network'
|
||||
/** 业务状态码(business 类型时有效) */
|
||||
code?: number
|
||||
/** HTTP 状态码(http/business 类型时有效) */
|
||||
httpStatus?: number
|
||||
/** 错误消息 */
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import { sseBus } from '@/stores/sseBus'
|
||||
import { useAccountStore } from '@/stores/modules/account'
|
||||
|
||||
/**
|
||||
* SSE 连接管理 composable(基于 @microsoft/fetch-event-source)
|
||||
*
|
||||
* 设计原则:
|
||||
* - 全局唯一:同一时间只有一条活跃的 SSE 连接
|
||||
* - token 驱动:仅 accessToken 变更时才断开重连
|
||||
* - 防重复:已连接(相同 token)时 connect() 直接跳过
|
||||
* - 所有业务变更通过 sseBus 事件总线推送,各组件自行订阅
|
||||
*
|
||||
* 使用示例:
|
||||
* ```ts
|
||||
* const { isConnected, connect, disconnect } = useSse()
|
||||
* watch(() => accountStore.accessToken, (v) => v ? connect() : disconnect())
|
||||
* ```
|
||||
*/
|
||||
export function useSse() {
|
||||
/** SSE 连接是否已建立 */
|
||||
const isConnected = ref(false)
|
||||
/** 用于取消 inflight 连接的控制器 */
|
||||
let abortController: AbortController | null = null
|
||||
/** 当前连接使用的 token(用于判断是否需要重连) */
|
||||
let currentToken: string | null = null
|
||||
/** 是否正在建立连接(防并发) */
|
||||
let connecting = false
|
||||
/** 当前连接的重试计数 */
|
||||
let retryCount = 0
|
||||
/** 是否正在刷新 token(防并发) */
|
||||
let isRefreshing = false
|
||||
|
||||
/**
|
||||
* 建立 SSE 连接
|
||||
*
|
||||
* 通过 fetch-event-source 发起,设置 saToken 请求头鉴权。
|
||||
* 连接期间持续接收服务端推送事件,直到 abort 或服务端关闭。
|
||||
*
|
||||
* 防重复:如果已用相同 token 连接或正在连接中,直接跳过。
|
||||
*/
|
||||
async function connect(): Promise<void> {
|
||||
const accountStore = useAccountStore()
|
||||
const token = accountStore.accessToken
|
||||
if (!token) {
|
||||
console.warn('[SSE] 无 accessToken,跳过连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 相同 token 已连接或正在连接中,跳过
|
||||
if (currentToken === token && (isConnected.value || connecting)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 取消旧连接
|
||||
abort()
|
||||
|
||||
connecting = true
|
||||
currentToken = token
|
||||
abortController = new AbortController()
|
||||
|
||||
// Vite 代理: /api/sse/subscribe → 后端 /sse/subscribe
|
||||
fetchEventSource('/api/sse/subscribe', {
|
||||
headers: { saToken: token },
|
||||
signal: abortController.signal,
|
||||
|
||||
/** 连接建立 */
|
||||
async onopen() {
|
||||
connecting = false
|
||||
isConnected.value = true
|
||||
retryCount = 0
|
||||
console.log('[SSE] 连接已建立')
|
||||
},
|
||||
|
||||
/** 收到服务端事件 */
|
||||
onmessage(msg) {
|
||||
// 心跳和 connected 确认消息可能 data 为空
|
||||
if (!msg.data) return
|
||||
|
||||
try {
|
||||
const data = JSON.parse(msg.data)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sseBus.emit(msg.event as any, data)
|
||||
} catch {
|
||||
console.warn('[SSE] 事件数据解析失败:', msg.event, msg.data)
|
||||
}
|
||||
},
|
||||
|
||||
/** 连接关闭 */
|
||||
onclose() {
|
||||
connecting = false
|
||||
isConnected.value = false
|
||||
console.log('[SSE] 连接已关闭')
|
||||
},
|
||||
|
||||
/**
|
||||
* 连接错误处理(必须同步,签名: (err) => number | void)
|
||||
*
|
||||
* 401 → fire-and-forget 刷新 token,阻止当前连接重连,
|
||||
* 刷新成功后由 Vue watcher 触发 connect() 自动重连
|
||||
* 其他错误 → 返回重试延迟(ms),库自动排队重连
|
||||
*/
|
||||
onerror(err) {
|
||||
connecting = false
|
||||
isConnected.value = false
|
||||
|
||||
// 鉴权失败 → 刷新 token(异步 fire-and-forget,onerror 本身保持同步)
|
||||
if (err.response?.status === 401) {
|
||||
if (isRefreshing) {
|
||||
// 已有刷新在进行中,直接放弃本次连接
|
||||
throw err
|
||||
}
|
||||
|
||||
isRefreshing = true
|
||||
const store = useAccountStore()
|
||||
|
||||
if (!store.refreshToken) {
|
||||
sseBus.emit('error', { message: '登录已过期,请重新登录', retryCount: 0 })
|
||||
throw err
|
||||
}
|
||||
|
||||
// 异步刷新,不阻塞 onerror 返回
|
||||
store
|
||||
.refreshAccessToken()
|
||||
.then((newToken) => {
|
||||
if (newToken) {
|
||||
console.log('[SSE] token 刷新成功,重新建立连接')
|
||||
retryCount = 0
|
||||
// connect() 由 App.vue 的 watch 触发(accessToken 变更)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.warn('[SSE] token 刷新失败')
|
||||
sseBus.emit('error', { message: '实时连接异常,请刷新页面', retryCount: 0 })
|
||||
})
|
||||
.finally(() => {
|
||||
isRefreshing = false
|
||||
})
|
||||
|
||||
throw err // 不自动重连
|
||||
}
|
||||
|
||||
// 其他错误:指数退避重连
|
||||
retryCount++
|
||||
if (retryCount >= 5) {
|
||||
sseBus.emit('error', {
|
||||
message: '实时连接异常,请刷新页面',
|
||||
retryCount
|
||||
})
|
||||
throw err // 超过阈值,停止重连
|
||||
}
|
||||
|
||||
// 返回重试间隔(ms),指数退避:3s, 6s, 12s, 24s,最大 30s
|
||||
const delay = Math.min(3000 * Math.pow(2, retryCount - 1), 30000)
|
||||
console.warn(`[SSE] 连接错误,${delay / 1000}s 后重试 (${retryCount}/5)`)
|
||||
return delay
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消当前 SSE 连接
|
||||
*/
|
||||
function abort(): void {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
connecting = false
|
||||
isConnected.value = false
|
||||
currentToken = null
|
||||
retryCount = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开 SSE 连接
|
||||
*
|
||||
* 在用户登出时调用。
|
||||
*/
|
||||
function disconnect(): void {
|
||||
abort()
|
||||
console.log('[SSE] 连接已断开')
|
||||
}
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
connect,
|
||||
disconnect
|
||||
}
|
||||
}
|
||||
173
src/common/sse/SseClient.ts
Normal file
173
src/common/sse/SseClient.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { fetchEventSource } from '@microsoft/fetch-event-source'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/**
|
||||
* SSE 连接客户端
|
||||
*
|
||||
* 只负责连接生命周期(建连、断开、重连、心跳),不包含任何业务逻辑。
|
||||
* 收到消息后通过 onMessage 回调抛出,由业务层自行分发。
|
||||
*/
|
||||
export class SseClient {
|
||||
private abortController: AbortController | null = null
|
||||
private retryCount = 0
|
||||
private _connected = false
|
||||
private _connecting = false
|
||||
|
||||
/** 重连配置 */
|
||||
private readonly maxRetries: number
|
||||
private readonly baseRetryDelayMs: number
|
||||
private readonly maxRetryDelayMs: number
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly getToken: () => string | null,
|
||||
private readonly callbacks: SseClientCallbacks,
|
||||
opts?: SseClientOptions
|
||||
) {
|
||||
this.maxRetries = opts?.maxRetries ?? 5
|
||||
this.baseRetryDelayMs = opts?.baseRetryDelayMs ?? 3000
|
||||
this.maxRetryDelayMs = opts?.maxRetryDelayMs ?? 30000
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get connecting(): boolean {
|
||||
return this._connecting
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 SSE 连接
|
||||
*
|
||||
* 幂等调用:已连接或正在连接则跳过。
|
||||
* 连接断开时自动指数退避重连,最多 maxRetries 次。
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
const token = this.getToken()
|
||||
if (!token) {
|
||||
this.callbacks.onError?.('缺少 accessToken')
|
||||
return
|
||||
}
|
||||
|
||||
// 幂等:相同 token 已连接或正在连接则跳过
|
||||
if (this._connected || this._connecting) {
|
||||
return
|
||||
}
|
||||
|
||||
this.disconnect()
|
||||
|
||||
this._connecting = true
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
await fetchEventSource(this.url, {
|
||||
headers: { saToken: token },
|
||||
signal: this.abortController.signal,
|
||||
openWhenHidden: true,
|
||||
|
||||
onopen: async (response) => {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new SseHttpError('SSE 鉴权失败', response)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new SseHttpError(`SSE 连接失败: ${response.status}`, response)
|
||||
}
|
||||
if (!contentType.includes('text/event-stream')) {
|
||||
throw new SseHttpError(`非法的响应类型: ${contentType || '空'}`, response)
|
||||
}
|
||||
|
||||
this._connecting = false
|
||||
this._connected = true
|
||||
this.retryCount = 0
|
||||
this.callbacks.onConnected?.()
|
||||
},
|
||||
|
||||
onmessage: (message) => {
|
||||
if (!message.event || !message.data) {
|
||||
return
|
||||
}
|
||||
this.callbacks.onMessage(message.event, message.data)
|
||||
},
|
||||
|
||||
onclose: () => {
|
||||
this._connecting = false
|
||||
this._connected = false
|
||||
},
|
||||
|
||||
onerror: (error) => {
|
||||
this._connecting = false
|
||||
this._connected = false
|
||||
|
||||
// 401 → 通知外部刷新 token
|
||||
if (isSseHttpError(error) && error.response.status === 401) {
|
||||
this.callbacks.onUnauthorized?.()
|
||||
throw error
|
||||
}
|
||||
|
||||
this.retryCount += 1
|
||||
if (this.retryCount >= this.maxRetries) {
|
||||
this.callbacks.onError?.('实时连接异常,请刷新页面')
|
||||
throw error
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
this.baseRetryDelayMs * Math.pow(2, this.retryCount - 1),
|
||||
this.maxRetryDelayMs
|
||||
)
|
||||
return delay
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
// fetchEventSource 仅在重连耗尽或 401 时 throw,状态已在 onerror 中处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 断开连接(主动关闭,不重连) */
|
||||
disconnect(): void {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort()
|
||||
this.abortController = null
|
||||
}
|
||||
this.retryCount = 0
|
||||
this._connected = false
|
||||
this._connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface SseClientCallbacks {
|
||||
/** 连接成功 */
|
||||
onConnected?: () => void
|
||||
/** 收到消息 (eventName, rawData) */
|
||||
onMessage: (event: string, data: string) => void
|
||||
/** 连接异常(重连耗尽或 token 缺失) */
|
||||
onError?: (message: string) => void
|
||||
/** 收到 401,需刷新 token */
|
||||
onUnauthorized?: () => void
|
||||
}
|
||||
|
||||
export interface SseClientOptions {
|
||||
maxRetries?: number
|
||||
baseRetryDelayMs?: number
|
||||
maxRetryDelayMs?: number
|
||||
}
|
||||
|
||||
// ==================== 内部工具 ====================
|
||||
|
||||
class SseHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly response: Response
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'SseHttpError'
|
||||
}
|
||||
}
|
||||
|
||||
function isSseHttpError(error: unknown): error is SseHttpError {
|
||||
return error instanceof SseHttpError
|
||||
}
|
||||
2
src/common/sse/index.ts
Normal file
2
src/common/sse/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './useSse.ts'
|
||||
export * from './sseBus.ts'
|
||||
30
src/common/sse/sseBus.ts
Normal file
30
src/common/sse/sseBus.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import mitt from 'mitt'
|
||||
import type { RoomItem } from '@/api/modules/game'
|
||||
|
||||
/**
|
||||
* SSE 事件定义
|
||||
*/
|
||||
export type SseEvents = {
|
||||
connected: undefined
|
||||
error: { message: string; retryCount: number }
|
||||
room_created: RoomItem
|
||||
room_updated: RoomItem
|
||||
room_removed: { roomId: string }
|
||||
kicked: { roomId: string }
|
||||
game_started: { roomId: string }
|
||||
balance_chg: { balance: number; delta: number }
|
||||
/** 自定义事件类型,用于测试或任意消息推送,数据格式由调用方决定 */
|
||||
custom: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 SSE 事件总线(发布-订阅模式)
|
||||
*
|
||||
* 连接层只负责解析 SSE 消息并 emit 到总线,业务模块自行订阅处理。
|
||||
* 示例:
|
||||
* ```ts
|
||||
* import { sseBus } from '@/stores/sseBus'
|
||||
* sseBus.on('room_updated', (room) => { ... })
|
||||
* ```
|
||||
*/
|
||||
export const sseBus = mitt<SseEvents>()
|
||||
77
src/common/sse/useSse.ts
Normal file
77
src/common/sse/useSse.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { ref } from 'vue'
|
||||
import { SseClient } from './SseClient.ts'
|
||||
import type { SseEvents } from './sseBus.ts'
|
||||
import { sseBus } from './sseBus.ts'
|
||||
import { useAccountStore } from '@/stores'
|
||||
|
||||
/** SSE 连接 URL */
|
||||
const SSE_URL = '/api/sse/subscribe'
|
||||
|
||||
// ==================== 模块级单例 ====================
|
||||
|
||||
const isConnected = ref(false)
|
||||
|
||||
const client = new SseClient(SSE_URL, () => useAccountStore().accessToken, {
|
||||
onConnected() {
|
||||
isConnected.value = true
|
||||
sseBus.emit('connected', undefined)
|
||||
},
|
||||
onMessage(event: string, data: string) {
|
||||
try {
|
||||
const payload = JSON.parse(data)
|
||||
sseBus.emit(event as keyof SseEvents, payload)
|
||||
} catch {
|
||||
console.warn('[SSE] 事件数据解析失败:', event, data)
|
||||
}
|
||||
},
|
||||
onError(message: string) {
|
||||
sseBus.emit('error', { message, retryCount: 0 })
|
||||
},
|
||||
onUnauthorized() {
|
||||
void refreshTokenAndReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
// ==================== Token 刷新 ====================
|
||||
|
||||
/**
|
||||
* SSE 收到 401 → 调用 store 的 refreshAccessToken() 刷新 token 后重连
|
||||
* <p>
|
||||
* 无需维护独立并发锁:accountStore.refreshAccessToken() 内部已有 Promise 级锁,
|
||||
* 若 HTTP 刷新拦截器已在刷新中,此调用会复用同一个 Promise,确保只发一次请求。
|
||||
*/
|
||||
async function refreshTokenAndReconnect(): Promise<void> {
|
||||
const accountStore = useAccountStore()
|
||||
if (!accountStore.refreshToken) {
|
||||
sseBus.emit('error', { message: '登录已过期,请重新登录', retryCount: 0 })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const newToken = await accountStore.refreshAccessToken()
|
||||
if (newToken) {
|
||||
await client.connect()
|
||||
}
|
||||
} catch {
|
||||
sseBus.emit('error', { message: '实时连接异常,请刷新页面', retryCount: 0 })
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Composable ====================
|
||||
|
||||
/**
|
||||
* 全局 SSE 连接管理
|
||||
*
|
||||
* SseClient 只管连接生命周期,业务消息通过 sseBus 分发。
|
||||
* 组件只需 `useSse().connect()` / `useSse().disconnect()`。
|
||||
*/
|
||||
export function useSse() {
|
||||
return {
|
||||
isConnected,
|
||||
connect: () => client.connect(),
|
||||
disconnect: () => {
|
||||
client.disconnect()
|
||||
isConnected.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,6 @@
|
||||
<div class="create-room-dialog__hint">设置密码后,其他玩家需输入密码才能加入</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 是否允许观战 -->
|
||||
<el-form-item label="观战">
|
||||
<el-switch v-model="form.allowSpectate" />
|
||||
<div class="create-room-dialog__hint">开启后,其他玩家可旁观对局</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
@@ -87,7 +82,6 @@ interface CreateRoomForm {
|
||||
mode: string
|
||||
stakes: number | undefined
|
||||
password: string
|
||||
allowSpectate: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -123,8 +117,7 @@ const form = reactive<CreateRoomForm>({
|
||||
name: '',
|
||||
mode: '',
|
||||
stakes: undefined,
|
||||
password: '',
|
||||
allowSpectate: true
|
||||
password: ''
|
||||
})
|
||||
|
||||
/** 初始化默认值:取当前游戏配置的第一个选项 */
|
||||
@@ -135,7 +128,6 @@ function initDefaults() {
|
||||
}
|
||||
form.name = ''
|
||||
form.password = ''
|
||||
form.allowSpectate = true
|
||||
}
|
||||
|
||||
/** 弹窗打开时设置默认值 */
|
||||
@@ -171,8 +163,7 @@ async function handleSubmit() {
|
||||
password: form.password || undefined,
|
||||
mode: form.mode,
|
||||
stakes: form.stakes!,
|
||||
maxPlayers: gameConfig.value?.maxPlayers ?? 0,
|
||||
allowSpectate: form.allowSpectate
|
||||
maxPlayers: gameConfig.value?.maxPlayers ?? 0
|
||||
})
|
||||
visible.value = false
|
||||
emit('created')
|
||||
|
||||
@@ -52,7 +52,7 @@ import type { RoomItem } from '@/api/modules/game'
|
||||
import { useBaseLayoutStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { getRoomList, joinRoom } from '@/api/modules/game'
|
||||
import { sseBus } from '@/stores/sseBus'
|
||||
import { sseBus } from '@/common/sse'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
/** 状态筛选选项 */
|
||||
@@ -89,9 +89,17 @@ watch(activeGameId, () => {
|
||||
fetchRoomList()
|
||||
})
|
||||
|
||||
/** 首次加载 */
|
||||
/**
|
||||
* 首次加载:HTTP 全量拉取(SSE 可能尚未连接,HTTP 是最可靠的基线)
|
||||
*
|
||||
* 后续增量由 SSE 事件(room_created/room_updated/room_removed)驱动,
|
||||
* SSE 重连时通过 connected 事件触发全量覆盖作为数据补偿。
|
||||
*/
|
||||
fetchRoomList()
|
||||
|
||||
/** 是否已完成首次加载(用于区分 SSE 首次 connected 和重连) */
|
||||
let initialLoadDone = false
|
||||
|
||||
// ===== SSE 实时事件订阅处理函数 =====
|
||||
/** 新房间创建 → 添加到列表头部 */
|
||||
function onRoomCreated(room: RoomItem) {
|
||||
@@ -108,17 +116,33 @@ function onRoomUpdated(room: RoomItem) {
|
||||
function onRoomRemoved({ roomId }: { roomId: string }) {
|
||||
rooms.value = rooms.value.filter((r) => r.id !== roomId)
|
||||
}
|
||||
/**
|
||||
* SSE 重连 → 全量拉取房间列表覆盖增量丢失
|
||||
*
|
||||
* 首次 connected 跳过(已由上面的 fetchRoomList() 处理),
|
||||
* 仅重连时触发全量覆盖,避免初始重复请求。
|
||||
* 重连期间的增量事件可能丢失,HTTP 全量拉取作为补偿。
|
||||
*/
|
||||
function onSseConnected() {
|
||||
if (!initialLoadDone) {
|
||||
initialLoadDone = true
|
||||
return
|
||||
}
|
||||
fetchRoomList()
|
||||
}
|
||||
|
||||
// ===== SSE 实时事件订阅 =====
|
||||
onMounted(() => {
|
||||
sseBus.on('room_created', onRoomCreated)
|
||||
sseBus.on('room_updated', onRoomUpdated)
|
||||
sseBus.on('room_removed', onRoomRemoved)
|
||||
sseBus.on('connected', onSseConnected)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
sseBus.off('room_created', onRoomCreated)
|
||||
sseBus.off('room_updated', onRoomUpdated)
|
||||
sseBus.off('room_removed', onRoomRemoved)
|
||||
sseBus.off('connected', onSseConnected)
|
||||
})
|
||||
|
||||
/** 筛选后的房间列表 */
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { RoomItem, RoomPlayer, RoomStatus } from '@/api/modules/game'
|
||||
import type { RoomItem } from '@/api/modules/game'
|
||||
import { getAvatarUrl } from '@/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -109,7 +109,7 @@ const chairPositions = computed(() => {
|
||||
|
||||
positions.push({
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
top: `${top}px`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,9 +164,7 @@ function isHost(idx: number): boolean {
|
||||
function chairTooltip(idx: number): string {
|
||||
const player = props.room.players[idx]
|
||||
if (!player) return ''
|
||||
return player.nickname === props.room.creatorName
|
||||
? `${player.nickname}(房主)`
|
||||
: player.nickname
|
||||
return player.nickname === props.room.creatorName ? `${player.nickname}(房主)` : player.nickname
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -350,12 +348,7 @@ function chairTooltip(idx: number): string {
|
||||
height: 76px;
|
||||
border-radius: 50%;
|
||||
/* 木质桌面 */
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
#e8d5b0 0%,
|
||||
#d4a574 40%,
|
||||
#c49060 100%
|
||||
);
|
||||
background: linear-gradient(145deg, #e8d5b0 0%, #d4a574 40%, #c49060 100%);
|
||||
box-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.15),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.4);
|
||||
|
||||
@@ -135,7 +135,14 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Setting, EditPen, ArrowRight, Check, DocumentCopy, SwitchButton } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Setting,
|
||||
EditPen,
|
||||
ArrowRight,
|
||||
Check,
|
||||
DocumentCopy,
|
||||
SwitchButton
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAccountStore } from '@/stores'
|
||||
import { UserStatus, type UserStatusType } from '@/api/modules/user'
|
||||
import { getAvatarUrl } from '@/utils'
|
||||
|
||||
@@ -95,26 +95,44 @@ export const useAccountStore = defineStore(
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Access Token
|
||||
* 刷新 Access Token(带并发锁)
|
||||
*
|
||||
* 由刷新拦截器的 doRefreshToken 调用。
|
||||
* 返回新的 Access Token 字符串,方便拦截器直接使用。
|
||||
* 由刷新拦截器的 doRefreshToken 和 SSE 的 onUnauthorized 调用。
|
||||
* 返回新的 Access Token 字符串,方便调用方直接使用。
|
||||
*
|
||||
* 并发控制:若已有刷新正在进行中,返回同一个 Promise,
|
||||
* 确保无论并发触发多少次,实际只发出一次 /account/refresh 请求。
|
||||
*
|
||||
* @returns 新的 Access Token
|
||||
*/
|
||||
/** 正在进行的刷新 Promise(null 表示无刷新进行中) */
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
|
||||
const refreshAccessToken = async (): Promise<string> => {
|
||||
// 已有刷新进行中 → 复用同一个 Promise,避免并发重复请求
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
if (!refreshToken.value) {
|
||||
throw new Error('无 Refresh Token,无法刷新')
|
||||
}
|
||||
|
||||
const res = await refreshApi({ refreshToken: refreshToken.value })
|
||||
accessToken.value = res.data.accessToken
|
||||
if (res.data.refreshToken) {
|
||||
refreshToken.value = res.data.refreshToken
|
||||
}
|
||||
isTokenValid.value = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const res = await refreshApi({ refreshToken: refreshToken.value })
|
||||
accessToken.value = res.data.accessToken
|
||||
if (res.data.refreshToken) {
|
||||
refreshToken.value = res.data.refreshToken
|
||||
}
|
||||
isTokenValid.value = true
|
||||
return accessToken.value
|
||||
} finally {
|
||||
refreshPromise = null
|
||||
}
|
||||
})()
|
||||
|
||||
return accessToken.value
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,7 +15,6 @@ export const useRoomStore = defineStore('room', () => {
|
||||
const status = ref<RoomStatus>('waiting')
|
||||
const creatorId = ref<string>('')
|
||||
const creatorName = ref<string>('')
|
||||
const allowSpectate = ref<boolean>(true)
|
||||
|
||||
// ===== 玩家信息 =====
|
||||
const players = ref<RoomPlayerDetail[]>([])
|
||||
@@ -43,18 +42,15 @@ export const useRoomStore = defineStore('room', () => {
|
||||
|
||||
// ===== 当前用户身份 =====
|
||||
const currentUserId = ref<string>('')
|
||||
/** 当前用户在房间中的身份:player(玩家)| spectator(观战者) */
|
||||
const myRole = ref<'player' | 'spectator'>('spectator')
|
||||
/** 当前用户是否为玩家 */
|
||||
const isPlayer = computed(() => myRole.value === 'player')
|
||||
/** 当前用户是否为房间中的玩家 */
|
||||
const isPlayer = computed(() =>
|
||||
players.value.some((p) => p.userId === currentUserId.value)
|
||||
)
|
||||
|
||||
// ===== 求和状态 =====
|
||||
const drawRequested = ref<boolean>(false)
|
||||
const drawRequestFrom = ref<string | null>(null)
|
||||
|
||||
// ===== 观战者 =====
|
||||
const spectatorCount = ref<number>(0)
|
||||
|
||||
/**
|
||||
* 从房间详情 HTTP 响应初始化 Store
|
||||
* @param detail - 房间详情响应数据
|
||||
@@ -72,16 +68,11 @@ export const useRoomStore = defineStore('room', () => {
|
||||
status.value = detail.status
|
||||
creatorId.value = detail.creatorId
|
||||
creatorName.value = detail.creatorName
|
||||
allowSpectate.value = detail.allowSpectate
|
||||
players.value = detail.players
|
||||
currentUserId.value = userId
|
||||
boardState.value = detail.boardState ?? []
|
||||
currentTurn.value = detail.currentTurn ?? null
|
||||
moves.value = detail.moves ?? []
|
||||
|
||||
// 判定当前用户是玩家还是观战者
|
||||
const isPlayerInRoom = detail.players.some((p) => p.userId === userId)
|
||||
myRole.value = isPlayerInRoom ? 'player' : 'spectator'
|
||||
}
|
||||
|
||||
/** 更新棋盘状态(WS move 事件) */
|
||||
@@ -145,11 +136,6 @@ export const useRoomStore = defineStore('room', () => {
|
||||
resultType.value = type
|
||||
}
|
||||
|
||||
/** 更新观战人数 */
|
||||
function setSpectatorCount(count: number) {
|
||||
spectatorCount.value = count
|
||||
}
|
||||
|
||||
/** 收到求和请求 */
|
||||
function receiveDrawRequest(fromUserId: string) {
|
||||
drawRequested.value = true
|
||||
@@ -178,14 +164,14 @@ export const useRoomStore = defineStore('room', () => {
|
||||
|
||||
return {
|
||||
roomId, roomName, gameId, gameIcon, gameName,
|
||||
maxPlayers, mode, stakes, status, creatorId, creatorName, allowSpectate,
|
||||
players, isOwner, currentUserId, myRole, isPlayer,
|
||||
maxPlayers, mode, stakes, status, creatorId, creatorName,
|
||||
players, isOwner, currentUserId, isPlayer,
|
||||
boardState, currentTurn, moves, winner, resultType,
|
||||
isPlaying, isFinished,
|
||||
messages,
|
||||
drawRequested, drawRequestFrom, spectatorCount,
|
||||
drawRequested, drawRequestFrom,
|
||||
initFromDetail, applyMove, setCurrentTurn, addMessage,
|
||||
updateReady, addPlayer, removePlayer, setGameOver, setSpectatorCount,
|
||||
updateReady, addPlayer, removePlayer, setGameOver,
|
||||
receiveDrawRequest, clearDrawRequest, reset
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
// webgame-frontend/src/stores/sseBus.ts
|
||||
import mitt from 'mitt'
|
||||
import type { RoomItem } from '@/api/modules/game'
|
||||
|
||||
/**
|
||||
* SSE 推送事件类型映射
|
||||
*
|
||||
* 每个事件类型对应一个数据类型,类型安全。
|
||||
* connected / error 由 useSse 连接管理发出,其余由服务端 SSE 事件映射而来。
|
||||
*/
|
||||
type SseEvents = {
|
||||
/** SSE 连接成功 */
|
||||
connected: void
|
||||
/** SSE 连接异常 */
|
||||
error: { message: string; retryCount: number }
|
||||
/** 新房间创建 */
|
||||
room_created: RoomItem
|
||||
/** 房间状态更新 */
|
||||
room_updated: RoomItem
|
||||
/** 房间移除 */
|
||||
room_removed: { roomId: string }
|
||||
/** 被踢出房间 */
|
||||
kicked: { roomId: string }
|
||||
/** 所在房间游戏开始 */
|
||||
game_started: { roomId: string }
|
||||
/** 余额变动 */
|
||||
balance_chg: { balance: number; delta: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局 SSE 事件总线(发布-订阅模式)
|
||||
*
|
||||
* 示例:
|
||||
* ```ts
|
||||
* import { sseBus } from '@/stores/sseBus'
|
||||
* const off = sseBus.on('room_updated', (room) => { ... })
|
||||
* onUnmounted(off)
|
||||
* ```
|
||||
*/
|
||||
export const sseBus = mitt<SseEvents>()
|
||||
@@ -7,6 +7,8 @@ type AppEventsMap = {
|
||||
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
|
||||
'app:request:error': {
|
||||
type: 'business' | 'http' | 'network'
|
||||
/** HTTP 状态码(http/business 类型时有效) */
|
||||
httpStatus?: number
|
||||
code?: number
|
||||
message: string
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@
|
||||
<!-- 右侧面板 -->
|
||||
<RoomRightPanel />
|
||||
</div>
|
||||
|
||||
<!-- 底部观战栏 -->
|
||||
<SpectatorBar v-if="roomStore.allowSpectate" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +33,6 @@ import RoomTopBar from './components/RoomTopBar.vue'
|
||||
import RoomSidePanel from './components/RoomSidePanel.vue'
|
||||
import GameCanvas from './components/GameCanvas.vue'
|
||||
import RoomRightPanel from './components/RoomRightPanel.vue'
|
||||
import SpectatorBar from './components/SpectatorBar.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -154,14 +150,10 @@ function handleWsMessage(msg: WsMessage) {
|
||||
roomStore.updateReady(data.userId, data.ready)
|
||||
break
|
||||
}
|
||||
case 'spectator_join': {
|
||||
const data = msg.data as { count: number }
|
||||
roomStore.setSpectatorCount(data.count)
|
||||
case 'player_disconnected': {
|
||||
break
|
||||
}
|
||||
case 'spectator_leave': {
|
||||
const data = msg.data as { count: number }
|
||||
roomStore.setSpectatorCount(data.count)
|
||||
case 'player_reconnected': {
|
||||
break
|
||||
}
|
||||
case 'draw_requested': {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<template>
|
||||
<div class="spectator-bar">
|
||||
<span class="spectator-bar__icon">👀</span>
|
||||
<span class="spectator-bar__text">
|
||||
观战中({{ spectatorCount }}人)
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoomStore } from '@/stores'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const store = useRoomStore()
|
||||
const { spectatorCount } = storeToRefs(store)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.spectator-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
background-color: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-light);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.spectator-bar__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user