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; 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 { .game-header h1 {
font-size: 28px; font-size: 28px;
font-weight: 800; font-weight: 800;
@@ -467,9 +500,9 @@ button:disabled {
} }
.game-header .sub-title { .game-header .sub-title {
margin-top: 6px; margin-top: 4px;
color: #d7eadf; color: #d7eadf;
font-size: 13px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -1195,6 +1228,10 @@ button:disabled {
justify-items: stretch; justify-items: stretch;
} }
.topbar-left {
flex-wrap: wrap;
}
.topbar-center, .topbar-center,
.topbar-right { .topbar-right {
justify-content: flex-start; 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 rightBackImage from '../assets/images/tiles/right/tbgs_1.png'
import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png' import bottomBackImage from '../assets/images/tiles/bottom/tdbgs_4.png'
import leftBackImage from '../assets/images/tiles/left/tbgs_3.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 type { AuthSession } from '../api/authed-request'
import { refreshAccessToken } from '../api/auth' import { refreshAccessToken } from '../api/auth'
import { getUserInfo } from '../api/user' import { getUserInfo } from '../api/user'
import type { SeatPlayerCardModel } from '../components/game/seat-player-card'
import { import {
DEFAULT_MAX_PLAYERS, DEFAULT_MAX_PLAYERS,
activeRoomState, 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 scoreMap = roomState.value.game?.state?.scores ?? {}
const dealerIndex = roomState.value.game?.state?.dealerIndex ?? -1 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 playerId = seat.player?.playerId ?? ''
const score = playerId ? scoreMap[playerId] : undefined const score = playerId ? scoreMap[playerId] : undefined
return { acc[seat.key] = {
seat: seat.key, avatar: seat.isSelf ? '我' : String(index + 1),
avatar: seat.isSelf ? '' : String(index + 1), name: seat.player ? (seat.isSelf ? '' : playerId) : '空位',
name: seat.player ? (seat.isSelf ? '你' : playerId) : '空位', money: typeof score === 'number' ? `${score}` : '--',
money: typeof score === 'number' ? `${score}` : '--', dealer: seat.player?.index === dealerIndex,
dealer: seat.player?.index === dealerIndex, isTurn: seat.isTurn,
isTurn: seat.isTurn, isOnline: Boolean(seat.player),
isOnline: Boolean(seat.player), missingSuitLabel: missingSuitLabel(null),
missingSuit: null as string | 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(() => { const centerTimer = computed(() => {
@@ -905,14 +951,15 @@ onBeforeUnmount(() => {
<template> <template>
<section class="hall-page game-page"> <section class="hall-page game-page">
<header class="hall-header game-header"> <header class="hall-header game-header">
<div> <div class="topbar-left">
<h1>成都麻将对局</h1> <button class="ghost-btn topbar-back-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
<p v-if="loggedInUserName" class="sub-title">玩家{{ loggedInUserName }}</p>
</div>
<div class="header-actions">
<button class="ghost-btn" type="button" :disabled="leaveRoomPending" @click="backHall">
{{ leaveRoomPending ? '退出中...' : '返回大厅' }} {{ leaveRoomPending ? '退出中...' : '返回大厅' }}
</button> </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>
<div class="topbar-center"> <div class="topbar-center">
@@ -972,20 +1019,10 @@ onBeforeUnmount(() => {
<small>底注 6 · 封顶 32 </small> <small>底注 6 · 封顶 32 </small>
</div> </div>
<article <TopPlayerCard :player="seatDecor.top" />
v-for="player in seatDecor" <RightPlayerCard :player="seatDecor.right" />
:key="player.seat" <BottomPlayerCard :player="seatDecor.bottom" />
class="player-badge" <LeftPlayerCard :player="seatDecor.left" />
: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>
<div class="wall wall-top"> <div class="wall wall-top">
<img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt="" /> <img v-for="key in wallBacks.top" :key="key" :src="getBackImage('top')" alt="" />