first commit
This commit is contained in:
468
src/views/HallPage.vue
Normal file
468
src/views/HallPage.vue
Normal file
@@ -0,0 +1,468 @@
|
||||
<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 type { StoredAuth } from '../types/session'
|
||||
import { clearAuth, readStoredAuth, writeStoredAuth } from '../utils/auth-storage'
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
const targetRoomName =
|
||||
room?.roomName ?? rooms.value.find((item) => item.room_id === targetRoomId)?.name ?? ''
|
||||
|
||||
roomSubmitting.value = true
|
||||
try {
|
||||
await joinRoom(session, { roomId: targetRoomId }, syncAuth)
|
||||
quickJoinRoomId.value = targetRoomId
|
||||
successMessage.value = `已加入房间:${targetRoomId}`
|
||||
await refreshRooms()
|
||||
await router.push({
|
||||
path: `/game/chengdu/${targetRoomId}`,
|
||||
query: targetRoomName ? { roomName: targetRoomName } : 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
|
||||
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">🔄</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>
|
||||
Reference in New Issue
Block a user