Compare commits

...

9 Commits

Author SHA1 Message Date
94dfa907f9 feat: 重构游戏房间架构 2026-06-30 10:19:41 +08:00
e1faf45683 feat: 刷新 Access Token(带并发锁) 2026-06-30 09:03:43 +08:00
b85c3cbd60 feat: 刷新 Access Token(带并发锁) 2026-06-30 09:03:26 +08:00
dd97f9cb70 feat: 刷新 Access Token(带并发锁) 2026-06-24 12:27:55 +08:00
7b3d31a57c feat: HTTP状态码 + 业务码 2026-06-24 11:14:27 +08:00
3f6d3f2761 feat: 重新实现SseClient 2026-06-24 09:33:38 +08:00
112ab3312d feat: 重新实现SseClient 2026-06-24 09:22:14 +08:00
9e36c91e13 feat: 重新实现SseClient 2026-06-24 09:14:51 +08:00
45f4d69446 fix: 暂存 2026-06-24 08:30:33 +08:00
22 changed files with 412 additions and 394 deletions

View File

@@ -9,7 +9,7 @@ import { watch } from 'vue'
import { RouterView } from 'vue-router' import { RouterView } from 'vue-router'
import { ElConfigProvider } from 'element-plus' import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn' 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' import { useAccountStore } from '@/stores/modules/account'
const accountStore = useAccountStore() const accountStore = useAccountStore()

View File

