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 0000000..11667ff Binary files /dev/null and b/src/assets/images/direction/bei.png differ diff --git a/src/assets/images/direction/dong.png b/src/assets/images/direction/dong.png new file mode 100755 index 0000000..20d8a6c Binary files /dev/null and b/src/assets/images/direction/dong.png differ diff --git a/src/assets/images/direction/nan.png b/src/assets/images/direction/nan.png new file mode 100755 index 0000000..79d3b2a Binary files /dev/null and b/src/assets/images/direction/nan.png differ diff --git a/src/assets/images/direction/xi.png b/src/assets/images/direction/xi.png new file mode 100755 index 0000000..d95c4b2 Binary files /dev/null and b/src/assets/images/direction/xi.png differ 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 +}