diff --git a/src/assets/images/icons/cancel.png b/src/assets/images/icons/cancel.png
new file mode 100755
index 0000000..f4bfb58
Binary files /dev/null and b/src/assets/images/icons/cancel.png differ
diff --git a/src/assets/images/icons/read.png b/src/assets/images/icons/read.png
new file mode 100755
index 0000000..95a0b92
Binary files /dev/null and b/src/assets/images/icons/read.png differ
diff --git a/src/assets/styles/room.css b/src/assets/styles/room.css
index d86828c..5f512f3 100644
--- a/src/assets/styles/room.css
+++ b/src/assets/styles/room.css
@@ -429,6 +429,22 @@
color: #eef5ff;
}
+.picture-scene .player-meta .ready-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 4px;
+ padding: 1px 8px;
+ border-radius: 999px;
+ color: #f7f2dd;
+ font-size: 10px;
+ font-weight: 700;
+ background: linear-gradient(180deg, rgba(214, 173, 58, 0.82), rgba(125, 91, 20, 0.92));
+ box-shadow:
+ inset 0 1px 0 rgba(255, 255, 255, 0.22),
+ 0 2px 6px rgba(0, 0, 0, 0.2);
+}
+
.picture-scene .player-badge.seat-right .player-meta,
.picture-scene .player-badge.seat-left .player-meta {
display: flex;
@@ -575,6 +591,57 @@
pointer-events: none;
}
+.ready-toggle {
+ position: absolute;
+ right: 120px;
+ bottom: 70px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ min-width: 138px;
+ height: 44px;
+ padding: 0 16px 0 14px;
+ border: 1px solid rgba(220, 191, 118, 0.24);
+ border-radius: 999px;
+ background:
+ linear-gradient(180deg, rgba(14, 55, 40, 0.92), rgba(8, 36, 27, 0.96)),
+ radial-gradient(circle at 20% 24%, rgba(237, 214, 157, 0.08), transparent 34%);
+ box-shadow:
+ inset 0 1px 0 rgba(255, 244, 214, 0.1),
+ inset 0 -1px 0 rgba(0, 0, 0, 0.22),
+ 0 8px 18px rgba(0, 0, 0, 0.2);
+ z-index: 4;
+ animation: ready-toggle-pop 180ms ease-out;
+}
+
+.ready-toggle-label {
+ color: #e5c472;
+ font-size: 14px;
+ font-weight: 800;
+ letter-spacing: 0.5px;
+ text-shadow:
+ -1px 0 rgba(0, 0, 0, 0.38),
+ 0 1px rgba(0, 0, 0, 0.38),
+ 1px 0 rgba(0, 0, 0, 0.38),
+ 0 -1px rgba(0, 0, 0, 0.38);
+}
+
+.ready-toggle:active {
+ transform: translateY(1px) scale(0.96);
+}
+
+@keyframes ready-toggle-pop {
+ from {
+ opacity: 0;
+ transform: translateY(10px) scale(0.94);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
.center-desk {
position: absolute;
left: 50%;
diff --git a/src/components/game/SeatPlayerCard.vue b/src/components/game/SeatPlayerCard.vue
index 3d4e552..627fc4f 100644
--- a/src/components/game/SeatPlayerCard.vue
+++ b/src/components/game/SeatPlayerCard.vue
@@ -43,6 +43,7 @@ const resolvedAvatarUrl = computed(() => {
diff --git a/src/components/game/seat-player-card.ts b/src/components/game/seat-player-card.ts
index a77f893..6be38c6 100644
--- a/src/components/game/seat-player-card.ts
+++ b/src/components/game/seat-player-card.ts
@@ -4,5 +4,6 @@ export interface SeatPlayerCardModel {
name: string // 显示名称
dealer: boolean // 是否庄家
isTurn: boolean // 是否当前轮到该玩家
+ isReady: boolean // 是否已准备
missingSuitLabel: string // 定缺花色(万/筒/条)
}
diff --git a/src/views/ChengduGamePage.vue b/src/views/ChengduGamePage.vue
index adf2bf8..9c07271 100644
--- a/src/views/ChengduGamePage.vue
+++ b/src/views/ChengduGamePage.vue
@@ -23,9 +23,11 @@ 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 type {GameAction, RoomPlayerUpdatePayload} from '../game/actions'
import {dispatchGameAction} from '../game/dispatcher'
import {refreshAccessToken} from '../api/auth'
+import {AuthExpiredError, type AuthSession} from '../api/authed-request'
+import {getUserInfo} from '../api/user'
import {clearAuth, readStoredAuth, writeStoredAuth} from '../utils/auth-storage'
import type {WsStatus} from '../ws/client'
import {wsClient} from '../ws/client'
@@ -62,6 +64,7 @@ const wsMessages = ref
([])
const wsError = ref('')
const selectedTile = ref(null)
const leaveRoomPending = ref(false)
+const readyTogglePending = ref(false)
let clockTimer: number | null = null
let unsubscribe: (() => void) | null = null
@@ -74,8 +77,13 @@ let refreshingWsToken = false
let lastForcedRefreshAt = 0
const loggedInUserId = computed(() => {
- const rawId = auth.value?.user?.id
- if (typeof rawId === 'string') {
+ const source = auth.value?.user as Record | undefined
+ const rawId =
+ source?.id ??
+ source?.userID ??
+ source?.user_id
+
+ if (typeof rawId === 'string' && rawId.trim()) {
return rawId
}
if (typeof rawId === 'number') {
@@ -170,7 +178,7 @@ const seatViews = computed(() => {
const currentTurn = gameStore.currentTurn
return players.slice(0, 4).map((player) => {
- const relativeIndex = (player.seatIndex - selfSeatIndex + 4) % 4
+ const relativeIndex = (selfSeatIndex - player.seatIndex + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
return {
key: seatKey,
@@ -195,7 +203,7 @@ const seatWinds = computed>(() => {
}
for (let absoluteSeat = 0; absoluteSeat < 4; absoluteSeat += 1) {
- const relativeIndex = (absoluteSeat - selfSeatIndex + 4) % 4
+ const relativeIndex = (selfSeatIndex - absoluteSeat + 4) % 4
const seatKey = tableOrder[relativeIndex] ?? 'top'
result[seatKey] = directionBySeatIndex[absoluteSeat] ?? northWind
}
@@ -226,6 +234,56 @@ const roomStatusText = computed(() => {
return map[status] ?? status ?? '--'
})
+const myReadyState = computed(() => {
+ return Boolean(myPlayer.value?.isReady)
+})
+
+const showReadyToggle = computed(() => {
+ return gameStore.phase === 'waiting' && Boolean(gameStore.roomId)
+})
+
+function applyPlayerReadyState(playerId: string, ready: boolean): void {
+ const player = gameStore.players[playerId]
+ if (player) {
+ player.isReady = ready
+ }
+
+ const room = activeRoom.value
+ if (!room || room.roomId !== gameStore.roomId) {
+ return
+ }
+
+ const roomPlayer = room.players.find((item) => item.playerId === playerId)
+ if (roomPlayer) {
+ roomPlayer.ready = ready
+ }
+}
+
+function syncReadyStatesFromRoomUpdate(payload: RoomPlayerUpdatePayload): void {
+ if (!Array.isArray(payload.players)) {
+ return
+ }
+
+ for (const item of payload.players) {
+ const playerId =
+ (typeof item.PlayerID === 'string' && item.PlayerID) ||
+ (typeof item.player_id === 'string' && item.player_id) ||
+ ''
+ const ready =
+ typeof item.Ready === 'boolean'
+ ? item.Ready
+ : typeof item.ready === 'boolean'
+ ? item.ready
+ : undefined
+
+ if (!playerId || typeof ready !== 'boolean') {
+ continue
+ }
+
+ applyPlayerReadyState(playerId, ready)
+ }
+}
+
const networkLabel = computed(() => {
const map: Record = {
connected: '已连接',
@@ -268,6 +326,7 @@ const seatDecor = computed>(() => {
name: '空位',
dealer: false,
isTurn: false,
+ isReady: false,
missingSuitLabel: defaultMissingSuitLabel,
})
@@ -294,6 +353,7 @@ const seatDecor = computed>(() => {
name: Array.from(seat.isSelf ? selfDisplayName : displayName).slice(0, 4).join(''),
dealer: seat.player.seatIndex === dealerIndex,
isTurn: seat.isTurn,
+ isReady: Boolean(seat.player.isReady),
missingSuitLabel: missingSuitLabel(seat.player.missingSuit),
}
}
@@ -439,6 +499,54 @@ function toGameAction(message: unknown): GameAction | null {
}
}
+function handleReadyStateResponse(message: unknown): void {
+ if (!message || typeof message !== 'object') {
+ return
+ }
+
+ const source = message as Record
+ if (typeof source.type !== 'string') {
+ return
+ }
+
+ const type = source.type.replace(/[-\s]/g, '_').toUpperCase()
+ if (type !== 'SET_READY') {
+ return
+ }
+
+ const payload = source.payload
+ if (!payload || typeof payload !== 'object') {
+ return
+ }
+
+ const readyPayload = payload as Record
+ const roomId =
+ typeof readyPayload.room_id === 'string'
+ ? readyPayload.room_id
+ : typeof source.roomId === 'string'
+ ? source.roomId
+ : ''
+ const userId =
+ typeof readyPayload.user_id === 'string'
+ ? readyPayload.user_id
+ : typeof source.target === 'string'
+ ? source.target
+ : ''
+ const ready = readyPayload.ready
+
+ if (roomId && roomId !== gameStore.roomId) {
+ return
+ }
+
+ if (typeof ready === 'boolean' && userId) {
+ applyPlayerReadyState(userId, ready)
+ }
+
+ if (userId && userId === loggedInUserId.value) {
+ readyTogglePending.value = false
+ }
+}
+
function logoutToLogin(): void {
clearAuth()
auth.value = null
@@ -446,6 +554,74 @@ function logoutToLogin(): void {
void router.replace('/login')
}
+function currentSession(): AuthSession | null {
+ const current = auth.value
+ if (!current?.token) {
+ return null
+ }
+
+ return {
+ token: current.token,
+ tokenType: current.tokenType,
+ refreshToken: current.refreshToken,
+ expiresIn: current.expiresIn,
+ }
+}
+
+function syncAuthSession(next: AuthSession): void {
+ if (!auth.value) {
+ return
+ }
+
+ auth.value = {
+ ...auth.value,
+ token: next.token,
+ tokenType: next.tokenType ?? auth.value.tokenType,
+ refreshToken: next.refreshToken ?? auth.value.refreshToken,
+ expiresIn: next.expiresIn,
+ }
+ writeStoredAuth(auth.value)
+}
+
+async function ensureCurrentUserLoaded(): Promise {
+ if (loggedInUserId.value) {
+ return
+ }
+
+ const currentAuth = auth.value
+ const session = currentSession()
+ if (!session) {
+ return
+ }
+
+ try {
+ const userInfo = await getUserInfo(session, syncAuthSession)
+ const resolvedId = userInfo.userID ?? userInfo.user_id ?? userInfo.id ?? currentAuth?.user?.id
+ const nextUser = {
+ ...(currentAuth?.user ?? {}),
+ ...userInfo,
+ id:
+ typeof resolvedId === 'string' || typeof resolvedId === 'number'
+ ? resolvedId
+ : undefined,
+ }
+
+ if (!currentAuth) {
+ return
+ }
+
+ auth.value = {
+ ...currentAuth,
+ user: nextUser,
+ }
+ writeStoredAuth(auth.value)
+ } catch (error) {
+ if (error instanceof AuthExpiredError) {
+ logoutToLogin()
+ }
+ }
+}
+
function decodeJwtExpMs(token: string): number | null {
const parts = token.split('.')
const payloadPart = parts[1]
@@ -557,6 +733,27 @@ function backHall(): void {
})
}
+function toggleReadyState(): void {
+ if (readyTogglePending.value) {
+ return
+ }
+
+ const nextReady = !myReadyState.value
+ readyTogglePending.value = true
+ console.log('[ready-toggle]', {
+ loggedInUserId: loggedInUserId.value,
+ myReadyState: myReadyState.value,
+ nextReady,
+ })
+ sendWsMessage({
+ type: 'set_ready',
+ roomId: gameStore.roomId,
+ payload: {
+ ready: nextReady,
+ },
+ })
+}
+
function handleLeaveRoom(): void {
menuOpen.value = false
backHall()
@@ -627,10 +824,12 @@ function hydrateFromActiveRoom(routeRoomId: string): void {
onMounted(() => {
const routeRoomId = typeof route.params.roomId === 'string' ? route.params.roomId : ''
- hydrateFromActiveRoom(routeRoomId)
- if (routeRoomId) {
- gameStore.roomId = routeRoomId
- }
+ void ensureCurrentUserLoaded().finally(() => {
+ hydrateFromActiveRoom(routeRoomId)
+ if (routeRoomId) {
+ gameStore.roomId = routeRoomId
+ }
+ })
const handler = (status: WsStatus) => {
wsStatus.value = status
@@ -639,9 +838,14 @@ onMounted(() => {
wsClient.onMessage((msg: unknown) => {
const text = typeof msg === 'string' ? msg : JSON.stringify(msg)
wsMessages.value.push(`[server] ${text}`)
+ handleReadyStateResponse(msg)
const gameAction = toGameAction(msg)
if (gameAction) {
dispatchGameAction(gameAction)
+ if (gameAction.type === 'ROOM_PLAYER_UPDATE') {
+ syncReadyStatesFromRoomUpdate(gameAction.payload)
+ readyTogglePending.value = false
+ }
}
})
wsClient.onError((message: string) => {
@@ -796,6 +1000,16 @@ onBeforeUnmount(() => {
+
+