@@ -53,11 +53,15 @@ export interface LoginResponse {
* 使用用户名和密码进行登录认证,成功返回双 Token。 * 使用用户名和密码进行登录认证,成功返回双 Token。
* 该接口无需登录即可调用。 * 该接口无需登录即可调用。
* *
* 注意:设置 __skipRefresh=true避免密码错误返回 HTTP 401 时触发无意义的刷新流程。
*
* @param req - 登录请求参数 * @param req - 登录请求参数
* @returns 包含 accessToken 和 refreshToken 的响应 * @returns 包含 accessToken 和 refreshToken 的响应
*/ */
export function login(req: LoginRequest) { 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。 * 注册成功后自动登录并返回双 Token。
* 该接口无需登录即可调用。 * 该接口无需登录即可调用。
* *
* 注意:设置 __skipRefresh=true避免意外 401 时触发无意义的刷新流程。
*
* @param req - 注册请求参数 * @param req - 注册请求参数
* @returns 包含 accessToken 和 refreshToken 的响应 * @returns 包含 accessToken 和 refreshToken 的响应
*/ */
export function register(req: RegisterRequest) { export function register(req: RegisterRequest) {
return requestClient.post<LoginResponse>('/account/register', req) return requestClient.post<LoginResponse>('/account/register', req, {
__skipRefresh: true
})
} }
/** /**

View File

@@ -12,15 +12,12 @@ import type {
/** /**
* 获取指定游戏的房间列表 * 获取指定游戏的房间列表
* *
* 返回某个游戏下所有房间(桌台)的信息,支持按状态筛选。
* 该接口无需登录即可调用。
*
* @param params - 查询参数,包含 gameId 和可选的 status 筛选 * @param params - 查询参数,包含 gameId 和可选的 status 筛选
* @returns 房间列表 * @returns 房间列表
*/ */
export function getRoomList(params: GetRoomListRequest) { export function getRoomList(params: GetRoomListRequest) {
return requestClient.get<GetRoomListResponse>( return requestClient.get<GetRoomListResponse>(
'/game/room/list', '/room/list',
params as unknown as Record<string, unknown> params as unknown as Record<string, unknown>
) )
} }
@@ -28,39 +25,29 @@ export function getRoomList(params: GetRoomListRequest) {
/** /**
* 创建房间 * 创建房间
* *
* 根据当前游戏和选定的参数创建新房间。
* 房间名称留空时由后端自动生成"桌 XX"编号。
* 密码留空时房间为公开房间。
*
* @param data - 创建房间参数 * @param data - 创建房间参数
* @returns 新建的房间信息 * @returns 新建的房间信息
*/ */
export function createRoom(data: CreateRoomRequest) { 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 * @param data - 加入房间参数roomId 和可选的 password
* @returns 更新后的房间信息 * @returns 更新后的房间信息
*/ */
export function joinRoom(data: JoinRoomRequest) { export function joinRoom(data: JoinRoomRequest) {
return requestClient.post<RoomItem>('/game/room/join', data) return requestClient.post<RoomItem>('/room/join', data)
} }
/** /**
* 获取房间详情 * 获取房间详情
* *
* 用于进入房间页时获取初始快照(棋盘状态、玩家信息等)。
* 无需登录即可调用(观战者也需访问)。
*
* @param roomId 房间 ID * @param roomId 房间 ID
* @returns 房间详情 * @returns 房间详情
*/ */
export function getRoomDetail(roomId: string) { export function getRoomDetail(roomId: string) {
return requestClient.get<RoomDetailResponse>(`/game/room/${roomId}`) return requestClient.get<RoomDetailResponse>(`/room/${roomId}`)
} }

View File

@@ -65,8 +65,6 @@ export interface RoomItem {
mode: string mode: string
/** 房主昵称 */ /** 房主昵称 */
creatorName: string creatorName: string
/** 是否允许观战 */
allowSpectate: boolean
} }
/** 获取游戏列表 — 无请求参数 */ /** 获取游戏列表 — 无请求参数 */
@@ -101,8 +99,6 @@ export interface CreateRoomRequest {
stakes: number stakes: number
/** 最大玩家数 */ /** 最大玩家数 */
maxPlayers: number maxPlayers: number
/** 是否允许观战 */
allowSpectate?: boolean
} }
/** 创建房间响应 */ /** 创建房间响应 */
@@ -124,7 +120,6 @@ export interface RoomDetailResponse {
status: RoomStatus status: RoomStatus
creatorId: string creatorId: string
creatorName: string creatorName: string
allowSpectate: boolean
players: RoomPlayerDetail[] players: RoomPlayerDetail[]
/** 棋盘状态(对局中才有) */ /** 棋盘状态(对局中才有) */
boardState?: number[][] boardState?: number[][]
@@ -185,8 +180,8 @@ export type WsOutboundType =
| 'game_over' | 'game_over'
| 'player_join' | 'player_join'
| 'player_leave' | 'player_leave'
| 'player_disconnected'
| 'player_reconnected'
| 'ready_change' | 'ready_change'
| 'spectator_join'
| 'spectator_leave'
| 'move_history' | 'move_history'
| 'error' | 'error'

View File

@@ -34,32 +34,17 @@ export function createRefreshTokenInterceptor(config: RefreshInterceptorConfig)
config 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>) { async function onFulfilled(response: AxiosResponse<ApiResponse>) {
const data = response.data return response
// 前一个拦截器在 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)
} }
/** /**
* 处理 HTTP 401(以及业务 401 转换后的类 AxiosError * 处理 HTTP 401 错误,启动队列式 Token 刷新机制
*/ */
async function onRejected(error: AxiosError) { async function onRejected(error: AxiosError) {
const { config: reqConfig, response } = error const { config: reqConfig, response } = error

View File

@@ -58,6 +58,7 @@ class RequestClient {
this.setupInterceptors() this.setupInterceptors()
} }
// @ts-ignore
/** 注册请求和响应拦截器 */ /** 注册请求和响应拦截器 */
private setupInterceptors(): void { private setupInterceptors(): void {
// === 请求拦截器 === // === 请求拦截器 ===
@@ -86,8 +87,8 @@ class RequestClient {
) )
// === 通用响应拦截器 === // === 通用响应拦截器 ===
// 职责:解包 ApiResponse + 发布非 401 的错误事件 // 职责:解包 ApiResponse + 按 HTTP 状态码分类处理错误
// 401 全部透传,由 refreshInterceptor 统一处理。 // HTTP 401 全部透传,由 refreshInterceptor 统一处理。
this.instance.interceptors.response.use( this.instance.interceptors.response.use(
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse // @ts-expect-error 拦截器解包 axiosResponse → ApiResponse
(response) => { (response) => {
@@ -97,30 +98,41 @@ class RequestClient {
return data return data
} }
// 401 透传给刷新拦截器,此处不处理 // code !== 0 但 HTTP 2xx — 未改造端点或特殊场景,按业务错误处理
if (data.code === 401) {
return response
}
eventBus.emit(AppEvents.REQUEST_ERROR, { eventBus.emit(AppEvents.REQUEST_ERROR, {
type: 'business', type: 'business',
code: data.code, code: data.code,
httpStatus: response.status,
message: data.message || '请求失败' message: data.message || '请求失败'
}) })
return Promise.reject(data) return Promise.reject(data)
}, },
(error) => { (error) => {
const status = error.response?.status
// HTTP 401 透传给刷新拦截器 // HTTP 401 透传给刷新拦截器
if (error.response?.status === 401) { if (status === 401) {
throw error 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, { eventBus.emit(AppEvents.REQUEST_ERROR, {
type: error.response ? 'http' : 'network', type: error.response ? 'http' : 'network',
code: error.response?.status, code: status,
message: error.response httpStatus: status,
? `请求错误 (${error.response.status})` message: error.response ? `请求错误 (${status})` : '网络异常,请检查网络连接'
: '网络异常,请检查网络连接'
}) })
return Promise.reject(error) return Promise.reject(error)
} }
@@ -150,7 +162,7 @@ class RequestClient {
}, },
refreshTokenQueue: this.refreshTokenQueue refreshTokenQueue: this.refreshTokenQueue
}) })
// @ts-expect-error 拦截器解包 axiosResponse → ApiResponse // 拦截器解包 axiosResponse → ApiResponse
this.instance.interceptors.response.use(onFulfilled, onRejected) this.instance.interceptors.response.use(onFulfilled, onRejected)
} }

