diff --git a/.env.development b/.env.development index cc48875..562a716 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ 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_WS_PROXY_TARGET=http://127.0.0.1:19000 diff --git a/package.json b/package.json index 46d4be0..22e2068 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "vite", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test" }, "dependencies": { "vue": "^3.5.25", diff --git a/src/assets/styles/global.css b/src/assets/styles/global.css index 1d22c72..81e0ee8 100644 --- a/src/assets/styles/global.css +++ b/src/assets/styles/global.css @@ -1,3 +1,10 @@ +html, +body, +#app { + height: 100%; + overflow: hidden; +} + :root { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; line-height: 1.5; @@ -21,6 +28,7 @@ body { #app { min-height: 100vh; + min-height: 100dvh; } h1, @@ -31,6 +39,8 @@ p { .app-shell { min-height: 100vh; + min-height: 100dvh; + overflow: hidden; } .auth-page { @@ -429,7 +439,9 @@ button:disabled { width: 100%; max-width: none; height: 100vh; + height: 100dvh; min-height: 100vh; + min-height: 100dvh; margin: 0; padding-top: max(12px, env(safe-area-inset-top)); padding-right: max(12px, env(safe-area-inset-right)); @@ -647,18 +659,23 @@ button:disabled { grid-template-columns: minmax(0, 1fr) 320px; gap: 12px; min-height: 0; + height: 100%; overflow: hidden; - align-items: stretch; + align-items: center; } .table-desk { display: block; grid-column: 1; grid-row: 1; - width: 100%; - height: 100%; + width: min(100%, calc((100dvh - 220px) * 16 / 9)); + max-width: 100%; + height: auto; + aspect-ratio: 16 / 9; + justify-self: center; + align-self: center; border-radius: 28px; - object-fit: cover; + object-fit: contain; object-position: center; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), @@ -670,7 +687,13 @@ button:disabled { grid-row: 1; position: relative; 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: 1px solid rgba(255, 255, 255, 0.06); background: @@ -1033,6 +1056,7 @@ button:disabled { display: flex; flex-direction: column; min-height: 0; + height: 100%; margin-top: 0; padding: 10px; border-radius: 8px; @@ -1041,6 +1065,10 @@ button:disabled { overflow: hidden; } +.ws-log { + min-height: 0; +} + .ws-panel-head { display: flex; align-items: center; diff --git a/src/features/chengdu-game/useChengduGameRoom.ts b/src/features/chengdu-game/useChengduGameRoom.ts index cb903e4..db8be98 100644 --- a/src/features/chengdu-game/useChengduGameRoom.ts +++ b/src/features/chengdu-game/useChengduGameRoom.ts @@ -176,12 +176,15 @@ export function useChengduGameRoom( return } - leaveHallAfterAck.value = true const sent = sendLeaveRoom() if (!sent) { - leaveHallAfterAck.value = false pushWsMessage('[client] Leave room request was not sent') } + + leaveHallAfterAck.value = false + disconnectWs() + destroyActiveRoomState() + void router.push('/hall') } function pushWsMessage(text: string): void { diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue index db65540..04dc592 100644 --- a/src/views/ChengduGamePage.vue +++ b/src/views/ChengduGamePage.vue @@ -101,64 +101,62 @@ const wallBacks = computed>(() => { const seatDecor = computed>(() => { const scoreMap = roomState.value.game?.state?.scores ?? {} const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1 - const emptyLabel = missingSuitLabel(null) + const defaultMissingSuitLabel = missingSuitLabel(null) - return seatViews.value.reduce( - (acc, seat, index) => { - const playerId = seat.player?.playerId ?? '' - const score = playerId ? scoreMap[playerId] : undefined + const emptySeat = (avatar: string): SeatPlayerCardModel => ({ + avatar, + name: '空位', + money: '--', + dealer: false, + isTurn: false, + isOnline: false, + missingSuitLabel: defaultMissingSuitLabel, + }) - acc[seat.key] = { - avatar: seat.isSelf ? '我' : String(index + 1), - name: seat.player ? (seat.isSelf ? '你' : playerId) : '空位', - money: typeof score === 'number' ? `${score}` : '--', - dealer: seat.player?.index === dealerIndex, - isTurn: seat.isTurn, - isOnline: Boolean(seat.player), - missingSuitLabel: emptyLabel, - } + const result: Record = { + top: emptySeat('1'), + right: emptySeat('2'), + bottom: emptySeat('我'), + left: emptySeat('4'), + } - return acc - }, - { - top: { - avatar: '1', - name: '空位', - money: '--', - dealer: false, - isTurn: false, - isOnline: false, - missingSuitLabel: emptyLabel, - }, - right: { - avatar: '2', - name: '空位', - money: '--', - dealer: false, - isTurn: false, - isOnline: false, - missingSuitLabel: emptyLabel, - }, - bottom: { - avatar: '我', - name: '空位', - money: '--', - dealer: false, - isTurn: false, - isOnline: false, - missingSuitLabel: emptyLabel, - }, - left: { - avatar: '4', - name: '空位', - money: '--', - dealer: false, - isTurn: false, - isOnline: false, - missingSuitLabel: emptyLabel, - }, - }, - ) + for (const [index, seat] of seatViews.value.entries()) { + if (!seat.player) { + continue + } + + const playerId = seat.player.playerId + const score = scoreMap[playerId] + result[seat.key] = { + avatar: seat.isSelf ? '我' : String(index + 1), + name: seat.isSelf ? '你自己' : playerId, + money: typeof score === 'number' ? String(score) : '--', + dealer: seat.player.index === dealerIndex, + isTurn: seat.isTurn, + isOnline: true, + missingSuitLabel: defaultMissingSuitLabel, + } + } + + return result +}) + +const seatMarkers = computed(() => { + const seatTitleMap: Record = { + top: '上家', + right: '右家', + bottom: '本家', + left: '左家', + } + + return seatViews.value.map((seat) => ({ + key: seat.key, + occupied: Boolean(seat.player), + isSelf: seat.isSelf, + isTurn: seat.isTurn, + label: seat.player ? seatTitleMap[seat.key] : '空位', + subLabel: seat.player ? `座位 ${seat.player.index}` : '', + })) }) const centerTimer = computed(() => { @@ -174,7 +172,7 @@ const centerTimer = computed(() => { function missingSuitLabel(value: string | null | undefined): string { if (!value) { - return '待定' + return '未定' } const suitMap: Record = { @@ -231,6 +229,7 @@ onBeforeUnmount(() => {

