Files
mahjong-web/src/views/HallPage.vue
wsy182 e96c45739e feat(game): 添加成都麻将游戏页面和大厅功能
- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面
- 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能
- 添加 mahjong API 接口用于房间管理操作
- 集成 store 状态管理和本地存储功能
- 实现 ChengduBottomActions 等游戏控制组件
- 添加 websocket 连接和游戏会话管理逻辑
- 实现游戏倒计时、结算等功能模块
2026-04-03 20:46:50 +08:00

547 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { AuthExpiredError, type AuthSession } from '../api/authed-request'
import { createRoom, joinRoom, listRooms, type RoomItem } from '../api/mahjong'
import { getUserInfo, type UserInfo } from '../api/user'
import refreshIcon from '../assets/images/icons/refresh.svg'
import { setRoomMetaSnapshot } from '../store'
import type { RoomPlayerState } from '../store/state'
import type { StoredAuth } from '../types/session'
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
import { wsClient } from '../ws/client'
import { buildWsUrl } from '../ws/url'
const router = useRouter()
const roomLoading = ref(false)
const roomSubmitting = ref(false)
const rooms = ref<RoomItem[]>([])
const roomTotal = ref(0)
const errorMessage = ref('')
const successMessage = ref('')
const showCreateModal = ref(false)
const showCreatedModal = ref(false)
const createdRoom = ref<RoomItem | null>(null)
const userInfo = ref<UserInfo | null>(null)
const userInfoLoading = ref(false)
const showFullPhone = ref(false)
const createRoomForm = ref({
name: '',
gameType: 'chengdu',
maxPlayers: 4,
totalRounds: 8,
})
const quickJoinRoomId = ref('')
const auth = ref<StoredAuth | null>(readStoredAuth())
const inviteLink = computed(() => {
if (!createdRoom.value) {
return ''
}
const url = new URL(window.location.href)
url.searchParams.set('room_id', createdRoom.value.room_id)
return url.toString()
})
const displayName = computed(() => {
return (
(typeof userInfo.value?.nickname === 'string' && userInfo.value.nickname) ||
(typeof userInfo.value?.username === 'string' && userInfo.value.username) ||
auth.value?.user?.nickname ||
auth.value?.user?.username ||
'Guest'
)
})
const currentUserId = computed(() => {
const candidate =
userInfo.value?.userID ??
userInfo.value?.user_id ??
userInfo.value?.id ??
auth.value?.user?.id
if (typeof candidate === 'string') {
return candidate
}
if (typeof candidate === 'number') {
return String(candidate)
}
return ''
})
const displayPhone = computed(() => {
return typeof userInfo.value?.phone === 'string' ? userInfo.value.phone : ''
})
const displayUserId = computed(() => {
const candidate =
userInfo.value?.userID ??
userInfo.value?.user_id ??
userInfo.value?.id ??
auth.value?.user?.id
if (typeof candidate === 'string') {
return candidate
}
if (typeof candidate === 'number') {
return String(candidate)
}
return ''
})
const displayEmail = computed(() => {
return typeof userInfo.value?.email === 'string' ? userInfo.value.email : ''
})
const maskedPhone = computed(() => {
const phone = displayPhone.value
if (phone.length < 7) {
return phone
}
return `${phone.slice(0, 3)}****${phone.slice(-4)}`
})
function gameTypeLabel(type: string): string {
if (type === 'chengdu') {
return '四川麻将'
}
if (type === 'xueliu') {
return '血流成河'
}
return type
}
function isMyRoom(room: RoomItem): boolean {
return Boolean(currentUserId.value) && room.owner_id === currentUserId.value
}
function mapRoomPlayers(room: RoomItem): RoomPlayerState[] {
return (room.players ?? [])
.map((item, fallbackIndex) => ({
index: Number.isFinite(item.index) ? item.index : fallbackIndex,
playerId: item.player_id,
displayName:
(typeof item.player_name === 'string' && item.player_name) ||
(typeof item.PlayerName === 'string' && item.PlayerName) ||
(item.player_id === currentUserId.value ? displayName.value : undefined),
ready: Boolean(item.ready),
trustee: false,
hand: [],
melds: [],
outTiles: [],
hasHu: false,
}))
.filter((item) => Boolean(item.playerId))
}
function toSession(source: StoredAuth): AuthSession {
return {
token: source.token,
tokenType: source.tokenType,
refreshToken: source.refreshToken,
expiresIn: source.expiresIn,
}
}
function syncAuth(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)
}
function logoutToLogin(): void {
clearAuth()
auth.value = null
void router.replace('/login')
}
function currentSession(): AuthSession | null {
if (!auth.value) {
logoutToLogin()
return null
}
return toSession(auth.value)
}
function connectGameWs(): void {
const token = auth.value?.token
if (!token) {
return
}
wsClient.connect(buildWsUrl(), token)
}
async function refreshRooms(): Promise<void> {
const session = currentSession()
if (!session) {
return
}
roomLoading.value = true
errorMessage.value = ''
try {
const result = await listRooms(session, syncAuth)
rooms.value = result.items ?? []
roomTotal.value = result.total ?? rooms.value.length
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
return
}
errorMessage.value = error instanceof Error ? error.message : '获取房间列表失败'
} finally {
roomLoading.value = false
}
}
function openCreateModal(): void {
errorMessage.value = ''
successMessage.value = ''
showCreateModal.value = true
}
function closeCreateModal(): void {
showCreateModal.value = false
}
async function submitCreateRoom(): Promise<void> {
const session = currentSession()
if (!session) {
return
}
errorMessage.value = ''
successMessage.value = ''
if (!createRoomForm.value.name.trim()) {
errorMessage.value = '请输入房间名'
return
}
roomSubmitting.value = true
try {
const room = await createRoom(
session,
{
name: createRoomForm.value.name.trim(),
gameType: createRoomForm.value.gameType,
maxPlayers: Number(createRoomForm.value.maxPlayers),
totalRounds: Number(createRoomForm.value.totalRounds),
},
syncAuth,
)
createdRoom.value = room
setRoomMetaSnapshot({
roomId: room.room_id,
roomName: room.name,
gameType: room.game_type,
ownerId: room.owner_id,
maxPlayers: room.max_players,
playerCount: room.player_count,
status: room.status,
createdAt: room.created_at,
updatedAt: room.updated_at,
players: mapRoomPlayers(room),
})
quickJoinRoomId.value = room.room_id
createRoomForm.value.name = ''
createRoomForm.value.totalRounds = 8
showCreateModal.value = false
showCreatedModal.value = true
await refreshRooms()
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
return
}
errorMessage.value = error instanceof Error ? error.message : '创建房间失败'
} finally {
roomSubmitting.value = false
}
}
async function handleJoinRoom(room?: { roomId?: string; roomName?: string }): Promise<void> {
const session = currentSession()
if (!session) {
return
}
errorMessage.value = ''
successMessage.value = ''
const targetRoomId = (room?.roomId ?? quickJoinRoomId.value).trim()
if (!targetRoomId) {
errorMessage.value = '请输入房间ID'
return
}
roomSubmitting.value = true
try {
const joinedRoom = await joinRoom(session, { roomId: targetRoomId }, syncAuth)
setRoomMetaSnapshot({
roomId: joinedRoom.room_id,
roomName: joinedRoom.name,
gameType: joinedRoom.game_type,
ownerId: joinedRoom.owner_id,
maxPlayers: joinedRoom.max_players,
playerCount: joinedRoom.player_count,
status: joinedRoom.status,
createdAt: joinedRoom.created_at,
updatedAt: joinedRoom.updated_at,
players: mapRoomPlayers(joinedRoom),
})
quickJoinRoomId.value = joinedRoom.room_id
successMessage.value = `已加入房间:${joinedRoom.room_id}`
await refreshRooms()
connectGameWs()
await router.push({
path: `/game/chengdu/${joinedRoom.room_id}`,
query: joinedRoom.name ? { roomName: joinedRoom.name } : undefined,
})
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
return
}
errorMessage.value = error instanceof Error ? error.message : '加入房间失败'
} finally {
roomSubmitting.value = false
}
}
async function copyText(text: string, okText: string): Promise<void> {
if (!text) {
return
}
try {
await navigator.clipboard.writeText(text)
successMessage.value = okText
} catch {
errorMessage.value = '复制失败,请手动复制'
}
}
function closeCreatedModal(): void {
showCreatedModal.value = false
}
async function enterCreatedRoom(): Promise<void> {
if (!createdRoom.value) {
return
}
showCreatedModal.value = false
setRoomMetaSnapshot({
roomId: createdRoom.value.room_id,
roomName: createdRoom.value.name,
gameType: createdRoom.value.game_type,
ownerId: createdRoom.value.owner_id,
maxPlayers: createdRoom.value.max_players,
playerCount: createdRoom.value.player_count,
status: createdRoom.value.status,
createdAt: createdRoom.value.created_at,
updatedAt: createdRoom.value.updated_at,
players: mapRoomPlayers(createdRoom.value),
})
connectGameWs()
await router.push({
path: `/game/chengdu/${createdRoom.value.room_id}`,
query: { roomName: createdRoom.value.name },
})
}
async function loadUserInfo(): Promise<void> {
const session = currentSession()
if (!session) {
return
}
userInfoLoading.value = true
try {
userInfo.value = await getUserInfo(session, syncAuth)
} catch (error) {
if (error instanceof AuthExpiredError) {
logoutToLogin()
return
}
errorMessage.value = error instanceof Error ? error.message : '获取用户信息失败'
} finally {
userInfoLoading.value = false
}
}
onMounted(async () => {
if (!auth.value) {
await router.replace('/login')
return
}
await loadUserInfo()
await refreshRooms()
})
</script>
<template>
<section class="hall-page hall-wood-bg">
<header class="hall-header hall-topbar">
<div class="brand-block">
<div class="hall-logo"></div>
<div>
<h1>麻将游戏大厅</h1>
<p class="sub-title">Mahjong Club - Chengdu Table</p>
</div>
</div>
<div class="user-chip">
<p>{{ userInfoLoading ? '加载用户中...' : '玩家信息' }}</p>
<strong>用户名{{ displayName }}</strong>
<small v-if="displayUserId">ID: {{ displayUserId }}</small>
<small v-if="displayEmail">邮箱: {{ displayEmail }}</small>
<small v-if="displayPhone">
电话: {{ showFullPhone ? displayPhone : maskedPhone }}
<button class="text-btn" type="button" @click="showFullPhone = !showFullPhone">
{{ showFullPhone ? '隐藏' : '显示' }}
</button>
</small>
</div>
</header>
<section class="hall-grid hall-grid-8-4">
<article class="panel room-list-panel">
<div class="room-panel-header">
<h2>房间列表</h2>
<button class="icon-btn" type="button" :disabled="roomLoading" @click="refreshRooms">
<img class="icon-btn-image" :src="refreshIcon" alt="刷新房间列表" />
</button>
</div>
<div v-if="roomLoading" class="empty-state">正在加载房间...</div>
<div v-else-if="rooms.length === 0" class="empty-state">暂无可加入房间先创建一个吧</div>
<ul v-else class="room-list">
<li v-for="room in rooms" :key="room.room_id" class="room-card">
<div class="room-meta">
<p class="room-name">{{ room.name }}</p>
<p class="room-tags">
<span>{{ gameTypeLabel(room.game_type) }}</span>
<span>{{ room.player_count }}/{{ room.max_players }}</span>
<span v-if="isMyRoom(room)" class="owner-tag">房主</span>
</p>
<p class="room-id">ID: {{ room.room_id }}</p>
</div>
<button
class="primary-btn"
:data-testid="`room-enter-${room.room_id}`"
type="button"
:disabled="roomSubmitting"
@click="handleJoinRoom({ roomId: room.room_id, roomName: room.name })"
>
进入
</button>
</li>
</ul>
<div class="room-actions-footer">
<button class="primary-btn wide-btn" data-testid="open-create-room" type="button" @click="openCreateModal">创建房间</button>
<button class="ghost-btn wide-btn" type="button" @click="logoutToLogin">退出大厅</button>
</div>
</article>
<aside class="panel side-panel">
<h2>大厅信息</h2>
<p class="hint-text">当前房间总数{{ roomTotal }}</p>
<p class="hint-text">推荐玩法四川麻将 / 血流成河</p>
<h3>快速加入</h3>
<form class="join-line" @submit.prevent="handleJoinRoom()">
<input v-model.trim="quickJoinRoomId" data-testid="quick-join-room-id" type="text" placeholder="输入 room_id" />
<button class="primary-btn" data-testid="quick-join-submit" type="submit" :disabled="roomSubmitting">加入</button>
</form>
</aside>
</section>
<p v-if="errorMessage" class="message error">{{ errorMessage }}</p>
<p v-if="successMessage" class="message success">{{ successMessage }}</p>
<div v-if="showCreateModal" class="modal-mask" @click.self="closeCreateModal">
<section class="modal-card modal-600">
<h2>创建房间</h2>
<form class="form" @submit.prevent="submitCreateRoom">
<label class="field">
<span>房间名</span>
<input v-model.trim="createRoomForm.name" data-testid="create-room-name" type="text" maxlength="24" placeholder="例如test001" />
</label>
<label class="field">
<span>玩法</span>
<select v-model="createRoomForm.gameType">
<option value="chengdu">四川麻将</option>
<option value="xueliu">血流成河</option>
</select>
</label>
<fieldset class="radio-group">
<legend>人数</legend>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4</label>
</fieldset>
<fieldset class="radio-group">
<legend>局数</legend>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="4" /> 4</label>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="8" /> 8</label>
<label><input v-model.number="createRoomForm.totalRounds" type="radio" :value="16" /> 16</label>
</fieldset>
<div class="modal-actions">
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
<button class="primary-btn" data-testid="submit-create-room" type="submit" :disabled="roomSubmitting">
{{ roomSubmitting ? '创建中...' : '创建' }}
</button>
</div>
</form>
</section>
</div>
<div v-if="showCreatedModal && createdRoom" class="modal-mask" @click.self="closeCreatedModal">
<section class="modal-card modal-600">
<h2>房间创建成功</h2>
<div class="copy-line">
<span>房间ID{{ createdRoom.room_id }}</span>
<button class="ghost-btn" type="button" @click="copyText(createdRoom.room_id, '房间ID已复制')">复制</button>
</div>
<div class="copy-line">
<span>邀请链接{{ inviteLink }}</span>
<button class="ghost-btn" type="button" @click="copyText(inviteLink, '邀请链接已复制')">复制</button>
</div>
<div class="modal-actions">
<button class="primary-btn" data-testid="enter-created-room" type="button" @click="enterCreatedRoom">进入房间</button>
</div>
</section>
</div>
</section>
</template>