View File

@@ -28,6 +28,8 @@ export interface RequestErrorEvent {
type: 'business' | 'http' | 'network' type: 'business' | 'http' | 'network'
/** 业务状态码business 类型时有效) */ /** 业务状态码business 类型时有效) */
code?: number code?: number
/** HTTP 状态码http/business 类型时有效) */
httpStatus?: number
/** 错误消息 */ /** 错误消息 */
message: string message: string
} }

View File

@@ -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-forgetonerror 本身保持同步)
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
View 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
View File

@@ -0,0 +1,2 @@
export * from './useSse.ts'
export * from './sseBus.ts'

30
src/common/sse/sseBus.ts Normal file
View 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
View 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
}
}
}

View File

@@ -58,11 +58,6 @@
<div class="create-room-dialog__hint">设置密码后其他玩家需输入密码才能加入</div> <div class="create-room-dialog__hint">设置密码后其他玩家需输入密码才能加入</div>
</el-form-item> </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> </el-form>
<template #footer> <template #footer>
@@ -87,7 +82,6 @@ interface CreateRoomForm {
mode: string mode: string
stakes: number | undefined stakes: number | undefined
password: string password: string
allowSpectate: boolean
} }
const props = defineProps<{ const props = defineProps<{
@@ -123,8 +117,7 @@ const form = reactive<CreateRoomForm>({
name: '', name: '',
mode: '', mode: '',
stakes: undefined, stakes: undefined,
password: '', password: ''
allowSpectate: true
}) })
/** 初始化默认值:取当前游戏配置的第一个选项 */ /** 初始化默认值:取当前游戏配置的第一个选项 */
@@ -135,7 +128,6 @@ function initDefaults() {
} }
form.name = '' form.name = ''
form.password = '' form.password = ''
form.allowSpectate = true
} }
/** 弹窗打开时设置默认值 */ /** 弹窗打开时设置默认值 */
@@ -171,8 +163,7 @@ async function handleSubmit() {
password: form.password || undefined, password: form.password || undefined,
mode: form.mode, mode: form.mode,
stakes: form.stakes!, stakes: form.stakes!,
maxPlayers: gameConfig.value?.maxPlayers ?? 0, maxPlayers: gameConfig.value?.maxPlayers ?? 0
allowSpectate: form.allowSpectate
}) })
visible.value = false visible.value = false
emit('created') emit('created')

View File

