feat(game): update websocket URL configuration and improve game room logic

- Change VITE_GAME_WS_URL from /api/v1/ws to /ws in .env.development
- Update proxy configuration in vite.config.ts to match new websocket path
- Refactor leave room functionality to properly disconnect websocket
and destroy room state
- Add e2e testing script to package.json
```
This commit is contained in:
2026-03-24 14:38:47 +08:00
parent 1b15748d0d
commit 84ce67b9be
7 changed files with 157 additions and 70 deletions

View File

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

View File

@@ -7,7 +7,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"vue": "^3.5.25", "vue": "^3.5.25",

View File

@@ -1,3 +1,10 @@
html,
body,
#app {
height: 100%;
overflow: hidden;
}
:root { :root {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
line-height: 1.5; line-height: 1.5;
@@ -21,6 +28,7 @@ body {
#app { #app {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
} }
h1, h1,
@@ -31,6 +39,8 @@ p {
.app-shell { .app-shell {
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
} }
.auth-page { .auth-page {
@@ -429,7 +439,9 @@ button:disabled {
width: 100%; width: 100%;
max-width: none; max-width: none;
height: 100vh; height: 100vh;
height: 100dvh;
min-height: 100vh; min-height: 100vh;
min-height: 100dvh;
margin: 0; margin: 0;
padding-top: max(12px, env(safe-area-inset-top)); padding-top: max(12px, env(safe-area-inset-top));
padding-right: max(12px, env(safe-area-inset-right)); padding-right: max(12px, env(safe-area-inset-right));
@@ -647,18 +659,23 @@ button:disabled {
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr) 320px;
gap: 12px; gap: 12px;
min-height: 0; min-height: 0;
height: 100%;
overflow: hidden; overflow: hidden;
align-items: stretch; align-items: center;
} }
.table-desk { .table-desk {
display: block; display: block;
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
width: 100%; width: min(100%, calc((100dvh - 220px) * 16 / 9));
height: 100%; max-width: 100%;
height: auto;
aspect-ratio: 16 / 9;
justify-self: center;
align-self: center;
border-radius: 28px; border-radius: 28px;
object-fit: cover; object-fit: contain;
object-position: center; object-position: center;
box-shadow: box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.04), inset 0 0 0 1px rgba(255, 255, 255, 0.04),
@@ -670,7 +687,13 @@ button:disabled {
grid-row: 1; grid-row: 1;
position: relative; position: relative;
min-height: 0; min-height: 0;
height: 100%; width: min(100%, calc((100dvh - 220px) * 16 / 9));
height: auto;
aspect-ratio: 16 / 9;
max-width: 100%;
max-height: 100%;
justify-self: center;
align-self: center;
border-radius: 28px; border-radius: 28px;
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.06);
background: background:
@@ -1033,6 +1056,7 @@ button:disabled {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
height: 100%;
margin-top: 0; margin-top: 0;
padding: 10px; padding: 10px;
border-radius: 8px; border-radius: 8px;
@@ -1041,6 +1065,10 @@ button:disabled {
overflow: hidden; overflow: hidden;
} }
.ws-log {
min-height: 0;
}
.ws-panel-head { .ws-panel-head {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -176,12 +176,15 @@ export function useChengduGameRoom(
return return
} }
leaveHallAfterAck.value = true
const sent = sendLeaveRoom() const sent = sendLeaveRoom()
if (!sent) { if (!sent) {
leaveHallAfterAck.value = false
pushWsMessage('[client] Leave room request was not sent') pushWsMessage('[client] Leave room request was not sent')
} }
leaveHallAfterAck.value = false
disconnectWs()
destroyActiveRoomState()
void router.push('/hall')
} }
function pushWsMessage(text: string): void { function pushWsMessage(text: string): void {

View File

@@ -101,64 +101,62 @@ const wallBacks = computed<Record<SeatKey, string[]>>(() => {
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => { const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const scoreMap = roomState.value.game?.state?.scores ?? {} const scoreMap = roomState.value.game?.state?.scores ?? {}
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1 const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
const emptyLabel = missingSuitLabel(null) const defaultMissingSuitLabel = missingSuitLabel(null)
return seatViews.value.reduce( const emptySeat = (avatar: string): SeatPlayerCardModel => ({
(acc, seat, index) => { avatar,
const playerId = seat.player?.playerId ?? '' name: '空位',
const score = playerId ? scoreMap[playerId] : undefined money: '--',
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: defaultMissingSuitLabel,
})
acc[seat.key] = { const result: Record<SeatKey, SeatPlayerCardModel> = {
avatar: seat.isSelf ? '我' : String(index + 1), top: emptySeat('1'),
name: seat.player ? (seat.isSelf ? '' : playerId) : '空位', right: emptySeat('2'),
money: typeof score === 'number' ? `${score}` : '--', bottom: emptySeat('我'),
dealer: seat.player?.index === dealerIndex, left: emptySeat('4'),
isTurn: seat.isTurn, }
isOnline: Boolean(seat.player),
missingSuitLabel: emptyLabel,
}
return acc for (const [index, seat] of seatViews.value.entries()) {
}, if (!seat.player) {
{ continue
top: { }
avatar: '1',
name: '空位', const playerId = seat.player.playerId
money: '--', const score = scoreMap[playerId]
dealer: false, result[seat.key] = {
isTurn: false, avatar: seat.isSelf ? '我' : String(index + 1),
isOnline: false, name: seat.isSelf ? '你自己' : playerId,
missingSuitLabel: emptyLabel, money: typeof score === 'number' ? String(score) : '--',
}, dealer: seat.player.index === dealerIndex,
right: { isTurn: seat.isTurn,
avatar: '2', isOnline: true,
name: '空位', missingSuitLabel: defaultMissingSuitLabel,
money: '--', }
dealer: false, }
isTurn: false,
isOnline: false, return result
missingSuitLabel: emptyLabel, })
},
bottom: { const seatMarkers = computed(() => {
avatar: '我', const seatTitleMap: Record<SeatKey, string> = {
name: '空位', top: '上家',
money: '--', right: '右家',
dealer: false, bottom: '本家',
isTurn: false, left: '左家',
isOnline: false, }
missingSuitLabel: emptyLabel,
}, return seatViews.value.map((seat) => ({
left: { key: seat.key,
avatar: '4', occupied: Boolean(seat.player),
name: '空位', isSelf: seat.isSelf,
money: '--', isTurn: seat.isTurn,
dealer: false, label: seat.player ? seatTitleMap[seat.key] : '空位',
isTurn: false, subLabel: seat.player ? `座位 ${seat.player.index}` : '',
isOnline: false, }))
missingSuitLabel: emptyLabel,
},
},
)
}) })
const centerTimer = computed(() => { const centerTimer = computed(() => {
@@ -174,7 +172,7 @@ const centerTimer = computed(() => {
function missingSuitLabel(value: string | null | undefined): string { function missingSuitLabel(value: string | null | undefined): string {
if (!value) { if (!value) {
return '定' return '定'
} }
const suitMap: Record<string, string> = { const suitMap: Record<string, string> = {
@@ -231,6 +229,7 @@ onBeforeUnmount(() => {
<p class="game-subtitle">{{ roomStatusText }} · {{ currentPhaseText }}</p> <p class="game-subtitle">{{ roomStatusText }} · {{ currentPhaseText }}</p>
</div> </div>
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
<div class="status-chip net-chip"> <div class="status-chip net-chip">
<span class="wifi-dot" :class="`is-${wsStatus}`"></span> <span class="wifi-dot" :class="`is-${wsStatus}`"></span>
@@ -249,7 +248,7 @@ onBeforeUnmount(() => {
<strong>{{ roomState.name || roomName || '未命名房间' }}</strong> <strong>{{ roomState.name || roomName || '未命名房间' }}</strong>
</span> </span>
<span class="room-brief-item room-brief-id"> <span class="room-brief-item room-brief-id">
<em>room_id:</em> <em>room_id</em>
<strong>{{ roomId || '未选择房间' }}</strong> <strong>{{ roomId || '未选择房间' }}</strong>
</span> </span>
<span class="room-brief-item"> <span class="room-brief-item">
@@ -276,6 +275,7 @@ onBeforeUnmount(() => {
<div class="table-felt"> <div class="table-felt">
<div class="felt-frame outer"></div> <div class="felt-frame outer"></div>
<div class="felt-frame inner"></div> <div class="felt-frame inner"></div>
<div class="table-watermark"> <div class="table-watermark">
<span>{{ statusRibbon }}</span> <span>{{ statusRibbon }}</span>
<strong>指尖四川麻将</strong> <strong>指尖四川麻将</strong>
@@ -307,19 +307,21 @@ onBeforeUnmount(() => {
<span class="wind east"></span> <span class="wind east"></span>
<strong>{{ centerTimer }}</strong> <strong>{{ centerTimer }}</strong>
</div> </div>
<div <div
v-for="seat in seatViews" v-for="seat in seatMarkers"
:key="seat.key" :key="seat.key"
class="seat" class="seat"
:class="[ :class="[
`seat-${seat.key}`, `seat-${seat.key}`,
{ occupied: Boolean(seat.player), 'seat-me': seat.isSelf, 'seat-turn': seat.isTurn }, { occupied: seat.occupied, 'seat-me': seat.isSelf, 'seat-turn': seat.isTurn },
]" ]"
> >
<strong>{{ seat.label }}</strong> <strong>{{ seat.label }}</strong>
<small v-if="seat.subLabel">{{ seat.subLabel }}</small> <small v-if="seat.subLabel">{{ seat.subLabel }}</small>
<span v-if="seat.isTurn" class="turn-indicator">出牌中</span> <span v-if="seat.isTurn" class="turn-indicator">出牌中</span>
</div> </div>
<div class="table-center"> <div class="table-center">
<p>成都麻将</p> <p>成都麻将</p>
<p>{{ roomState.id || roomId || '等待中...' }}</p> <p>{{ roomState.id || roomId || '等待中...' }}</p>
@@ -330,7 +332,7 @@ onBeforeUnmount(() => {
<div class="ws-panel-head"> <div class="ws-panel-head">
<strong>实时消息</strong> <strong>实时消息</strong>
<div class="ws-actions"> <div class="ws-actions">
<span class="ws-state" :class="`is-${wsStatus}`">{{ wsStatus }}</span> <span class="ws-state" :class="`is-${wsStatus}`">{{ networkLabel }}</span>
<button class="ghost-btn ws-reconnect" type="button" @click="connectWs">重连</button> <button class="ghost-btn ws-reconnect" type="button" @click="connectWs">重连</button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,53 @@
import { expect, test } from '@playwright/test'
function uniqueUser() {
const stamp = Date.now().toString()
return {
username: `pw${stamp.slice(-8)}`,
phone: `13${stamp.slice(-9)}`,
email: `pw${stamp}@example.com`,
password: 'playwright123',
roomName: `pw-room-${stamp.slice(-6)}`,
}
}
test('register, login, create room, enter game, and back to hall', async ({ page }) => {
const user = uniqueUser()
await page.goto('/register')
const registerInputs = page.locator('.auth-card .form input')
await registerInputs.nth(0).fill(user.username)
await registerInputs.nth(1).fill(user.phone)
await registerInputs.nth(2).fill(user.email)
await registerInputs.nth(3).fill(user.password)
await registerInputs.nth(4).fill(user.password)
await page.locator('.auth-card .primary-btn[type="submit"]').click()
await page.waitForURL(/\/login/)
const loginInputs = page.locator('.auth-card .form input')
await expect(loginInputs.nth(0)).toHaveValue(user.phone)
await loginInputs.nth(1).fill(user.password)
await page.locator('.auth-card .primary-btn[type="submit"]').click()
await page.waitForURL(/\/hall/)
await expect(page.locator('.hall-page')).toBeVisible()
await page.locator('.room-actions-footer .primary-btn').click()
await expect(page.locator('.modal-card')).toBeVisible()
await page.locator('.modal-card .field input').first().fill(user.roomName)
await page.locator('.modal-card .modal-actions .primary-btn').click()
await expect(page.locator('.copy-line')).toHaveCount(2)
await page.locator('.modal-card .modal-actions .primary-btn').click()
await page.waitForURL(/\/game\/chengdu\//)
await expect(page.locator('.game-page')).toBeVisible()
await expect(page.locator('.table-felt')).toBeVisible()
await page.locator('.topbar-back-btn').click()
await page.waitForURL(/\/hall/)
await expect(page.locator('.hall-page')).toBeVisible()
})

View File

@@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => {
plugins: [vue()], plugins: [vue()],
server: { server: {
proxy: { proxy: {
'/api/v1/ws': { '/ws': {
target: wsProxyTarget, target: wsProxyTarget,
changeOrigin: true, changeOrigin: true,
ws: true, ws: true,