first commit

This commit is contained in:
2026-02-18 14:30:42 +08:00
commit f79920ad6a
212 changed files with 3850 additions and 0 deletions

468
src/views/HallPage.vue Normal file
View 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>