feat: 添加房间侧边栏和操作按钮组件

This commit is contained in:
2026-06-23 11:13:19 +08:00
parent 6c7d1ce128
commit c3c26f2935
2 changed files with 346 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
<template>
<div class="room-actions">
<!-- 等待中准备/取消准备按钮 -->
<template v-if="store.status === 'waiting' && store.isPlayer">
<button
class="room-actions__btn"
:class="isReady ? 'room-actions__btn--cancel' : 'room-actions__btn--ready'"
@click="toggleReady"
>
{{ isReady ? '取消准备' : '准备' }}
</button>
<button
v-if="store.isOwner"
class="room-actions__btn room-actions__btn--start"
@click="startGame"
>
开始游戏
</button>
</template>
<!-- 对局中认输求和 -->
<template v-if="store.isPlaying && store.isPlayer">
<button class="room-actions__btn room-actions__btn--danger" @click="resign">
认输
</button>
<button class="room-actions__btn room-actions__btn--warn" @click="requestDraw">
求和
</button>
</template>
<!-- 离开房间始终显示 -->
<button class="room-actions__btn room-actions__btn--leave" @click="leaveRoom">
离开房间
</button>
</div>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { useRouter } from 'vue-router'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import type { RoomSocket } from '@/common/websocket/roomSocket'
const store = useRoomStore()
const { players, currentUserId } = storeToRefs(store)
const router = useRouter()
/** 通过 inject 获取 WS 连接(父组件 provide */
const socket = inject<RoomSocket>('roomSocket')!
/** 当前用户是否已准备 */
const isReady = computed(() => {
const me = players.value.find((p) => p.userId === currentUserId.value)
return me?.ready ?? false
})
function toggleReady() {
socket.send('ready')
}
function startGame() {
socket.send('start')
}
function resign() {
socket.send('resign')
}
function requestDraw() {
socket.send('draw_request')
}
function leaveRoom() {
socket.send('leave')
socket.close()
router.push('/')
}
</script>
<style scoped lang="scss">
.room-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
padding-top: 16px;
}
.room-actions__btn {
width: 100%;
padding: 10px 0;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
&--ready {
background-color: var(--color-primary);
color: var(--text-inverse);
&:hover { background-color: var(--color-primary-hover); }
}
&--cancel {
background-color: var(--bg-surface-hover);
color: var(--text-secondary);
&:hover { background-color: var(--border-default); }
}
&--start {
background-color: var(--color-success);
color: white;
&:hover { opacity: 0.9; }
}
&--danger {
background-color: transparent;
color: var(--color-danger);
border: 1px solid var(--color-danger);
&:hover { background-color: var(--color-danger); color: white; }
}
&--warn {
background-color: transparent;
color: var(--color-warning);
border: 1px solid var(--color-warning);
&:hover { background-color: var(--color-warning); color: white; }
}
&--leave {
background-color: transparent;
color: var(--text-tertiary);
border: 1px solid var(--border-default);
&:hover { border-color: var(--text-secondary); color: var(--text-primary); }
}
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<div class="room-side-panel">
<!-- 对手区域非当前用户的第一个玩家 -->
<div v-if="opponent" class="room-side-panel__player-card">
<img :src="getAvatarUrl(opponent.avatar)" class="room-side-panel__avatar" />
<div class="room-side-panel__player-info">
<div class="room-side-panel__nickname">
{{ opponent.nickname }}
<span v-if="opponent.userId === store.creatorId" class="room-side-panel__crown">👑</span>
</div>
<div class="room-side-panel__role">
{{ opponentReady ? '✅ 已准备' : '⏳ 未准备' }}
</div>
</div>
</div>
<!-- 对局信息 -->
<div v-if="store.isPlaying || store.isFinished" class="room-side-panel__game-info">
<div class="room-side-panel__info-row">
<span>步数</span>
<span>{{ store.moves.length }}</span>
</div>
<div class="room-side-panel__info-row">
<span>当前轮次</span>
<span :class="{ 'room-side-panel__turn--me': isMyTurn }">
{{ isMyTurn ? '轮到你了' : '等待对手' }}
</span>
</div>
</div>
<!-- 我的信息 -->
<div v-if="me" class="room-side-panel__player-card room-side-panel__player-card--me">
<img :src="getAvatarUrl(me.avatar)" class="room-side-panel__avatar" />
<div class="room-side-panel__player-info">
<div class="room-side-panel__nickname">
{{ me.nickname }}
<span v-if="me.userId === store.creatorId" class="room-side-panel__crown">👑</span>
</div>
<div class="room-side-panel__role">
{{ meReady ? '✅ 已准备' : '⏳ 未准备' }}
</div>
</div>
</div>
<!-- 观战提示 -->
<div v-if="!store.isPlayer" class="room-side-panel__spectator-note">
👀 观战中
</div>
<!-- 对局结果 -->
<div v-if="store.isFinished && store.resultType" class="room-side-panel__result">
<div v-if="store.resultType === 'draw'" class="room-side-panel__result-text">
🤝 平局
</div>
<div
v-else
class="room-side-panel__result-text"
:class="isWinner ? 'room-side-panel__result-text--win' : 'room-side-panel__result-text--lose'"
>
{{ isWinner ? '🎉 你赢了!' : '💀 你输了' }}
</div>
</div>
<!-- 操作按钮组 -->
<RoomActions />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoomStore } from '@/stores'
import { storeToRefs } from 'pinia'
import { getAvatarUrl } from '@/utils'
import RoomActions from './RoomActions.vue'
const store = useRoomStore()
const { players, currentUserId, currentTurn, winner } = storeToRefs(store)
/** 当前玩家自己 */
const me = computed(() => players.value.find((p) => p.userId === currentUserId.value) ?? null)
/** 对手(非当前用户的第一个玩家) */
const opponent = computed(() => players.value.find((p) => p.userId !== currentUserId.value) ?? null)
/** 当前用户是否已准备 */
const meReady = computed(() => me.value?.ready ?? false)
const opponentReady = computed(() => opponent.value?.ready ?? false)
/** 当前是否轮到我了 */
const isMyTurn = computed(() => currentTurn.value === currentUserId.value)
/** 当前用户是否赢了 */
const isWinner = computed(() => winner.value === currentUserId.value)
</script>
<style scoped lang="scss">
.room-side-panel {
display: flex;
flex-direction: column;
width: 240px;
padding: 16px;
gap: 16px;
background-color: var(--bg-sidebar);
border-right: 1px solid var(--border-default);
flex-shrink: 0;
overflow-y: auto;
}
.room-side-panel__player-card {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
background-color: var(--bg-surface);
border-radius: 10px;
border: 1px solid var(--border-light);
&--me {
border-color: var(--color-primary-light);
background-color: var(--color-primary-light);
}
}
.room-side-panel__avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
.room-side-panel__player-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.room-side-panel__nickname {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
}
.room-side-panel__crown {
font-size: 12px;
}
.room-side-panel__role {
font-size: 12px;
color: var(--text-secondary);
}
.room-side-panel__game-info {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background-color: var(--bg-surface);
border-radius: 8px;
border: 1px solid var(--border-light);
}
.room-side-panel__info-row {
display: flex;
justify-content: space-between;
font-size: 13px;
span:first-child {
color: var(--text-secondary);
}
span:last-child {
font-weight: 600;
color: var(--text-primary);
}
}
.room-side-panel__turn--me {
color: var(--color-primary) !important;
animation: pulse 1.5s ease-in-out infinite;
}
.room-side-panel__spectator-note {
text-align: center;
font-size: 14px;
color: var(--text-tertiary);
padding: 12px;
}
.room-side-panel__result {
text-align: center;
padding: 12px;
}
.room-side-panel__result-text {
font-size: 18px;
font-weight: 700;
&--win {
color: var(--color-success);
}
&--lose {
color: var(--color-danger);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>