@@ -52,7 +52,7 @@ import type { RoomItem } from '@/api/modules/game'
import { useBaseLayoutStore } from '@/stores' import { useBaseLayoutStore } from '@/stores'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { getRoomList, joinRoom } from '@/api/modules/game' import { getRoomList, joinRoom } from '@/api/modules/game'
import { sseBus } from '@/stores/sseBus' import { sseBus } from '@/common/sse'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
/** 状态筛选选项 */ /** 状态筛选选项 */
@@ -89,9 +89,17 @@ watch(activeGameId, () => {
fetchRoomList() fetchRoomList()
}) })
/** 首次加载 */ /**
* 首次加载HTTP 全量拉取SSE 可能尚未连接HTTP 是最可靠的基线)
*
* 后续增量由 SSE 事件room_created/room_updated/room_removed驱动
* SSE 重连时通过 connected 事件触发全量覆盖作为数据补偿。
*/
fetchRoomList() fetchRoomList()
/** 是否已完成首次加载(用于区分 SSE 首次 connected 和重连) */
let initialLoadDone = false
// ===== SSE 实时事件订阅处理函数 ===== // ===== SSE 实时事件订阅处理函数 =====
/** 新房间创建 → 添加到列表头部 */ /** 新房间创建 → 添加到列表头部 */
function onRoomCreated(room: RoomItem) { function onRoomCreated(room: RoomItem) {
@@ -108,17 +116,33 @@ function onRoomUpdated(room: RoomItem) {
function onRoomRemoved({ roomId }: { roomId: string }) { function onRoomRemoved({ roomId }: { roomId: string }) {
rooms.value = rooms.value.filter((r) => r.id !== roomId) rooms.value = rooms.value.filter((r) => r.id !== roomId)
} }
/**
* SSE 重连 → 全量拉取房间列表覆盖增量丢失
*
* 首次 connected 跳过(已由上面的 fetchRoomList() 处理),
* 仅重连时触发全量覆盖,避免初始重复请求。
* 重连期间的增量事件可能丢失HTTP 全量拉取作为补偿。
*/
function onSseConnected() {
if (!initialLoadDone) {
initialLoadDone = true
return
}
fetchRoomList()
}
// ===== SSE 实时事件订阅 ===== // ===== SSE 实时事件订阅 =====
onMounted(() => { onMounted(() => {
sseBus.on('room_created', onRoomCreated) sseBus.on('room_created', onRoomCreated)
sseBus.on('room_updated', onRoomUpdated) sseBus.on('room_updated', onRoomUpdated)
sseBus.on('room_removed', onRoomRemoved) sseBus.on('room_removed', onRoomRemoved)
sseBus.on('connected', onSseConnected)
}) })
onUnmounted(() => { onUnmounted(() => {
sseBus.off('room_created', onRoomCreated) sseBus.off('room_created', onRoomCreated)
sseBus.off('room_updated', onRoomUpdated) sseBus.off('room_updated', onRoomUpdated)
sseBus.off('room_removed', onRoomRemoved) sseBus.off('room_removed', onRoomRemoved)
sseBus.off('connected', onSseConnected)
}) })
/** 筛选后的房间列表 */ /** 筛选后的房间列表 */

View File

@@ -72,7 +72,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { RoomItem, RoomPlayer, RoomStatus } from '@/api/modules/game' import type { RoomItem } from '@/api/modules/game'
import { getAvatarUrl } from '@/utils' import { getAvatarUrl } from '@/utils'
const props = defineProps<{ const props = defineProps<{
@@ -109,7 +109,7 @@ const chairPositions = computed(() => {
positions.push({ positions.push({
left: `${left}px`, left: `${left}px`,
top: `${top}px`, top: `${top}px`
}) })
} }
@@ -164,9 +164,7 @@ function isHost(idx: number): boolean {
function chairTooltip(idx: number): string { function chairTooltip(idx: number): string {
const player = props.room.players[idx] const player = props.room.players[idx]
if (!player) return '' if (!player) return ''
return player.nickname === props.room.creatorName return player.nickname === props.room.creatorName ? `${player.nickname}(房主)` : player.nickname
? `${player.nickname}(房主)`
: player.nickname
} }
</script> </script>
@@ -350,12 +348,7 @@ function chairTooltip(idx: number): string {
height: 76px; height: 76px;
border-radius: 50%; border-radius: 50%;
/* 木质桌面 */ /* 木质桌面 */
background: linear-gradient( background: linear-gradient(145deg, #e8d5b0 0%, #d4a574 40%, #c49060 100%);
145deg,
#e8d5b0 0%,
#d4a574 40%,
#c49060 100%
);
box-shadow: box-shadow:
0 3px 12px rgba(0, 0, 0, 0.15), 0 3px 12px rgba(0, 0, 0, 0.15),
inset 0 1px 2px rgba(255, 255, 255, 0.4); inset 0 1px 2px rgba(255, 255, 255, 0.4);

View File

@@ -135,7 +135,14 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus' 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 { useAccountStore } from '@/stores'
import { UserStatus, type UserStatusType } from '@/api/modules/user' import { UserStatus, type UserStatusType } from '@/api/modules/user'
import { getAvatarUrl } from '@/utils' import { getAvatarUrl } from '@/utils'

View File

@@ -95,26 +95,44 @@ export const useAccountStore = defineStore(
} }
/** /**
* 刷新 Access Token * 刷新 Access Token(带并发锁)
* *
* 由刷新拦截器的 doRefreshToken 调用。 * 由刷新拦截器的 doRefreshToken 和 SSE 的 onUnauthorized 调用。
* 返回新的 Access Token 字符串,方便拦截器直接使用。 * 返回新的 Access Token 字符串,方便调用方直接使用。
*
* 并发控制:若已有刷新正在进行中,返回同一个 Promise
* 确保无论并发触发多少次,实际只发出一次 /account/refresh 请求。
* *
* @returns 新的 Access Token * @returns 新的 Access Token
*/ */
/** 正在进行的刷新 Promisenull 表示无刷新进行中) */
let refreshPromise: Promise<string> | null = null
const refreshAccessToken = async (): Promise<string> => { const refreshAccessToken = async (): Promise<string> => {
// 已有刷新进行中 → 复用同一个 Promise避免并发重复请求
if (refreshPromise) {
return refreshPromise
}
if (!refreshToken.value) { if (!refreshToken.value) {
throw new Error('无 Refresh Token无法刷新') throw new Error('无 Refresh Token无法刷新')
} }
const res = await refreshApi({ refreshToken: refreshToken.value }) refreshPromise = (async () => {
accessToken.value = res.data.accessToken try {
if (res.data.refreshToken) { const res = await refreshApi({ refreshToken: refreshToken.value })
refreshToken.value = res.data.refreshToken accessToken.value = res.data.accessToken
} if (res.data.refreshToken) {
isTokenValid.value = true refreshToken.value = res.data.refreshToken
}
isTokenValid.value = true
return accessToken.value
} finally {
refreshPromise = null
}
})()
return accessToken.value return refreshPromise
} }
/** /**

View File

@@ -15,7 +15,6 @@ export const useRoomStore = defineStore('room', () => {
const status = ref<RoomStatus>('waiting') const status = ref<RoomStatus>('waiting')
const creatorId = ref<string>('') const creatorId = ref<string>('')
const creatorName = ref<string>('') const creatorName = ref<string>('')
const allowSpectate = ref<boolean>(true)
// ===== 玩家信息 ===== // ===== 玩家信息 =====
const players = ref<RoomPlayerDetail[]>([]) const players = ref<RoomPlayerDetail[]>([])
@@ -43,18 +42,15 @@ export const useRoomStore = defineStore('room', () => {
// ===== 当前用户身份 ===== // ===== 当前用户身份 =====
const currentUserId = ref<string>('') const currentUserId = ref<string>('')
/** 当前用户房间中的身份player玩家| spectator观战者 */ /** 当前用户是否为房间中的玩家 */
const myRole = ref<'player' | 'spectator'>('spectator') const isPlayer = computed(() =>
/** 当前用户是否为玩家 */ players.value.some((p) => p.userId === currentUserId.value)
const isPlayer = computed(() => myRole.value === 'player') )
// ===== 求和状态 ===== // ===== 求和状态 =====
const drawRequested = ref<boolean>(false) const drawRequested = ref<boolean>(false)
const drawRequestFrom = ref<string | null>(null) const drawRequestFrom = ref<string | null>(null)
// ===== 观战者 =====
const spectatorCount = ref<number>(0)
/** /**
* 从房间详情 HTTP 响应初始化 Store * 从房间详情 HTTP 响应初始化 Store
* @param detail - 房间详情响应数据 * @param detail - 房间详情响应数据
@@ -72,16 +68,11 @@ export const useRoomStore = defineStore('room', () => {
status.value = detail.status status.value = detail.status
creatorId.value = detail.creatorId creatorId.value = detail.creatorId
creatorName.value = detail.creatorName creatorName.value = detail.creatorName
allowSpectate.value = detail.allowSpectate
players.value = detail.players players.value = detail.players
currentUserId.value = userId currentUserId.value = userId
boardState.value = detail.boardState ?? [] boardState.value = detail.boardState ?? []
currentTurn.value = detail.currentTurn ?? null currentTurn.value = detail.currentTurn ?? null
moves.value = detail.moves ?? [] moves.value = detail.moves ?? []
// 判定当前用户是玩家还是观战者
const isPlayerInRoom = detail.players.some((p) => p.userId === userId)
myRole.value = isPlayerInRoom ? 'player' : 'spectator'
} }
/** 更新棋盘状态WS move 事件) */ /** 更新棋盘状态WS move 事件) */
@@ -145,11 +136,6 @@ export const useRoomStore = defineStore('room', () => {
resultType.value = type resultType.value = type
} }
/** 更新观战人数 */
function setSpectatorCount(count: number) {
spectatorCount.value = count
}
/** 收到求和请求 */ /** 收到求和请求 */
function receiveDrawRequest(fromUserId: string) { function receiveDrawRequest(fromUserId: string) {
drawRequested.value = true drawRequested.value = true
@@ -178,14 +164,14 @@ export const useRoomStore = defineStore('room', () => {
return { return {
roomId, roomName, gameId, gameIcon, gameName, roomId, roomName, gameId, gameIcon, gameName,
maxPlayers, mode, stakes, status, creatorId, creatorName, allowSpectate, maxPlayers, mode, stakes, status, creatorId, creatorName,
players, isOwner, currentUserId, myRole, isPlayer, players, isOwner, currentUserId, isPlayer,
boardState, currentTurn, moves, winner, resultType, boardState, currentTurn, moves, winner, resultType,
isPlaying, isFinished, isPlaying, isFinished,
messages, messages,
drawRequested, drawRequestFrom, spectatorCount, drawRequested, drawRequestFrom,
initFromDetail, applyMove, setCurrentTurn, addMessage, initFromDetail, applyMove, setCurrentTurn, addMessage,
updateReady, addPlayer, removePlayer, setGameOver, setSpectatorCount, updateReady, addPlayer, removePlayer, setGameOver,
receiveDrawRequest, clearDrawRequest, reset receiveDrawRequest, clearDrawRequest, reset
} }
}) })

View File

@@ -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>()

View File

@@ -7,6 +7,8 @@ type AppEventsMap = {
/** 请求错误(业务错误 / HTTP 错误 / 网络错误) */ /** 请求错误(业务错误 / HTTP 错误 / 网络错误) */
'app:request:error': { 'app:request:error': {
type: 'business' | 'http' | 'network' type: 'business' | 'http' | 'network'
/** HTTP 状态码http/business 类型时有效) */
httpStatus?: number
code?: number code?: number
message: string message: string
} }

View File

@@ -16,9 +16,6 @@
<!-- 右侧面板 --> <!-- 右侧面板 -->
<RoomRightPanel /> <RoomRightPanel />
</div> </div>
<!-- 底部观战栏 -->
<SpectatorBar v-if="roomStore.allowSpectate" />
</div> </div>
</template> </template>
@@ -36,7 +33,6 @@ import RoomTopBar from './components/RoomTopBar.vue'
import RoomSidePanel from './components/RoomSidePanel.vue' import RoomSidePanel from './components/RoomSidePanel.vue'
import GameCanvas from './components/GameCanvas.vue' import GameCanvas from './components/GameCanvas.vue'
import RoomRightPanel from './components/RoomRightPanel.vue' import RoomRightPanel from './components/RoomRightPanel.vue'
import SpectatorBar from './components/SpectatorBar.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -154,14 +150,10 @@ function handleWsMessage(msg: WsMessage) {
roomStore.updateReady(data.userId, data.ready) roomStore.updateReady(data.userId, data.ready)
break break
} }
case 'spectator_join': { case 'player_disconnected': {
const data = msg.data as { count: number }
roomStore.setSpectatorCount(data.count)
break break
} }
case 'spectator_leave': { case 'player_reconnected': {
const data = msg.data as { count: number }
roomStore.setSpectatorCount(data.count)
break break
} }
case 'draw_requested': { case 'draw_requested': {

View File

@@ -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>