first commit
This commit is contained in:
569
frontend/src/App.vue
Normal file
569
frontend/src/App.vue
Normal file
@@ -0,0 +1,569 @@
|
||||
<script setup lang="ts">
|
||||
import { Client, type IFrame, type IMessage } from '@stomp/stompjs'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
type ApiResponse<T> = {
|
||||
success: boolean
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
type RoomSeatView = {
|
||||
seatNo: number
|
||||
participantType: string
|
||||
displayName: string
|
||||
botLevel: string | null
|
||||
readyStatus: string
|
||||
teachingEnabled: boolean
|
||||
}
|
||||
|
||||
type RoomSummaryResponse = {
|
||||
roomId: string
|
||||
inviteCode: string
|
||||
status: string
|
||||
allowBotFill: boolean
|
||||
seats: RoomSeatView[]
|
||||
}
|
||||
|
||||
type SelfSeatView = {
|
||||
seatNo: number
|
||||
playerId: string
|
||||
nickname: string
|
||||
lackSuit: string | null
|
||||
handTiles: string[]
|
||||
discardTiles: string[]
|
||||
}
|
||||
|
||||
type PublicSeatView = {
|
||||
seatNo: number
|
||||
playerId: string
|
||||
nickname: string
|
||||
ai: boolean
|
||||
lackSuit: string | null
|
||||
handCount: number
|
||||
discardTiles: string[]
|
||||
}
|
||||
|
||||
type GameStateResponse = {
|
||||
gameId: string
|
||||
phase: string
|
||||
dealerSeatNo: number
|
||||
currentSeatNo: number
|
||||
remainingWallCount: number
|
||||
selfSeat: SelfSeatView
|
||||
seats: PublicSeatView[]
|
||||
}
|
||||
|
||||
type PublicGameMessage = {
|
||||
gameId: string
|
||||
eventType: string
|
||||
seatNo: number | null
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
type PrivateActionMessage = {
|
||||
gameId: string
|
||||
userId: string
|
||||
availableActions: string[]
|
||||
currentSeatNo: number
|
||||
}
|
||||
|
||||
type PrivateTeachingMessage = {
|
||||
gameId: string
|
||||
userId: string
|
||||
teachingMode: string
|
||||
recommendedAction: string
|
||||
explanation: string
|
||||
}
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const info = ref('H5 房间流原型已就位,现在会在进入对局后自动订阅 WebSocket 公共事件和私有消息。')
|
||||
|
||||
const ownerId = ref('host-1')
|
||||
const ownerName = ref('房主')
|
||||
const joinUserId = ref('player-2')
|
||||
const joinUserName = ref('玩家二')
|
||||
const roomIdInput = ref('')
|
||||
const lackSuit = ref('WAN')
|
||||
|
||||
const room = ref<RoomSummaryResponse | null>(null)
|
||||
const game = ref<GameStateResponse | null>(null)
|
||||
const currentUserId = ref('host-1')
|
||||
|
||||
const wsStatus = ref<'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'>('idle')
|
||||
const publicEvents = ref<PublicGameMessage[]>([])
|
||||
const privateAction = ref<PrivateActionMessage | null>(null)
|
||||
const privateTeaching = ref<PrivateTeachingMessage | null>(null)
|
||||
let stompClient: Client | null = null
|
||||
|
||||
const phaseLabelMap: Record<string, string> = {
|
||||
WAITING: '等待中',
|
||||
READY: '全部就绪',
|
||||
PLAYING: '对局中',
|
||||
FINISHED: '已结束',
|
||||
LACK_SELECTION: '定缺阶段'
|
||||
}
|
||||
|
||||
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
|
||||
const canDiscard = computed(
|
||||
() => game.value?.phase === 'PLAYING' && game.value.selfSeat.playerId === currentUserId.value && game.value.currentSeatNo === game.value.selfSeat.seatNo
|
||||
)
|
||||
const publicSeats = computed(() => game.value?.seats ?? [])
|
||||
|
||||
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...options
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`请求失败:${url}`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ApiResponse<T>
|
||||
if (!payload.success) {
|
||||
throw new Error(payload.message || '接口处理失败')
|
||||
}
|
||||
|
||||
return payload.data
|
||||
}
|
||||
|
||||
async function runTask(task: () => Promise<void>) {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await task()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '操作失败'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildBrokerUrl() {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const host = window.location.hostname
|
||||
const port = window.location.port === '5173' ? '8080' : window.location.port
|
||||
return `${protocol}://${host}${port ? `:${port}` : ''}/ws`
|
||||
}
|
||||
|
||||
function disconnectWs() {
|
||||
if (stompClient) {
|
||||
stompClient.deactivate()
|
||||
stompClient = null
|
||||
}
|
||||
wsStatus.value = 'disconnected'
|
||||
}
|
||||
|
||||
function connectWs(gameId: string, userId: string) {
|
||||
disconnectWs()
|
||||
wsStatus.value = 'connecting'
|
||||
publicEvents.value = []
|
||||
privateAction.value = null
|
||||
privateTeaching.value = null
|
||||
|
||||
const client = new Client({
|
||||
brokerURL: buildBrokerUrl(),
|
||||
reconnectDelay: 3000,
|
||||
onConnect() {
|
||||
wsStatus.value = 'connected'
|
||||
client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => {
|
||||
const payload = JSON.parse(message.body) as PublicGameMessage
|
||||
publicEvents.value = [payload, ...publicEvents.value].slice(0, 16)
|
||||
})
|
||||
client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => {
|
||||
privateAction.value = JSON.parse(message.body) as PrivateActionMessage
|
||||
})
|
||||
client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => {
|
||||
privateTeaching.value = JSON.parse(message.body) as PrivateTeachingMessage
|
||||
})
|
||||
},
|
||||
onStompError(frame: IFrame) {
|
||||
wsStatus.value = 'error'
|
||||
error.value = frame.headers.message ?? 'WebSocket 订阅失败'
|
||||
},
|
||||
onWebSocketClose() {
|
||||
wsStatus.value = 'disconnected'
|
||||
},
|
||||
onWebSocketError() {
|
||||
wsStatus.value = 'error'
|
||||
}
|
||||
})
|
||||
|
||||
client.activate()
|
||||
stompClient = client
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [game.value?.gameId, currentUserId.value] as const,
|
||||
([gameId, userId]) => {
|
||||
if (gameId) {
|
||||
connectWs(gameId, userId)
|
||||
} else {
|
||||
disconnectWs()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnectWs()
|
||||
})
|
||||
|
||||
async function createRoom() {
|
||||
await runTask(async () => {
|
||||
const result = await requestJson<RoomSummaryResponse>('/api/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ownerUserId: ownerId.value,
|
||||
allowBotFill: true
|
||||
})
|
||||
})
|
||||
room.value = result
|
||||
roomIdInput.value = result.roomId
|
||||
currentUserId.value = ownerId.value
|
||||
info.value = `房间已创建,邀请码 ${result.inviteCode}。`
|
||||
})
|
||||
}
|
||||
|
||||
async function loadRoom() {
|
||||
if (!roomIdInput.value) {
|
||||
error.value = '请先输入房间 ID'
|
||||
return
|
||||
}
|
||||
await runTask(async () => {
|
||||
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${roomIdInput.value}`)
|
||||
info.value = '已刷新房间状态。'
|
||||
})
|
||||
}
|
||||
|
||||
async function joinRoom() {
|
||||
if (!roomIdInput.value) {
|
||||
error.value = '请先输入房间 ID'
|
||||
return
|
||||
}
|
||||
await runTask(async () => {
|
||||
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${roomIdInput.value}/join`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: joinUserId.value,
|
||||
displayName: joinUserName.value
|
||||
})
|
||||
})
|
||||
info.value = `${joinUserName.value} 已加入房间。`
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleReady(userId: string, ready: boolean) {
|
||||
if (!room.value) {
|
||||
return
|
||||
}
|
||||
const targetRoomId = room.value.roomId
|
||||
await runTask(async () => {
|
||||
room.value = await requestJson<RoomSummaryResponse>(`/api/rooms/${targetRoomId}/ready`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
ready
|
||||
})
|
||||
})
|
||||
info.value = ready ? `${userId} 已准备。` : `${userId} 已取消准备。`
|
||||
})
|
||||
}
|
||||
|
||||
async function startGame() {
|
||||
if (!room.value) {
|
||||
error.value = '请先创建或加载房间'
|
||||
return
|
||||
}
|
||||
const targetRoomId = room.value.roomId
|
||||
await runTask(async () => {
|
||||
currentUserId.value = ownerId.value
|
||||
game.value = await requestJson<GameStateResponse>(`/api/rooms/${targetRoomId}/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
operatorUserId: ownerId.value
|
||||
})
|
||||
})
|
||||
info.value = '对局已开始,进入定缺阶段。'
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshGameState() {
|
||||
if (!game.value) {
|
||||
return
|
||||
}
|
||||
const targetGameId = game.value.gameId
|
||||
await runTask(async () => {
|
||||
game.value = await requestJson<GameStateResponse>(
|
||||
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(currentUserId.value)}`
|
||||
)
|
||||
info.value = '已刷新对局状态。'
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAction(actionType: string, tile?: string) {
|
||||
if (!game.value) {
|
||||
return
|
||||
}
|
||||
const targetGameId = game.value.gameId
|
||||
await runTask(async () => {
|
||||
game.value = await requestJson<GameStateResponse>(`/api/games/${targetGameId}/actions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId: currentUserId.value,
|
||||
actionType,
|
||||
tile: tile ?? null
|
||||
})
|
||||
})
|
||||
info.value = actionType === 'DISCARD' ? `已打出 ${tile}。` : `已提交动作 ${actionType}。`
|
||||
})
|
||||
}
|
||||
|
||||
function selectLack() {
|
||||
return submitAction('SELECT_LACK_SUIT', lackSuit.value)
|
||||
}
|
||||
|
||||
function discard(tile: string) {
|
||||
return submitAction('DISCARD', tile)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="hero-panel">
|
||||
<div>
|
||||
<p class="eyebrow">XueZhanMaster H5</p>
|
||||
<h1>血战大师移动端房间流原型</h1>
|
||||
<p class="intro">
|
||||
当前页面已切成 H5 操作台,并接入 WebSocket 骨架。目标是让后续手机端新对话也能快速恢复上下文:房间流、对局流、消息流都能看见。
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-card">
|
||||
<span class="signal-label">当前提示</span>
|
||||
<strong>{{ info }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
|
||||
<main class="workspace-grid">
|
||||
<section class="panel control-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">步骤 1</p>
|
||||
<h2>建房与入房</h2>
|
||||
</div>
|
||||
<span class="status-pill" :class="{ busy: busy }">{{ busy ? '处理中' : '可操作' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field">
|
||||
<span>房主 ID</span>
|
||||
<input v-model="ownerId" type="text" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>房主昵称</span>
|
||||
<input v-model="ownerName" type="text" />
|
||||
</label>
|
||||
<button class="primary-btn" @click="createRoom">创建房间</button>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field">
|
||||
<span>房间 ID</span>
|
||||
<input v-model="roomIdInput" type="text" placeholder="创建后自动填充,也可手工输入" />
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button class="secondary-btn" @click="loadRoom">刷新房间</button>
|
||||
<button class="secondary-btn" @click="currentUserId = ownerId">切到房主视角</button>
|
||||
<button class="secondary-btn" @click="currentUserId = joinUserId">切到玩家二视角</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-card">
|
||||
<label class="field">
|
||||
<span>加入用户 ID</span>
|
||||
<input v-model="joinUserId" type="text" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>加入昵称</span>
|
||||
<input v-model="joinUserName" type="text" />
|
||||
</label>
|
||||
<button class="primary-btn ghost-btn" @click="joinRoom">模拟加入房间</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel room-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">步骤 2</p>
|
||||
<h2>准备与开局</h2>
|
||||
</div>
|
||||
<span class="status-pill">{{ room ? phaseLabelMap[room.status] ?? room.status : '未建房' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="room" class="room-summary">
|
||||
<div class="room-meta">
|
||||
<div>
|
||||
<span class="meta-label">房间 ID</span>
|
||||
<strong>{{ room.roomId }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">邀请码</span>
|
||||
<strong>{{ room.inviteCode }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">当前视角</span>
|
||||
<strong>{{ currentUserId }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="seat-list">
|
||||
<article v-for="seat in room.seats" :key="seat.seatNo" class="seat-card">
|
||||
<div class="seat-top">
|
||||
<strong>{{ seat.displayName }}</strong>
|
||||
<span class="mini-pill">{{ seat.participantType }}</span>
|
||||
</div>
|
||||
<div class="mini-tags">
|
||||
<span class="mini-tag">{{ seat.readyStatus }}</span>
|
||||
<span v-if="seat.botLevel" class="mini-tag">{{ seat.botLevel }}</span>
|
||||
<span class="mini-tag">{{ seat.teachingEnabled ? '教学开' : '教学关' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="btn-row vertical-on-mobile">
|
||||
<button class="secondary-btn" @click="toggleReady(ownerId, true)">房主准备</button>
|
||||
<button class="secondary-btn" @click="toggleReady(joinUserId, true)">玩家二准备</button>
|
||||
<button class="primary-btn" @click="startGame">房主开局</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="placeholder-card">先创建或加载一个房间,再进入准备和开局流程。</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="play-grid" v-if="game">
|
||||
<article class="panel game-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">步骤 3</p>
|
||||
<h2>对局控制台</h2>
|
||||
</div>
|
||||
<span class="status-pill">{{ phaseLabelMap[game.phase] ?? game.phase }}</span>
|
||||
</div>
|
||||
|
||||
<div class="room-meta">
|
||||
<div>
|
||||
<span class="meta-label">对局 ID</span>
|
||||
<strong>{{ game.gameId }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">当前视角</span>
|
||||
<strong>{{ currentUserId }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">剩余牌墙</span>
|
||||
<strong>{{ game.remainingWallCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row vertical-on-mobile">
|
||||
<button class="secondary-btn" @click="refreshGameState">刷新对局</button>
|
||||
<label class="field compact-field">
|
||||
<span>定缺</span>
|
||||
<select v-model="lackSuit">
|
||||
<option value="WAN">万</option>
|
||||
<option value="TONG">筒</option>
|
||||
<option value="TIAO">条</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="primary-btn" :disabled="!canSelectLack" @click="selectLack">提交定缺</button>
|
||||
</div>
|
||||
|
||||
<div class="self-card">
|
||||
<div class="section-title">
|
||||
<strong>我的手牌</strong>
|
||||
<span class="mini-pill">当前回合 {{ game.currentSeatNo }}</span>
|
||||
</div>
|
||||
<div class="tile-grid">
|
||||
<button
|
||||
v-for="(tile, index) in game.selfSeat.handTiles"
|
||||
:key="`${tile}-${index}`"
|
||||
class="tile-chip"
|
||||
:disabled="!canDiscard"
|
||||
@click="discard(tile)"
|
||||
>
|
||||
{{ tile }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="panel board-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
<p class="eyebrow">步骤 4</p>
|
||||
<h2>消息与公共桌面</h2>
|
||||
</div>
|
||||
<span class="status-pill">{{ wsStatus }}</span>
|
||||
</div>
|
||||
|
||||
<div class="message-stack">
|
||||
<div class="message-card">
|
||||
<span class="meta-label">私有动作消息</span>
|
||||
<strong v-if="privateAction">{{ privateAction.availableActions.join(' / ') }}</strong>
|
||||
<span v-else class="empty-copy">尚未收到私有动作消息</span>
|
||||
</div>
|
||||
<div class="message-card">
|
||||
<span class="meta-label">私有教学消息</span>
|
||||
<strong v-if="privateTeaching">{{ privateTeaching.recommendedAction }}</strong>
|
||||
<span v-if="privateTeaching" class="message-copy">{{ privateTeaching.explanation }}</span>
|
||||
<span v-else class="empty-copy">尚未收到私有教学消息</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="seat-list">
|
||||
<article v-for="seat in publicSeats" :key="seat.seatNo" class="seat-card seat-card-wide">
|
||||
<div class="seat-top">
|
||||
<strong>{{ seat.nickname }}</strong>
|
||||
<span class="mini-pill">座位 {{ seat.seatNo }}</span>
|
||||
</div>
|
||||
<div class="mini-tags">
|
||||
<span class="mini-tag">{{ seat.ai ? 'AI' : '真人' }}</span>
|
||||
<span class="mini-tag">手牌 {{ seat.handCount }}</span>
|
||||
<span class="mini-tag">{{ seat.lackSuit ?? '未定缺' }}</span>
|
||||
</div>
|
||||
<div class="discard-row">
|
||||
<span v-for="(tile, index) in seat.discardTiles" :key="`${seat.seatNo}-${tile}-${index}`" class="discard-chip">
|
||||
{{ tile }}
|
||||
</span>
|
||||
<span v-if="seat.discardTiles.length === 0" class="empty-copy">暂无弃牌</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="event-timeline">
|
||||
<div class="section-title">
|
||||
<strong>公共事件</strong>
|
||||
<span class="mini-pill">{{ publicEvents.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="publicEvents.length === 0" class="placeholder-card">还没有收到公共事件,开局或执行动作后会自动出现。</div>
|
||||
<div v-else class="timeline-list">
|
||||
<article v-for="(event, index) in publicEvents" :key="`${event.createdAt}-${index}`" class="timeline-item">
|
||||
<div class="seat-top">
|
||||
<strong>{{ event.eventType }}</strong>
|
||||
<span class="mini-pill">{{ event.seatNo ?? '-' }}</span>
|
||||
</div>
|
||||
<div class="message-copy">{{ JSON.stringify(event.payload) }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user