first commit
This commit is contained in:
579
src/views/ChengduGamePage.vue
Normal file
579
src/views/ChengduGamePage.vue
Normal file
@@ -0,0 +1,579 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { readStoredAuth } from '../utils/auth-storage'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const auth = ref(readStoredAuth())
|
||||
const ws = ref<WebSocket | null>(null)
|
||||
const wsStatus = ref<'disconnected' | 'connecting' | 'connected'>('disconnected')
|
||||
const wsError = ref('')
|
||||
const wsMessages = ref<string[]>([])
|
||||
const startGamePending = ref(false)
|
||||
const lastStartRequestId = ref('')
|
||||
|
||||
const WS_BASE_URL = import.meta.env.VITE_GAME_WS_URL ?? 'ws://127.0.0.1:8080/ws'
|
||||
const DEFAULT_MAX_PLAYERS = 4
|
||||
|
||||
type SeatKey = 'top' | 'right' | 'bottom' | 'left'
|
||||
|
||||
interface RoomPlayerState {
|
||||
index: number
|
||||
playerId: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
interface RoomState {
|
||||
id: string
|
||||
name: string
|
||||
ownerId: string
|
||||
maxPlayers: number
|
||||
status: string
|
||||
players: RoomPlayerState[]
|
||||
currentTurnIndex: number | null
|
||||
}
|
||||
|
||||
interface ActionEventLike {
|
||||
type?: unknown
|
||||
status?: unknown
|
||||
requestId?: unknown
|
||||
roomId?: unknown
|
||||
room_id?: unknown
|
||||
payload?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
const roomId = computed(() => {
|
||||
return typeof route.params.roomId === 'string' ? route.params.roomId : ''
|
||||
})
|
||||
|
||||
const roomName = computed(() => {
|
||||
return typeof route.query.roomName === 'string' ? route.query.roomName : ''
|
||||
})
|
||||
|
||||
const currentUserId = computed(() => {
|
||||
const candidate = auth.value?.user?.id
|
||||
if (typeof candidate === 'string') {
|
||||
return candidate
|
||||
}
|
||||
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||||
return String(candidate)
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const loggedInUserName = computed(() => {
|
||||
if (!auth.value?.user) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return auth.value.user.nickname ?? auth.value.user.username ?? ''
|
||||
})
|
||||
|
||||
const roomState = ref<RoomState>({
|
||||
id: roomId.value,
|
||||
name: roomName.value,
|
||||
ownerId: '',
|
||||
maxPlayers: DEFAULT_MAX_PLAYERS,
|
||||
status: 'waiting',
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
})
|
||||
|
||||
const isRoomFull = computed(() => {
|
||||
return (
|
||||
roomState.value.maxPlayers > 0 &&
|
||||
roomState.value.players.length === roomState.value.maxPlayers
|
||||
)
|
||||
})
|
||||
|
||||
const canStartGame = computed(() => {
|
||||
return (
|
||||
Boolean(roomState.value.id) &&
|
||||
roomState.value.status === 'waiting' &&
|
||||
isRoomFull.value &&
|
||||
Boolean(currentUserId.value) &&
|
||||
roomState.value.ownerId === currentUserId.value
|
||||
)
|
||||
})
|
||||
|
||||
const seatViews = computed(() => {
|
||||
const seats: Record<SeatKey, RoomPlayerState | null> = {
|
||||
top: null,
|
||||
right: null,
|
||||
bottom: null,
|
||||
left: null,
|
||||
}
|
||||
|
||||
const players = [...roomState.value.players].sort((a, b) => a.index - b.index)
|
||||
const me = players.find((player) => player.playerId === currentUserId.value) ?? null
|
||||
const anchorIndex = me?.index ?? players[0]?.index ?? 0
|
||||
const clockwiseSeatByDelta: SeatKey[] = ['bottom', 'left', 'top', 'right']
|
||||
|
||||
for (const player of players) {
|
||||
const normalizedDelta = ((player.index - anchorIndex) % 4 + 4) % 4
|
||||
const seat = clockwiseSeatByDelta[normalizedDelta] ?? 'top'
|
||||
seats[seat] = player
|
||||
}
|
||||
|
||||
const turnSeat =
|
||||
roomState.value.currentTurnIndex === null
|
||||
? null
|
||||
: clockwiseSeatByDelta[
|
||||
((roomState.value.currentTurnIndex - anchorIndex) % 4 + 4) % 4
|
||||
] ?? null
|
||||
|
||||
const order: SeatKey[] = ['top', 'right', 'bottom', 'left']
|
||||
return order.map((seat) => {
|
||||
const player = seats[seat]
|
||||
const isSelf = Boolean(player) && player?.playerId === currentUserId.value
|
||||
return {
|
||||
key: seat,
|
||||
player,
|
||||
isSelf,
|
||||
isTurn: turnSeat === seat,
|
||||
label: player ? (isSelf ? '你' : player.playerId) : '空位',
|
||||
subLabel: player ? `座位 ${player.index}` : '',
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const roomStatusText = computed(() => {
|
||||
if (roomState.value.status === 'playing') {
|
||||
return '对局中'
|
||||
}
|
||||
if (roomState.value.status === 'finished') {
|
||||
return '已结束'
|
||||
}
|
||||
return '等待中'
|
||||
})
|
||||
|
||||
function backHall(): void {
|
||||
sendLeaveRoom()
|
||||
void router.push('/hall')
|
||||
}
|
||||
|
||||
function pushWsMessage(text: string): void {
|
||||
const line = `[${new Date().toLocaleTimeString()}] ${text}`
|
||||
wsMessages.value.unshift(line)
|
||||
if (wsMessages.value.length > 80) {
|
||||
wsMessages.value.length = 80
|
||||
}
|
||||
}
|
||||
|
||||
function logWsSend(message: unknown): void {
|
||||
console.log('[WS][client] 发送:', message)
|
||||
}
|
||||
|
||||
function logWsReceive(kind: string, payload?: unknown): void {
|
||||
const now = new Date().toLocaleTimeString()
|
||||
if (payload === undefined) {
|
||||
console.log(`[WS][${now}] 收到${kind}`)
|
||||
return
|
||||
}
|
||||
console.log(`[WS][${now}] 收到${kind}:`, payload)
|
||||
}
|
||||
|
||||
function disconnectWs(): void {
|
||||
if (ws.value) {
|
||||
ws.value.close()
|
||||
ws.value = null
|
||||
}
|
||||
wsStatus.value = 'disconnected'
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
||||
}
|
||||
|
||||
function toStringOrEmpty(value: unknown): string {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function toFiniteNumber(value: unknown): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizePlayer(input: unknown, fallbackIndex: number): RoomPlayerState | null {
|
||||
const player = toRecord(input)
|
||||
if (!player) {
|
||||
return null
|
||||
}
|
||||
|
||||
const playerId = toStringOrEmpty(player.playerId ?? player.player_id ?? player.user_id ?? player.id)
|
||||
if (!playerId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const seatIndex = toFiniteNumber(player.index ?? player.seat ?? player.position ?? player.player_index)
|
||||
return {
|
||||
index: seatIndex ?? fallbackIndex,
|
||||
playerId,
|
||||
ready: Boolean(player.ready),
|
||||
}
|
||||
}
|
||||
|
||||
function extractCurrentTurnIndex(value: Record<string, unknown>): number | null {
|
||||
const keys = [
|
||||
value.currentTurnIndex,
|
||||
value.current_turn_index,
|
||||
value.currentPlayerIndex,
|
||||
value.current_player_index,
|
||||
value.turnIndex,
|
||||
value.turn_index,
|
||||
value.activePlayerIndex,
|
||||
value.active_player_index,
|
||||
]
|
||||
for (const key of keys) {
|
||||
const parsed = toFiniteNumber(key)
|
||||
if (parsed !== null) {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeRoom(input: unknown): RoomState | null {
|
||||
const room = toRecord(input)
|
||||
if (!room) {
|
||||
return null
|
||||
}
|
||||
|
||||
const id = toStringOrEmpty(room.roomId ?? room.room_id ?? room.id)
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const maxPlayers =
|
||||
toFiniteNumber(room.maxPlayers ?? room.max_players) ?? roomState.value.maxPlayers ?? DEFAULT_MAX_PLAYERS
|
||||
const playersRaw =
|
||||
(Array.isArray(room.players) ? room.players : null) ??
|
||||
(Array.isArray(room.playerList) ? room.playerList : null) ??
|
||||
(Array.isArray(room.player_list) ? room.player_list : null) ??
|
||||
[]
|
||||
|
||||
const players = playersRaw
|
||||
.map((item, index) => normalizePlayer(item, index))
|
||||
.filter((item): item is RoomPlayerState => Boolean(item))
|
||||
.sort((a, b) => a.index - b.index)
|
||||
|
||||
return {
|
||||
id,
|
||||
name: toStringOrEmpty(room.name) || roomState.value.name,
|
||||
ownerId: toStringOrEmpty(room.ownerId ?? room.owner_id),
|
||||
maxPlayers,
|
||||
status: toStringOrEmpty(room.status) || roomState.value.status || 'waiting',
|
||||
players,
|
||||
currentTurnIndex: extractCurrentTurnIndex(room),
|
||||
}
|
||||
}
|
||||
|
||||
function mergeRoomState(next: RoomState): void {
|
||||
if (roomId.value && next.id !== roomId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
roomState.value = {
|
||||
...roomState.value,
|
||||
...next,
|
||||
players: next.players.length > 0 ? next.players : roomState.value.players,
|
||||
currentTurnIndex:
|
||||
next.currentTurnIndex !== null ? next.currentTurnIndex : roomState.value.currentTurnIndex,
|
||||
}
|
||||
}
|
||||
|
||||
function consumeGameEvent(raw: string): void {
|
||||
let parsed: unknown = null
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const event = toRecord(parsed) as ActionEventLike | null
|
||||
if (!event) {
|
||||
return
|
||||
}
|
||||
|
||||
const candidates: unknown[] = [event, event.payload, event.data]
|
||||
const payload = toRecord(event.payload)
|
||||
if (payload) {
|
||||
candidates.push(payload.room, payload.state, payload.roomState)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const normalized = normalizeRoom(candidate)
|
||||
if (normalized) {
|
||||
mergeRoomState(normalized)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.status === 'error' &&
|
||||
typeof event.requestId === 'string' &&
|
||||
event.requestId === lastStartRequestId.value
|
||||
) {
|
||||
startGamePending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
function sendStartGame(): void {
|
||||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN || !canStartGame.value || startGamePending.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const sender = currentUserId.value
|
||||
|
||||
if (!sender) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = createRequestId('start-game')
|
||||
lastStartRequestId.value = requestId
|
||||
startGamePending.value = true
|
||||
|
||||
const message = {
|
||||
type: 'start_game',
|
||||
sender,
|
||||
target: 'room',
|
||||
roomId: roomState.value.id || roomId.value,
|
||||
seq: Date.now(),
|
||||
requestId,
|
||||
trace_id: createRequestId('trace'),
|
||||
payload: {},
|
||||
}
|
||||
logWsSend(message)
|
||||
ws.value.send(JSON.stringify(message))
|
||||
pushWsMessage(`[client] 请求开始游戏 requestId=${requestId}`)
|
||||
}
|
||||
|
||||
function sendLeaveRoom(): void {
|
||||
if (!ws.value || ws.value.readyState !== WebSocket.OPEN) {
|
||||
return
|
||||
}
|
||||
|
||||
const sender = currentUserId.value
|
||||
const targetRoomId = roomState.value.id || roomId.value
|
||||
if (!sender || !targetRoomId) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = createRequestId('leave-room')
|
||||
const message = {
|
||||
type: 'leave_room',
|
||||
sender,
|
||||
target: 'room',
|
||||
roomId: targetRoomId,
|
||||
seq: Date.now(),
|
||||
requestId,
|
||||
trace_id: createRequestId('trace'),
|
||||
payload: {},
|
||||
}
|
||||
|
||||
logWsSend(message)
|
||||
ws.value.send(JSON.stringify(message))
|
||||
pushWsMessage(`[client] 请求退出房间 requestId=${requestId}`)
|
||||
}
|
||||
|
||||
function connectWs(): void {
|
||||
wsError.value = ''
|
||||
const token = auth.value?.token
|
||||
if (!token) {
|
||||
wsError.value = '缺少 token,无法建立 WebSocket 连接'
|
||||
return
|
||||
}
|
||||
|
||||
disconnectWs()
|
||||
wsStatus.value = 'connecting'
|
||||
|
||||
const url = `${WS_BASE_URL}?token=${encodeURIComponent(token)}`
|
||||
const socket = new WebSocket(url)
|
||||
ws.value = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
wsStatus.value = 'connected'
|
||||
pushWsMessage('WebSocket 已连接')
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (typeof event.data === 'string') {
|
||||
logWsReceive('文本消息', event.data)
|
||||
try {
|
||||
const parsed = JSON.parse(event.data)
|
||||
logWsReceive('JSON消息', parsed)
|
||||
|
||||
pushWsMessage(`[server] ${JSON.stringify(parsed, null, 2)}`)
|
||||
} catch {
|
||||
pushWsMessage(`[server] ${event.data}`)
|
||||
}
|
||||
|
||||
consumeGameEvent(event.data)
|
||||
return
|
||||
}
|
||||
|
||||
logWsReceive('二进制消息')
|
||||
pushWsMessage('[binary] 收到二进制消息')
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
wsError.value = 'WebSocket 连接异常'
|
||||
}
|
||||
|
||||
socket.onclose = () => {
|
||||
wsStatus.value = 'disconnected'
|
||||
startGamePending.value = false
|
||||
pushWsMessage('WebSocket 已断开')
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
roomId,
|
||||
(nextRoomId) => {
|
||||
roomState.value = {
|
||||
id: nextRoomId,
|
||||
name: roomName.value,
|
||||
ownerId: '',
|
||||
maxPlayers: DEFAULT_MAX_PLAYERS,
|
||||
status: 'waiting',
|
||||
players: [],
|
||||
currentTurnIndex: null,
|
||||
}
|
||||
startGamePending.value = false
|
||||
lastStartRequestId.value = ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(roomName, (next) => {
|
||||
roomState.value = {
|
||||
...roomState.value,
|
||||
name: next || roomState.value.name,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[canStartGame, wsStatus],
|
||||
([canStart, status]) => {
|
||||
if (!canStart || status !== 'connected') {
|
||||
return
|
||||
}
|
||||
sendStartGame()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => roomState.value.status,
|
||||
(status) => {
|
||||
if (status === 'playing' || status === 'finished') {
|
||||
startGamePending.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
connectWs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectWs()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="hall-page game-page">
|
||||
<header class="hall-header game-header">
|
||||
<div>
|
||||
<h1>成都麻将对局</h1>
|
||||
<p v-if="loggedInUserName" class="sub-title">玩家:{{ loggedInUserName }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="ghost-btn" type="button" @click="backHall">返回大厅</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="table-panel game-table-panel">
|
||||
<div class="room-brief">
|
||||
<span class="room-brief-title">当前房间</span>
|
||||
<span class="room-brief-item">
|
||||
<em>房间名:</em>
|
||||
<strong>{{ roomState.name || roomName || '未命名房间' }}</strong>
|
||||
</span>
|
||||
<span class="room-brief-item room-brief-id">
|
||||
<em>room_id:</em>
|
||||
<strong>{{ roomId || '未选择房间' }}</strong>
|
||||
</span>
|
||||
<span class="room-brief-item">
|
||||
<em>状态:</em>
|
||||
<strong>{{ roomStatusText }}</strong>
|
||||
</span>
|
||||
<span class="room-brief-item">
|
||||
<em>人数:</em>
|
||||
<strong>{{ roomState.players.length }}/{{ roomState.maxPlayers }}</strong>
|
||||
</span>
|
||||
<button
|
||||
class="ghost-btn ws-reconnect"
|
||||
type="button"
|
||||
:disabled="!canStartGame || startGamePending"
|
||||
@click="sendStartGame"
|
||||
>
|
||||
{{ startGamePending ? '开局请求中...' : '开始游戏' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mahjong-table game-mahjong-table" :class="{ active: Boolean(roomId) }">
|
||||
<div
|
||||
v-for="seat in seatViews"
|
||||
:key="seat.key"
|
||||
class="seat"
|
||||
:class="[
|
||||
`seat-${seat.key}`,
|
||||
{ occupied: Boolean(seat.player), 'seat-me': seat.isSelf, 'seat-turn': seat.isTurn },
|
||||
]"
|
||||
>
|
||||
<strong>{{ seat.label }}</strong>
|
||||
<small v-if="seat.subLabel">{{ seat.subLabel }}</small>
|
||||
<span v-if="seat.isTurn" class="turn-indicator">出牌中</span>
|
||||
</div>
|
||||
<div class="table-center">
|
||||
<p>Chengdu Mahjong</p>
|
||||
<p>{{ roomState.id || roomId || 'Waiting...' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="ws-panel">
|
||||
<div class="ws-panel-head">
|
||||
<strong>实时消息</strong>
|
||||
<div class="ws-actions">
|
||||
<span class="ws-state" :class="`is-${wsStatus}`">{{ wsStatus }}</span>
|
||||
<button class="ghost-btn ws-reconnect" type="button" @click="connectWs">重连</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="wsError" class="message error">{{ wsError }}</p>
|
||||
<div class="ws-log">
|
||||
<p v-if="wsMessages.length === 0" class="ws-empty">等待服务器消息...</p>
|
||||
<p v-for="(line, idx) in wsMessages" :key="idx" class="ws-line">{{ line }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
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>
|
||||
87
src/views/LoginPage.vue
Normal file
87
src/views/LoginPage.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { login } from '../api/auth'
|
||||
import { readStoredAuth, saveAuth } from '../utils/auth-storage'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const form = ref({
|
||||
loginId: '',
|
||||
password: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
watch(
|
||||
() => route.query.loginId,
|
||||
(value) => {
|
||||
if (typeof value === 'string' && value && !form.value.loginId) {
|
||||
form.value.loginId = value
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (readStoredAuth()) {
|
||||
void router.replace('/hall')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
|
||||
if (!form.value.loginId.trim() || !form.value.password) {
|
||||
errorMessage.value = '请输入登录ID和密码'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await login({
|
||||
loginId: form.value.loginId.trim(),
|
||||
password: form.value.password,
|
||||
})
|
||||
saveAuth(result)
|
||||
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/hall'
|
||||
await router.replace(redirect)
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '登录失败,请稍后重试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h1>麻将</h1>
|
||||
<p class="sub-title">先完成账号登录,后续进入大厅创建/加入房间</p>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button class="tab-btn active" type="button">登录</button>
|
||||
<button class="tab-btn" type="button" @click="router.push('/register')">注册</button>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="handleSubmit">
|
||||
<label class="field">
|
||||
<span>登录ID</span>
|
||||
<input v-model.trim="form.loginId" type="text" placeholder="请输入手机号或账号" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input v-model="form.password" type="password" placeholder="请输入密码" />
|
||||
</label>
|
||||
<button class="primary-btn" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="errorMessage" class="message error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
120
src/views/RegisterPage.vue
Normal file
120
src/views/RegisterPage.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { register } from '../api/auth'
|
||||
import { readStoredAuth } from '../utils/auth-storage'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
username: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
})
|
||||
const submitting = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
if (readStoredAuth()) {
|
||||
void router.replace('/hall')
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
const username = form.value.username.trim()
|
||||
const phone = form.value.phone.trim()
|
||||
const email = form.value.email.trim()
|
||||
const password = form.value.password
|
||||
|
||||
if (!username || !phone || !email || !password) {
|
||||
errorMessage.value = '请完整填写用户名、手机号、邮箱和密码'
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^\d{11}$/.test(phone)) {
|
||||
errorMessage.value = '手机号格式不正确,请输入11位数字'
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
errorMessage.value = '邮箱格式不正确'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
errorMessage.value = '密码至少 6 位'
|
||||
return
|
||||
}
|
||||
|
||||
if (password !== form.value.confirmPassword) {
|
||||
errorMessage.value = '两次输入密码不一致'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await register({ username, phone, email, password })
|
||||
successMessage.value = '注册成功,正在前往登录页'
|
||||
await router.replace({ path: '/login', query: { loginId: phone } })
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : '注册失败,请稍后重试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="auth-page">
|
||||
<div class="auth-card">
|
||||
<h1>注册账号</h1>
|
||||
<p class="sub-title">完成注册后可登录进入麻将大厅</p>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button class="tab-btn" type="button" @click="router.push('/login')">登录</button>
|
||||
<button class="tab-btn active" type="button">注册</button>
|
||||
</div>
|
||||
|
||||
<form class="form" @submit.prevent="handleSubmit">
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<input v-model.trim="form.username" type="text" placeholder="3-20位用户名" minlength="3" maxlength="20" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>手机号</span>
|
||||
<input
|
||||
v-model.trim="form.phone"
|
||||
type="tel"
|
||||
inputmode="numeric"
|
||||
placeholder="请输入11位手机号"
|
||||
maxlength="11"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>邮箱</span>
|
||||
<input v-model.trim="form.email" type="email" placeholder="请输入邮箱地址" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>密码</span>
|
||||
<input v-model="form.password" type="password" placeholder="至少6位密码" minlength="6" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>确认密码</span>
|
||||
<input v-model="form.confirmPassword" type="password" placeholder="再次输入密码" minlength="6" />
|
||||
</label>
|
||||
<button class="primary-btn" type="submit" :disabled="submitting">
|
||||
{{ submitting ? '注册中...' : '注册' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="errorMessage" class="message error">{{ errorMessage }}</p>
|
||||
<p v-if="successMessage" class="message success">{{ successMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user