```
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:
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
53
tests/e2e/chengdu-flow.spec.ts
Normal file
53
tests/e2e/chengdu-flow.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user