{{ roomStatusText }} · {{ currentPhaseText }}

+
@@ -249,7 +248,7 @@ onBeforeUnmount(() => { {{ roomState.name || roomName || '未命名房间' }} - room_id: + room_id: {{ roomId || '未选择房间' }} @@ -276,6 +275,7 @@ onBeforeUnmount(() => {
+
{{ statusRibbon }} 指尖四川麻将 @@ -307,19 +307,21 @@ onBeforeUnmount(() => { {{ centerTimer }}
+
{{ seat.label }} {{ seat.subLabel }} 出牌中
+

成都麻将

{{ roomState.id || roomId || '等待中...' }}

@@ -330,7 +332,7 @@ onBeforeUnmount(() => {
实时消息
- {{ wsStatus }} + {{ networkLabel }}
diff --git a/tests/e2e/chengdu-flow.spec.ts b/tests/e2e/chengdu-flow.spec.ts new file mode 100644 index 0000000..990facc --- /dev/null +++ b/tests/e2e/chengdu-flow.spec.ts @@ -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() +}) diff --git a/vite.config.ts b/vite.config.ts index 9317c96..76fd7fc 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig(({ mode }) => { plugins: [vue()], server: { proxy: { - '/api/v1/ws': { + '/ws': { target: wsProxyTarget, changeOrigin: true, ws: true,