feat(game): add player cards and topbar styling for Chengdu Mahjong game

- Add new CSS classes for topbar layout including .topbar-left,
  .topbar-back-btn, .topbar-room-meta, .eyebrow, and .topbar-room-name
- Create dedicated player card components for each seat position
  (top, right, bottom, left)
- Refactor seatDecor computed property to use SeatPlayerCardModel
  interface with proper typing
- Replace inline player badge rendering with reusable player card
  components
- Update game header layout to use new topbar structure with
  back button and room metadata
- Adjust spacing and font sizes in game header elements
```
This commit is contained in:
2026-03-24 14:02:21 +08:00
parent d4e217b11b
commit f97f1ffdbc
8 changed files with 189 additions and 35 deletions

View File

@@ -459,6 +459,39 @@ button:disabled {
min-width: 0;
}
.topbar-left {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.topbar-back-btn {
flex: 0 0 auto;
min-width: 108px;
}
.topbar-room-meta {
min-width: 0;
}
.eyebrow {
color: #f7e4b0;
font-size: 12px;
letter-spacing: 2px;
text-transform: uppercase;
}
.topbar-room-name {
margin-top: 4px;
color: #f6edd5;
font-size: 20px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.game-header h1 {
font-size: 28px;
font-weight: 800;
@@ -467,9 +500,9 @@ button:disabled {
}
.game-header .sub-title {
margin-top: 6px;
margin-top: 4px;
color: #d7eadf;
font-size: 13px;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -1195,6 +1228,10 @@ button:disabled {
justify-items: stretch;
}
.topbar-left {
flex-wrap: wrap;
}
.topbar-center,
.topbar-right {
justify-content: flex-start;

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-bottom" :player="player" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-left" :player="player" />
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-right" :player="player" />
</template>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
seatClass: string
player: SeatPlayerCardModel
}>()
</script>
<template>
<article
class="player-badge"
:class="[seatClass, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
>
<div class="avatar-card">{{ player.avatar }}</div>
<div class="player-meta">
<p>{{ player.name }}</p>
<strong>{{ player.money }}</strong>
</div>
<span v-if="player.dealer" class="dealer-mark"></span>
<span class="missing-mark">{{ player.missingSuitLabel }}</span>
</article>
</template>

View File

@@ -0,0 +1,12 @@
<script setup lang="ts">
import SeatPlayerCard from './SeatPlayerCard.vue'
import type { SeatPlayerCardModel } from './seat-player-card'
defineProps<{
player: SeatPlayerCardModel
}>()
</script>
<template>
<SeatPlayerCard seat-class="seat-top" :player="player" />
</template>

View File

@@ -0,0 +1,9 @@
export interface SeatPlayerCardModel {
avatar: string
name: string
money: string
dealer: boolean
isTurn: boolean
isOnline: boolean
missingSuitLabel: string
}

View File

@@ -6,9 +6,14 @@ import topBackImage from '../assets/images/tiles/top/tbgs_2.png'
import rightBackImage from '../assets/images/tiles/right/tbgs_1.png'
import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png'
import leftBackImage from '../assets/images/tiles/left/tbgs_3.png'
import TopPlayerCard from '../components/game/TopPlayerCard.vue'
import RightPlayerCard from '../components/game/RightPlayerCard.vue'
import BottomPlayerCard from '../components/game/BottomPlayerCard.vue'
import LeftPlayerCard from '../components/game/LeftPlayerCard.vue'
import type { AuthSession } from '../api/authed-request'
import { refreshAccessToken } from '../api/auth'
import { getUserInfo } from '../api/user'
import type { SeatPlayerCardModel } from '../components/game/seat-player-card'
import {
DEFAULT_MAX_PLAYERS,
activeRoomState,
@@ -213,25 +218,66 @@ const wallBacks = computed<Record<SeatKey, string[]>>(() => {
}
})
const seatDecor = computed(() => {
const seatDecor = computed<Record<SeatKey, SeatPlayerCardModel>>(() => {
const scoreMap = roomState.value.game?.state?.scores ?? {}
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1
return seatViews.value.map((seat, index) => {
return seatViews.value.reduce(
(acc, seat, index) => {
const playerId = seat.player?.playerId ?? ''
const score = playerId ? scoreMap[playerId] : undefined
return {
seat: 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),
missingSuit: null as string | null,
}
})
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: missingSuitLabel(null),
}
return acc
},
{
top: {
avatar: '1',
name: '空位',
money: '--',
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
},
right: {
avatar: '2',
name: '空位',
money: '--',
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
},
bottom: {
avatar: '我',
name: '空位',
money: '--',
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
},
left: {
avatar: '4',
name: '空位',
money: '--',
dealer: false,
isTurn: false,
isOnline: false,
missingSuitLabel: missingSuitLabel(null),
},
},
)
})
const centerTimer = computed(() => {
@@ -905,14 +951,15 @@ onBeforeUnmount(() => {
<template>
<section class="hall-page game-page">
<header class="hall-header game-header">
<div>
<h1>成都麻将对局</h1>
<p v-if="loggedInUserName" class="sub-title">玩家{{ loggedInUserName }}</p>
</div>
<div class="header-actions">
<button class="ghost-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
<div class="topbar-left">
<button class="ghost-btn topbar-back-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
{{ leaveRoomPending ? '退出中...' : '返回大厅' }}
</button>
<div class="topbar-room-meta">
<p class="eyebrow">成都麻将对局</p>
<p class="topbar-room-name">{{ roomState.name || roomName || '未命名房间' }}</p>
<p v-if="loggedInUserName" class="sub-title">玩家{{ loggedInUserName }}</p>
</div>
</div>
<div class="topbar-center">
@@ -972,20 +1019,10 @@ onBeforeUnmount(() => {
<small>底注 6 · 封顶 32 </small>
</div>
<article
v-for="player in seatDecor"
:key="player.seat"
class="player-badge"
:class="[`seat-${player.seat}`, { 'is-turn': player.isTurn, offline: !player.isOnline }]"
>
<div class="avatar-card">{{ player.avatar }}</div>
<div class="player-meta">
<p>{{ player.name }}</p>
<strong>{{ player.money }}</strong>
</div>
<span v-if="player.dealer" class="dealer-mark"></span>
<span class="missing-mark">{{ missingSuitLabel(player.missingSuit) }}</span>
</article>
<TopPlayerCard :player="seatDecor.top" />
<RightPlayerCard :player="seatDecor.right" />
<BottomPlayerCard :player="seatDecor.bottom" />
<LeftPlayerCard :player="seatDecor.left" />
<div class="wall wall-top">
<img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt="" />