feat(game): 更新游戏页面功能和认证刷新机制

- 将开发环境代理目标从 192.168.1.5 改为 127.0.0.1
- 重构 auth.ts 文件中的代码缩进格式
- 实现自动令牌刷新机制,支持 JWT 过期时间检测
- 添加 WebSocket 连接的令牌强制刷新逻辑
- 新增 WindSquare 组件显示方位风向图标
- 实现动态座位风向计算和显示功能
- 优化 WebSocket URL 构建方式,移除查询参数中的令牌传递
- 添加登录失效时自动跳转到登录页面的功能
- 限制玩家名称显示长度为4个字符
- 改进 WebSocket 错误处理和重连机制
This commit is contained in:
2026-03-25 22:11:54 +08:00
parent 43744c2203
commit 0f1684b8d7
14 changed files with 480 additions and 370 deletions

View File

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