feat(game): 更新游戏页面功能和认证刷新机制
- 将开发环境代理目标从 192.168.1.5 改为 127.0.0.1 - 重构 auth.ts 文件中的代码缩进格式 - 实现自动令牌刷新机制,支持 JWT 过期时间检测 - 添加 WebSocket 连接的令牌强制刷新逻辑 - 新增 WindSquare 组件显示方位风向图标 - 实现动态座位风向计算和显示功能 - 优化 WebSocket URL 构建方式,移除查询参数中的令牌传递 - 添加登录失效时自动跳转到登录页面的功能 - 限制玩家名称显示长度为4个字符 - 改进 WebSocket 错误处理和重连机制
This commit is contained in:
@@ -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<SeatViewModel[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const seatWinds = computed<Record<SeatKey, string>>(() => {
|
||||
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<SeatKey, string> = {
|
||||
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<Record<SeatKey, SeatPlayerCardModel>>(() => {
|
||||
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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(() => {
|
||||
<span>{{ seatDecor.right.missingSuitLabel }}</span>
|
||||
</div>
|
||||
|
||||
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
|
||||
|
||||
|
||||
<div class="bottom-control-panel">
|
||||
|
||||
|
||||
Reference in New Issue
Block a user