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

@@ -1,4 +1,4 @@
VITE_API_BASE_URL=/api/v1 VITE_API_BASE_URL=/api/v1
VITE_GAME_WS_URL=/ws VITE_GAME_WS_URL=/ws
VITE_API_PROXY_TARGET=http://192.168.1.5:19000 VITE_API_PROXY_TARGET=http://127.0.0.1:19000
VITE_WS_PROXY_TARGET=http://192.168.1.5:19000 VITE_WS_PROXY_TARGET=http://127.0.0.1:19000

View File

@@ -170,7 +170,7 @@ export async function login(input: { loginId: string; password: string }): Promi
login_id: input.loginId, login_id: input.loginId,
password: input.password, password: input.password,
}, },
LOGIN_BEARER_TOKEN ? { Authorization: `Bearer ${LOGIN_BEARER_TOKEN}` } : undefined, LOGIN_BEARER_TOKEN ? {Authorization: `Bearer ${LOGIN_BEARER_TOKEN}`} : undefined,
) )
return parseAuthResult(payload) return parseAuthResult(payload)
} }
@@ -180,15 +180,28 @@ export async function refreshAccessToken(input: AuthSessionInput): Promise<AuthR
throw new Error('缺少 refresh_token无法刷新登录状态') throw new Error('缺少 refresh_token无法刷新登录状态')
} }
const payload = await request<Record<string, unknown>>( const refreshBody = {
refreshToken: input.refreshToken
}
// 兼容不同后端实现:
// 1) 有的要求 Authorization + refresh token
// 2) 有的只接受 refresh token不接受 Authorization
let payload: Record<string, unknown>
try {
payload = await request<Record<string, unknown>>(
buildUrl(REFRESH_PATH), buildUrl(REFRESH_PATH),
{ refreshBody,
refreshToken: input.refreshToken,
},
{ {
Authorization: createAuthHeader(input.token, input.tokenType), Authorization: createAuthHeader(input.token, input.tokenType),
}, },
) )
} catch {
payload = await request<Record<string, unknown>>(
buildUrl(REFRESH_PATH),
refreshBody,
)
}
return parseAuthResult(payload) return parseAuthResult(payload)
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1774491457300" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="6759" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M535.466667 812.8l450.133333-563.2c14.933333-19.2 2.133333-49.066667-23.466667-49.066667H61.866667c-25.6 0-38.4 29.866667-23.466667 49.066667l450.133333 563.2c12.8 14.933333 34.133333 14.933333 46.933334 0z" fill="#ffffff" p-id="6760"></path></svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@@ -746,225 +746,6 @@ button:disabled {
border-style: solid; 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 { .center-deck {
position: absolute; position: absolute;

View File

@@ -393,6 +393,10 @@
border-color: rgba(244, 222, 163, 0.72); border-color: rgba(244, 222, 163, 0.72);
} }
.picture-scene .player-badge.offline {
opacity: 0.55;
}
.picture-scene .avatar-card { .picture-scene .avatar-card {
display: grid; display: grid;
place-items: center; place-items: center;
@@ -425,6 +429,22 @@
color: #eef5ff; 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 .dealer-mark,
.picture-scene .missing-mark { .picture-scene .missing-mark {
display: inline-flex; display: inline-flex;
@@ -461,6 +481,10 @@
object-fit: contain; object-fit: contain;
} }
.picture-scene .missing-mark span {
color: #effff5;
}
.wall { .wall {
position: absolute; position: absolute;
display: flex; display: flex;
@@ -542,6 +566,15 @@
left: 110px; left: 110px;
} }
.center-wind-square {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
z-index: 3;
pointer-events: none;
}
.center-desk { .center-desk {
position: absolute; position: absolute;
left: 50%; left: 50%;

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import triangleIcon from '../../assets/images/icons/triangle.svg'
defineProps<{
seatWinds: {
top: string
right: string
bottom: string
left: string
}
}>()
</script>
<template>
<div class="wind-square">
<img class="triangle top" :src="triangleIcon" alt="" />
<img class="triangle right" :src="triangleIcon" alt="" />
<img class="triangle bottom" :src="triangleIcon" alt="" />
<img class="triangle left" :src="triangleIcon" alt="" />
<span class="wind-slot wind-top">
<img class="wind-icon" :src="seatWinds.top" alt="北位风" />
</span>
<span class="wind-slot wind-right">
<img class="wind-icon" :src="seatWinds.right" alt="右位风" />
</span>
<span class="wind-slot wind-bottom">
<img class="wind-icon" :src="seatWinds.bottom" alt="本位风" />
</span>
<span class="wind-slot wind-left">
<img class="wind-icon" :src="seatWinds.left" alt="左位风" />
</span>
</div>
</template>
<style scoped>
.wind-square {
position: relative;
width: 128px;
height: 128px;
border-radius: 18px;
}
.wind-square::before {
content: '';
position: absolute;
inset: 18px;
border-radius: 10px;
background:
radial-gradient(circle at 50% 45%, rgba(244, 222, 151, 0.2), rgba(12, 40, 30, 0.05) 65%),
linear-gradient(145deg, rgba(21, 82, 58, 0.42), rgba(8, 38, 27, 0.16));
box-shadow:
inset 0 0 0 1px rgba(255, 225, 165, 0.15),
0 6px 12px rgba(0, 0, 0, 0.24);
}
.triangle {
position: absolute;
width: 64px;
height: 64px;
object-fit: contain;
opacity: 0.96;
filter: drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.top {
top: 4px;
left: 32px;
transform: rotate(0deg);
filter:
hue-rotate(-8deg)
saturate(1.35)
brightness(1.1)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.right {
top: 32px;
right: 4px;
transform: rotate(90deg);
filter:
hue-rotate(16deg)
saturate(1.28)
brightness(1.08)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.bottom {
bottom: 4px;
left: 32px;
transform: rotate(180deg);
filter:
hue-rotate(34deg)
saturate(1.2)
brightness(1.02)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.triangle.left {
top: 32px;
left: 4px;
transform: rotate(270deg);
filter:
hue-rotate(-26deg)
saturate(1.24)
brightness(1.06)
drop-shadow(0 3px 6px rgba(0, 0, 0, 0.3));
}
.wind-slot {
position: absolute;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 9px;
background:
linear-gradient(180deg, rgba(255, 237, 186, 0.92), rgba(232, 191, 105, 0.84));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.56),
0 3px 8px rgba(0, 0, 0, 0.26);
}
.wind-icon {
width: 24px;
height: 24px;
object-fit: contain;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.wind-top {
top: 12px;
left: 48px;
}
.wind-right {
top: 48px;
right: 12px;
}
.wind-bottom {
bottom: 12px;
left: 48px;
}
.wind-left {
top: 48px;
left: 12px;
}
</style>

View File

@@ -16,11 +16,17 @@ import TopPlayerCard from '../components/game/TopPlayerCard.vue'
import RightPlayerCard from '../components/game/RightPlayerCard.vue' import RightPlayerCard from '../components/game/RightPlayerCard.vue'
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue' import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
import LeftPlayerCard from '../components/game/LeftPlayerCard.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 {SeatPlayerCardModel} from '../components/game/seat-player-card'
import type {SeatKey} from '../game/seat' import type {SeatKey} from '../game/seat'
import type {GameAction} from '../game/actions' import type {GameAction} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher' 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 type {WsStatus} from '../ws/client'
import {wsClient} from '../ws/client' import {wsClient} from '../ws/client'
import {sendWsMessage} from '../ws/sender' import {sendWsMessage} from '../ws/sender'
@@ -64,6 +70,8 @@ const isTrustMode = ref(false)
const menuTriggerActive = ref(false) const menuTriggerActive = ref(false)
let menuTriggerTimer: number | null = null let menuTriggerTimer: number | null = null
let menuOpenTimer: number | null = null let menuOpenTimer: number | null = null
let refreshingWsToken = false
let lastForcedRefreshAt = 0
const loggedInUserId = computed(() => { const loggedInUserId = computed(() => {
const rawId = auth.value?.user?.id 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 rightMessages = computed(() => wsMessages.value.slice(-16).reverse())
const currentPhaseText = computed(() => { const currentPhaseText = computed(() => {
@@ -261,7 +291,7 @@ const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
result[seat.key] = { result[seat.key] = {
avatarUrl, avatarUrl,
name: seat.isSelf ? selfDisplayName : displayName, name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
dealer: seat.player.seatIndex === dealerIndex, dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn, isTurn: seat.isTurn,
missingSuitLabel: missingSuitLabel(seat.player.missingSuit), missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
@@ -409,26 +439,106 @@ function toGameAction(message: unknown): GameAction | null {
} }
} }
function ensureWsConnected(): void { function logoutToLogin(): void {
const token = auth.value?.token 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) { if (!token) {
wsError.value = '未找到登录凭证,无法建立连接' wsError.value = '未找到登录凭证,无法建立连接'
return return
} }
wsError.value = '' 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 { function reconnectWs(): void {
const token = auth.value?.token void reconnectWsInternal()
if (!token) {
wsError.value = '未找到登录凭证,无法建立连接'
return
}
wsError.value = ''
wsClient.reconnect(buildWsUrl(token), token)
} }
function backHall(): void { function backHall(): void {
@@ -537,10 +647,25 @@ onMounted(() => {
wsClient.onError((message: string) => { wsClient.onError((message: string) => {
wsError.value = message wsError.value = message
wsMessages.value.push(`[error] ${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) unsubscribe = wsClient.onStatusChange(handler)
ensureWsConnected() void ensureWsConnected()
clockTimer = window.setInterval(() => { clockTimer = window.setInterval(() => {
now.value = Date.now() now.value = Date.now()
@@ -669,6 +794,8 @@ onBeforeUnmount(() => {
<span>{{ seatDecor.right.missingSuitLabel }}</span> <span>{{ seatDecor.right.missingSuitLabel }}</span>
</div> </div>
<WindSquare class="center-wind-square" :seat-winds="seatWinds"/>
<div class="bottom-control-panel"> <div class="bottom-control-panel">

View File

@@ -188,7 +188,7 @@ function connectGameWs(): void {
if (!token) { if (!token) {
return return
} }
wsClient.connect(buildWsUrl(token), token) wsClient.connect(buildWsUrl(), token)
} }
async function refreshRooms(): Promise<void> { async function refreshRooms(): Promise<void> {

View File

@@ -38,11 +38,16 @@ class WsClient {
private buildUrl(): string { private buildUrl(): string {
if (!this.token) return this.url if (!this.token) return this.url
try {
const parsed = new URL(this.url)
parsed.searchParams.set('token', this.token)
return parsed.toString()
} catch {
const hasQuery = this.url.includes('?') const hasQuery = this.url.includes('?')
const connector = hasQuery ? '&' : '?' const connector = hasQuery ? '&' : '?'
return `${this.url}${connector}token=${encodeURIComponent(this.token)}` return `${this.url}${connector}token=${encodeURIComponent(this.token)}`
} }
}
// 建立连接 // 建立连接

View File

@@ -1,6 +1,6 @@
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? '/api/v1/ws' 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) const baseUrl = /^wss?:\/\//.test(WS_BASE_URL)
? new URL(WS_BASE_URL) ? new URL(WS_BASE_URL)
: new URL( : new URL(
@@ -8,6 +8,5 @@ export function buildWsUrl(token: string): string {
`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`, `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`,
) )
baseUrl.searchParams.set('token', token)
return baseUrl.toString() return baseUrl.toString()
} }