Files
mahjong-web/src/views/HallPage.vue
wsy182 ae5d8d48c4 feat(game): 支持玩家显示名称的多种数据源
- 在麻将游戏页面中添加本地缓存头像URL的优先级处理
- 为玩家座位信息添加自定义显示名称功能
- 支持从player_name或PlayerName字段获取玩家名称
- 实现当前用户显示名称的回退逻辑
- 更新API接口定义以支持可选的玩家名称字段
2026-03-25 21:15:40 +08:00

537 lines
15 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 { setActiveRoom } 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,
})
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),
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), 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),
},
syncAuth,
)
createdRoom.value = room
setActiveRoom({
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 = ''
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)
setActiveRoom({
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
setActiveRoom({
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"
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" 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" type="text" placeholder="输入 room_id" />
<button class="primary-btn" 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" 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="2" /> 2</label>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="3" /> 3</label>
<label><input v-model.number="createRoomForm.maxPlayers" type="radio" :value="4" /> 4</label>
</fieldset>
<div class="modal-actions">
<button class="ghost-btn" type="button" @click="closeCreateModal">取消</button>
<button class="primary-btn" 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" type="button" @click="enterCreatedRoom">进入房间</button>
</div>
</section>
</div>
</section>
</template>