From 0f1684b8d784272f201e5c8079ff163c0e3b989d Mon Sep 17 00:00:00 2001 From: wsy182 <2392948297@qq.com> Date: Wed, 25 Mar 2026 22:11:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(game):=20=E6=9B=B4=E6=96=B0=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E9=A1=B5=E9=9D=A2=E5=8A=9F=E8=83=BD=E5=92=8C=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=88=B7=E6=96=B0=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将开发环境代理目标从 192.168.1.5 改为 127.0.0.1 - 重构 auth.ts 文件中的代码缩进格式 - 实现自动令牌刷新机制,支持 JWT 过期时间检测 - 添加 WebSocket 连接的令牌强制刷新逻辑 - 新增 WindSquare 组件显示方位风向图标 - 实现动态座位风向计算和显示功能 - 优化 WebSocket URL 构建方式,移除查询参数中的令牌传递 - 添加登录失效时自动跳转到登录页面的功能 - 限制玩家名称显示长度为4个字符 - 改进 WebSocket 错误处理和重连机制 --- .env.development | 4 +- src/api/auth.ts | 263 ++++++++++++++------------- src/assets/images/direction/bei.png | Bin 0 -> 4426 bytes src/assets/images/direction/dong.png | Bin 0 -> 5010 bytes src/assets/images/direction/nan.png | Bin 0 -> 5757 bytes src/assets/images/direction/xi.png | Bin 0 -> 5385 bytes src/assets/images/icons/triangle.svg | 1 + src/assets/styles/global.css | 219 ---------------------- src/assets/styles/room.css | 33 ++++ src/components/game/WindSquare.vue | 151 +++++++++++++++ src/views/ChengduGamePage.vue | 159 ++++++++++++++-- src/views/HallPage.vue | 2 +- src/ws/client.ts | 13 +- src/ws/url.ts | 5 +- 14 files changed, 480 insertions(+), 370 deletions(-) create mode 100755 src/assets/images/direction/bei.png create mode 100755 src/assets/images/direction/dong.png create mode 100755 src/assets/images/direction/nan.png create mode 100755 src/assets/images/direction/xi.png create mode 100644 src/assets/images/icons/triangle.svg create mode 100644 src/components/game/WindSquare.vue diff --git a/.env.development b/.env.development index 152e027..562a716 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ VITE_API_BASE_URL=/api/v1 VITE_GAME_WS_URL=/ws -VITE_API_PROXY_TARGET=http://192.168.1.5:19000 -VITE_WS_PROXY_TARGET=http://192.168.1.5:19000 +VITE_API_PROXY_TARGET=http://127.0.0.1:19000 +VITE_WS_PROXY_TARGET=http://127.0.0.1:19000 diff --git a/src/api/auth.ts b/src/api/auth.ts index ae70331..df0f3ee 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -1,26 +1,26 @@ export interface AuthUser { - id?: string | number - username?: string - nickname?: string + id?: string | number + username?: string + nickname?: string } export interface AuthSessionInput { - token: string - tokenType?: string - refreshToken?: string + token: string + tokenType?: string + refreshToken?: string } export interface AuthResult { - token: string - tokenType?: string - refreshToken?: string - expiresIn?: number - user?: AuthUser + token: string + tokenType?: string + refreshToken?: string + expiresIn?: number + user?: AuthUser } interface ApiErrorPayload { - message?: string - error?: string + message?: string + error?: string } const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').trim().replace(/\/$/, '') @@ -30,165 +30,178 @@ const REFRESH_PATH = import.meta.env.VITE_REFRESH_PATH ?? '/api/v1/auth/refresh' const LOGIN_BEARER_TOKEN = (import.meta.env.VITE_LOGIN_BEARER_TOKEN ?? '').trim() function buildUrl(path: string): string { - if (/^https?:\/\//.test(path)) { - return path - } - - const normalizedPath = path.startsWith('/') ? path : `/${path}` - if (!API_BASE_URL) { - return normalizedPath - } - - if (API_BASE_URL.startsWith('/')) { - const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}` - if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) { - return normalizedPath + if (/^https?:\/\//.test(path)) { + return path } - return `${basePath}${normalizedPath}` - } - - // Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login - try { - const baseUrl = new URL(API_BASE_URL) - const basePath = baseUrl.pathname.replace(/\/$/, '') - if (basePath && normalizedPath.startsWith(`${basePath}/`)) { - return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}` + const normalizedPath = path.startsWith('/') ? path : `/${path}` + if (!API_BASE_URL) { + return normalizedPath } - } catch { - // API_BASE_URL may be a relative path; fallback to direct join. - } - return `${API_BASE_URL}${normalizedPath}` + if (API_BASE_URL.startsWith('/')) { + const basePath = API_BASE_URL.startsWith('/') ? API_BASE_URL : `/${API_BASE_URL}` + if (normalizedPath === basePath || normalizedPath.startsWith(`${basePath}/`)) { + return normalizedPath + } + + return `${basePath}${normalizedPath}` + } + + // Avoid duplicated API prefix, e.g. base: /api/v1 + path: /api/v1/auth/login + try { + const baseUrl = new URL(API_BASE_URL) + const basePath = baseUrl.pathname.replace(/\/$/, '') + if (basePath && normalizedPath.startsWith(`${basePath}/`)) { + return `${API_BASE_URL}${normalizedPath.slice(basePath.length)}` + } + } catch { + // API_BASE_URL may be a relative path; fallback to direct join. + } + + return `${API_BASE_URL}${normalizedPath}` } async function request( - url: string, - body: Record, - extraHeaders?: Record, + url: string, + body: Record, + extraHeaders?: Record, ): Promise { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...extraHeaders, - }, - body: JSON.stringify(body), - }) + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...extraHeaders, + }, + body: JSON.stringify(body), + }) - const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload - if (!response.ok) { - throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试') - } + const payload = (await response.json().catch(() => ({}))) as T & ApiErrorPayload + if (!response.ok) { + throw new Error(payload.message ?? payload.error ?? '请求失败,请稍后再试') + } - return payload + return payload } function createAuthHeader(token: string, tokenType = 'Bearer'): string { - const normalizedToken = token.trim() - if (/^\S+\s+\S+/.test(normalizedToken)) { - return normalizedToken - } + const normalizedToken = token.trim() + if (/^\S+\s+\S+/.test(normalizedToken)) { + return normalizedToken + } - return `${tokenType || 'Bearer'} ${normalizedToken}` + return `${tokenType || 'Bearer'} ${normalizedToken}` } function extractToken(payload: Record): string { - const candidate = - payload.token ?? - payload.accessToken ?? - payload.access_token ?? - (payload.data as Record | undefined)?.token ?? - (payload.data as Record | undefined)?.accessToken ?? - (payload.data as Record | undefined)?.access_token + const candidate = + payload.token ?? + payload.accessToken ?? + payload.access_token ?? + (payload.data as Record | undefined)?.token ?? + (payload.data as Record | undefined)?.accessToken ?? + (payload.data as Record | undefined)?.access_token - if (typeof candidate !== 'string' || candidate.length === 0) { - throw new Error('登录成功,但后端未返回 token 字段') - } + if (typeof candidate !== 'string' || candidate.length === 0) { + throw new Error('登录成功,但后端未返回 token 字段') + } - return candidate + return candidate } function extractTokenType(payload: Record): string | undefined { - const candidate = - payload.token_type ?? - payload.tokenType ?? - (payload.data as Record | undefined)?.token_type ?? - (payload.data as Record | undefined)?.tokenType + const candidate = + payload.token_type ?? + payload.tokenType ?? + (payload.data as Record | undefined)?.token_type ?? + (payload.data as Record | undefined)?.tokenType - return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined + return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined } function extractRefreshToken(payload: Record): string | undefined { - const candidate = - payload.refresh_token ?? - payload.refreshToken ?? - (payload.data as Record | undefined)?.refresh_token ?? - (payload.data as Record | undefined)?.refreshToken + const candidate = + payload.refresh_token ?? + payload.refreshToken ?? + (payload.data as Record | undefined)?.refresh_token ?? + (payload.data as Record | undefined)?.refreshToken - return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined + return typeof candidate === 'string' && candidate.length > 0 ? candidate : undefined } function extractExpiresIn(payload: Record): number | undefined { - const candidate = - payload.expires_in ?? - payload.expiresIn ?? - (payload.data as Record | undefined)?.expires_in ?? - (payload.data as Record | undefined)?.expiresIn + const candidate = + payload.expires_in ?? + payload.expiresIn ?? + (payload.data as Record | undefined)?.expires_in ?? + (payload.data as Record | undefined)?.expiresIn - return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined + return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : undefined } function extractUser(payload: Record): AuthUser | undefined { - const user = payload.user ?? (payload.data as Record | undefined)?.user - return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined + const user = payload.user ?? (payload.data as Record | undefined)?.user + return typeof user === 'object' && user !== null ? (user as AuthUser) : undefined } function parseAuthResult(payload: Record): AuthResult { - return { - token: extractToken(payload), - tokenType: extractTokenType(payload), - refreshToken: extractRefreshToken(payload), - expiresIn: extractExpiresIn(payload), - user: extractUser(payload), - } + return { + token: extractToken(payload), + tokenType: extractTokenType(payload), + refreshToken: extractRefreshToken(payload), + expiresIn: extractExpiresIn(payload), + user: extractUser(payload), + } } export async function register(input: { - username: string - phone: string - email: string - password: string + username: string + phone: string + email: string + password: string }): Promise { - await request>(buildUrl(REGISTER_PATH), input) + await request>(buildUrl(REGISTER_PATH), input) } export async function login(input: { loginId: string; password: string }): Promise { - const payload = await request>( - buildUrl(LOGIN_PATH), - { - login_id: input.loginId, - password: input.password, - }, - LOGIN_BEARER_TOKEN ? { Authorization: `Bearer ${LOGIN_BEARER_TOKEN}` } : undefined, - ) - return parseAuthResult(payload) + const payload = await request>( + buildUrl(LOGIN_PATH), + { + login_id: input.loginId, + password: input.password, + }, + LOGIN_BEARER_TOKEN ? {Authorization: `Bearer ${LOGIN_BEARER_TOKEN}`} : undefined, + ) + return parseAuthResult(payload) } export async function refreshAccessToken(input: AuthSessionInput): Promise { - if (!input.refreshToken) { - throw new Error('缺少 refresh_token,无法刷新登录状态') - } + if (!input.refreshToken) { + throw new Error('缺少 refresh_token,无法刷新登录状态') + } - const payload = await request>( - buildUrl(REFRESH_PATH), - { - refreshToken: input.refreshToken, - }, - { - Authorization: createAuthHeader(input.token, input.tokenType), - }, - ) + const refreshBody = { + refreshToken: input.refreshToken + } - return parseAuthResult(payload) + // 兼容不同后端实现: + // 1) 有的要求 Authorization + refresh token + // 2) 有的只接受 refresh token,不接受 Authorization + let payload: Record + try { + payload = await request>( + buildUrl(REFRESH_PATH), + refreshBody, + { + Authorization: createAuthHeader(input.token, input.tokenType), + }, + ) + } catch { + payload = await request>( + buildUrl(REFRESH_PATH), + refreshBody, + ) + } + + return parseAuthResult(payload) } diff --git a/src/assets/images/direction/bei.png b/src/assets/images/direction/bei.png new file mode 100755 index 0000000000000000000000000000000000000000..11667ff44621a925d766ee856ce70752c168dc3f GIT binary patch literal 4426 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 axL>5Fe%!rqa{%fm11JBoF)1gJWBv!2He|*C literal 0 HcmV?d00001 diff --git a/src/assets/images/direction/xi.png b/src/assets/images/direction/xi.png new file mode 100755 index 0000000000000000000000000000000000000000..d95c4b2648f287900561a09dd547f4ced083d03a GIT binary patch literal 5385 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 literal 0 HcmV?d00001 diff --git a/src/assets/images/icons/triangle.svg b/src/assets/images/icons/triangle.svg new file mode 100644 index 0000000..4c104bf --- /dev/null +++ b/src/assets/images/icons/triangle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index bbae90a..4260e62 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -746,225 +746,6 @@ button:disabled { border-style: solid; } -.table-watermark { - position: absolute; - left: 50%; - top: 24px; - transform: translateX(-50%); - display: flex; - flex-direction: column; - align-items: center; - gap: 2px; - color: rgba(244, 240, 220, 0.82); - text-align: center; - pointer-events: none; -} - -.table-watermark span { - font-size: 12px; - color: #f7e4b0; -} - -.table-watermark strong { - font-size: 26px; - letter-spacing: 2px; - text-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); -} - -.table-watermark small { - font-size: 12px; - color: #bdd8ca; -} - -.player-badge { - position: absolute; - display: flex; - align-items: center; - gap: 10px; - min-width: 154px; - padding: 9px 12px; - border-radius: 16px; - border: 1px solid rgba(248, 226, 173, 0.24); - background: - linear-gradient(180deg, rgba(43, 52, 73, 0.84), rgba(17, 22, 34, 0.82)), - radial-gradient(circle at top, rgba(255, 255, 255, 0.08), transparent 40%); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 12px 28px rgba(0, 0, 0, 0.24); -} - -.avatar-panel { - position: relative; - flex: 0 0 auto; -} - -.player-badge.seat-top { - top: 20px; - left: 50%; - transform: translateX(-50%); -} - -.player-badge.seat-right { - right: -20px; - top: 50%; - transform: translateY(-50%) rotate(90deg); -} - -.player-badge.seat-bottom { - bottom: 20px; - left: 50%; - transform: translateX(-50%); -} - -.player-badge.seat-left { - left: -20px; - top: 50%; - transform: translateY(-50%) rotate(90deg); -} - -.player-badge.is-turn { - border-color: rgba(244, 222, 163, 0.72); -} - -.player-badge.offline { - opacity: 0.55; -} - -.avatar-card { - display: grid; - place-items: center; - width: 48px; - height: 48px; - border-radius: 10px; - border: 1px solid rgba(255, 248, 215, 0.32); - background: - linear-gradient(145deg, #b3e79c, #4eaf4a 46%, #2f7e28 100%); - color: #f7fff7; - font-weight: 800; - box-shadow: - inset 0 2px 4px rgba(255, 255, 255, 0.18), - 0 6px 14px rgba(0, 0, 0, 0.22); - overflow: hidden; -} - -.avatar-card img { - width: 100%; - height: 100%; - object-fit: cover; -} - -.player-badge.seat-right .avatar-card img { - transform: rotate(-90deg); -} - -.player-badge.seat-left .avatar-card img { - transform: rotate(-90deg); -} - -.player-meta p { - font-size: 14px; - font-weight: 700; - color: #eef5ff; -} - -.player-meta strong { - font-size: 15px; - color: #ffd85c; - text-shadow: 0 0 10px rgba(255, 216, 92, 0.2); -} - -.dealer-mark, -.missing-mark { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 28px; - height: 28px; - border-radius: 999px; - font-size: 12px; -} - -.dealer-mark { - position: absolute; - right: -8px; - bottom: -6px; - background: linear-gradient(180deg, #ffe38a 0%, #f1b92e 100%); - color: #5f3200; - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.18); -} - -.missing-mark { - margin-left: auto; - width: 34px; - height: 34px; - padding: 0; - overflow: hidden; - background: linear-gradient(180deg, rgba(114, 219, 149, 0.2) 0%, rgba(21, 148, 88, 0.34) 100%); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.16); -} - -.missing-mark img { - width: 22px; - height: 22px; - object-fit: contain; -} - -.missing-mark span { - color: #effff5; -} - -.wall { - position: absolute; - display: flex; - gap: 2px; - filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.22)); -} - -.wall img { - display: block; - object-fit: contain; -} - -.wall-top, -.wall-bottom { - left: 50%; - transform: translateX(-50%); -} - -.wall-left, -.wall-right { - top: 50%; - transform: translateY(-50%); - flex-direction: column; -} - -.wall-top { - top: 154px; -} - -.wall-top img, -.wall-bottom img { - width: 24px; - height: 36px; -} - -.wall-right { - right: 132px; -} - -.wall-left { - left: 132px; -} - -.wall-left img, -.wall-right img { - width: 36px; - height: 24px; -} - -.wall-bottom { - bottom: 176px; -} .center-deck { position: absolute; diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css index fd0d74b..d86828c 100644 --- a/src/assets/styles/room.css +++ b/src/assets/styles/room.css @@ -393,6 +393,10 @@ border-color: rgba(244, 222, 163, 0.72); } +.picture-scene .player-badge.offline { + opacity: 0.55; +} + .picture-scene .avatar-card { display: grid; place-items: center; @@ -425,6 +429,22 @@ color: #eef5ff; } +.picture-scene .player-badge.seat-right .player-meta, +.picture-scene .player-badge.seat-left .player-meta { + display: flex; + align-items: center; + justify-content: center; + min-height: 48px; + transform: rotate(-90deg); +} + +.picture-scene .player-badge.seat-right .player-meta p, +.picture-scene .player-badge.seat-left .player-meta p { + line-height: 1; + letter-spacing: 1px; + white-space: nowrap; +} + .picture-scene .dealer-mark, .picture-scene .missing-mark { display: inline-flex; @@ -461,6 +481,10 @@ object-fit: contain; } +.picture-scene .missing-mark span { + color: #effff5; +} + .wall { position: absolute; display: flex; @@ -542,6 +566,15 @@ left: 110px; } +.center-wind-square { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 3; + pointer-events: none; +} + .center-desk { position: absolute; left: 50%; diff --git a/src/components/game/WindSquare.vue b/src/components/game/WindSquare.vue new file mode 100644 index 0000000..8e8c607 --- /dev/null +++ b/src/components/game/WindSquare.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index 38b1459..adf2bf8 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -16,11 +16,17 @@ import TopPlayerCard from '../components/game/TopPlayerCard.vue' import RightPlayerCard from '../components/game/RightPlayerCard.vue' import BottomPlayerCard from '../components/game/BottomPlayerCard.vue' import LeftPlayerCard from '../components/game/LeftPlayerCard.vue' +import WindSquare from '../components/game/WindSquare.vue' +import eastWind from '../assets/images/direction/dong.png' +import southWind from '../assets/images/direction/nan.png' +import westWind from '../assets/images/direction/xi.png' +import northWind from '../assets/images/direction/bei.png' import type {SeatPlayerCardModel} from '../components/game/seat-player-card' import type {SeatKey} from '../game/seat' import type {GameAction} from '../game/actions' import {dispatchGameAction} from '../game/dispatcher' -import {readStoredAuth} from '../utils/auth-storage' +import {refreshAccessToken} from '../api/auth' +import {clearAuth, readStoredAuth, writeStoredAuth} from '../utils/auth-storage' import type {WsStatus} from '../ws/client' import {wsClient} from '../ws/client' import {sendWsMessage} from '../ws/sender' @@ -64,6 +70,8 @@ const isTrustMode = ref(false) const menuTriggerActive = ref(false) let menuTriggerTimer: number | null = null let menuOpenTimer: number | null = null +let refreshingWsToken = false +let lastForcedRefreshAt = 0 const loggedInUserId = computed(() => { const rawId = auth.value?.user?.id @@ -173,6 +181,28 @@ const seatViews = computed(() => { }) }) +const seatWinds = computed>(() => { + const tableOrder: SeatKey[] = ['bottom', 'right', 'top', 'left'] + const players = gamePlayers.value + const selfSeatIndex = myPlayer.value?.seatIndex ?? players.find((player) => player.playerId === loggedInUserId.value)?.seatIndex ?? 0 + + const directionBySeatIndex = [eastWind, southWind, westWind, northWind] + const result: Record = { + top: northWind, + right: eastWind, + bottom: southWind, + left: westWind, + } + + for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) { + const relativeIndex = (absoluteSeat - selfSeatIndex + 4) % 4 + const seatKey = tableOrder[relativeIndex] ?? 'top' + result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind + } + + return result +}) + const rightMessages = computed(() => wsMessages.value.slice(-16).reverse()) const currentPhaseText = computed(() => { @@ -255,13 +285,13 @@ const seatDecor = computed>(() => { const displayName = seat.player.displayName || `玩家${seat.player.seatIndex + 1}` const avatarUrl = seat.isSelf - ? (localCachedAvatarUrl.value || seat.player.avatarURL || '') - : (seat.player.avatarURL || '') + ? (localCachedAvatarUrl.value || seat.player.avatarURL || '') + : (seat.player.avatarURL || '') const selfDisplayName = seat.player.displayName || loggedInUserName.value || '你自己' result[seat.key] = { avatarUrl, - name: seat.isSelf ? selfDisplayName : displayName, + name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''), dealer: seat.player.seatIndex === dealerIndex, isTurn: seat.isTurn, missingSuitLabel: missingSuitLabel(seat.player.missingSuit), @@ -409,26 +439,106 @@ function toGameAction(message: unknown): GameAction | null { } } -function ensureWsConnected(): void { - const token = auth.value?.token +function logoutToLogin(): void { + clearAuth() + auth.value = null + wsClient.close() + void router.replace('/login') +} + +function decodeJwtExpMs(token: string): number | null { + const parts = token.split('.') + const payloadPart = parts[1] + if (!payloadPart) { + return null + } + + try { + const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) + const payload = JSON.parse(window.atob(padded)) as { exp?: number } + return typeof payload.exp === 'number' ? payload.exp * 1000 : null + } catch { + return null + } +} + +function shouldRefreshWsToken(token: string): boolean { + const expMs = decodeJwtExpMs(token) + if (!expMs) { + return false + } + + return expMs <= Date.now() + 30_000 +} + +async function resolveWsToken(forceRefresh = false, logoutOnRefreshFail = false): Promise { + const current = auth.value + if (!current?.token) { + return null + } + + if (!forceRefresh && !shouldRefreshWsToken(current.token)) { + return current.token + } + + if (!current.refreshToken || refreshingWsToken) { + return current.token + } + + refreshingWsToken = true + try { + const refreshed = await refreshAccessToken({ + token: current.token, + tokenType: current.tokenType, + refreshToken: current.refreshToken, + }) + + const nextAuth = { + ...current, + token: refreshed.token, + tokenType: refreshed.tokenType ?? current.tokenType, + refreshToken: refreshed.refreshToken ?? current.refreshToken, + expiresIn: refreshed.expiresIn, + } + auth.value = nextAuth + writeStoredAuth(nextAuth) + return nextAuth.token + } catch { + if (logoutOnRefreshFail) { + logoutToLogin() + } + return null + } finally { + refreshingWsToken = false + } +} + +async function ensureWsConnected(forceRefresh = false): Promise { + const token = await resolveWsToken(forceRefresh, false) if (!token) { wsError.value = '未找到登录凭证,无法建立连接' return } wsError.value = '' - wsClient.connect(buildWsUrl(token), token) + wsClient.connect(buildWsUrl(), token) +} + +async function reconnectWsInternal(forceRefresh = false): Promise { + const token = await resolveWsToken(forceRefresh, false) + if (!token) { + wsError.value = '未找到登录凭证,无法建立连接' + return false + } + + wsError.value = '' + wsClient.reconnect(buildWsUrl(), token) + return true } function reconnectWs(): void { - const token = auth.value?.token - if (!token) { - wsError.value = '未找到登录凭证,无法建立连接' - return - } - - wsError.value = '' - wsClient.reconnect(buildWsUrl(token), token) + void reconnectWsInternal() } function backHall(): void { @@ -537,10 +647,25 @@ onMounted(() => { wsClient.onError((message: string) => { wsError.value = message wsMessages.value.push(`[error] ${message}`) + + // WebSocket 握手失败时浏览器拿不到 401 状态码,统一按需强制刷新 token 后重连一次 + const nowMs = Date.now() + if (nowMs - lastForcedRefreshAt > 5000) { + lastForcedRefreshAt = nowMs + void resolveWsToken(true, true).then((refreshedToken) => { + if (!refreshedToken) { + return + } + wsError.value = '' + wsClient.reconnect(buildWsUrl(), refreshedToken) + }).catch(() => { + logoutToLogin() + }) + } }) unsubscribe = wsClient.onStatusChange(handler) - ensureWsConnected() + void ensureWsConnected() clockTimer = window.setInterval(() => { now.value = Date.now() @@ -669,6 +794,8 @@ onBeforeUnmount(() => { {{ seatDecor.right.missingSuitLabel }} + +
diff --git a/src/views/HallPage.vue b/src/views/HallPage.vue index 3883251..672372c 100644 --- a/src/views/HallPage.vue +++ b/src/views/HallPage.vue @@ -188,7 +188,7 @@ function connectGameWs(): void { if (!token) { return } - wsClient.connect(buildWsUrl(token), token) + wsClient.connect(buildWsUrl(), token) } async function refreshRooms(): Promise { diff --git a/src/ws/client.ts b/src/ws/client.ts index 5f5be59..e2592f3 100644 --- a/src/ws/client.ts +++ b/src/ws/client.ts @@ -38,10 +38,15 @@ class WsClient { private buildUrl(): string { if (!this.token) return this.url - const hasQuery = this.url.includes('?') - const connector = hasQuery ? '&' : '?' - - return `${this.url}${connector}token=${encodeURIComponent(this.token)}` + try { + const parsed = new URL(this.url) + parsed.searchParams.set('token', this.token) + return parsed.toString() + } catch { + const hasQuery = this.url.includes('?') + const connector = hasQuery ? '&' : '?' + return `${this.url}${connector}token=${encodeURIComponent(this.token)}` + } } diff --git a/src/ws/url.ts b/src/ws/url.ts index a8054ed..3437732 100644 --- a/src/ws/url.ts +++ b/src/ws/url.ts @@ -1,6 +1,6 @@ const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws' -export function buildWsUrl(token: string): string { +export function buildWsUrl(): string { const baseUrl = /^wss?:\/\//.test(WS_BASE_URL) ? new URL(WS_BASE_URL) : new URL( @@ -8,6 +8,5 @@ export function buildWsUrl(token: string): string { `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`, ) - baseUrl.searchParams.set('token', token) return baseUrl.toString() -} \ No newline at end of file +}