- 实现 ChengduGamePage.vue 组件,包含完整的麻将游戏界面 - 实现 HallPage.vue 组件,支持房间列表展示、创建和加入功能 - 添加 mahjong API 接口用于房间管理操作 - 集成 store 状态管理和本地存储功能 - 实现 ChengduBottomActions 等游戏控制组件 - 添加 websocket 连接和游戏会话管理逻辑 - 实现游戏倒计时、结算等功能模块
547 lines
16 KiB
Vue
547 lines
16 KiB
Vue
<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>
|