Files
xzmaster/frontend/src/App.vue
hujun faf87fe3d6 feat: 添加局后复盘服务与页面容器组件
新增复盘服务相关DTO、Controller和Service
实现复盘页面容器组件ReviewPageContainer
更新前端页面架构文档与开发计划
移除DemoGameController中的演示复盘接口
补充复盘服务单元测试
2026-03-20 16:50:49 +08:00

607 lines
19 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 { Client, type IFrame, type IMessage } from '@stomp/stompjs'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import AppShell from './components/AppShell.vue'
import GamePageContainer from './pages/GamePageContainer.vue'
import ReviewPageContainer from './pages/ReviewPageContainer.vue'
import RoomPageContainer from './pages/RoomPageContainer.vue'
import type {
ApiResponse,
DiagnosticItem,
GameStateResponse,
PrivateActionMessage,
PrivateTeachingMessage,
PublicGameMessage,
RoomSummaryResponse,
ScoreChangeCardView,
SettlementCardView,
ViewUserOption
} from './types/game'
import type { ReviewSummaryResponse } from './types/review'
import {
actionScopeLabelMap,
formatSettlementType,
readNumber,
readString,
phaseLabelMap,
toActionLabel,
toSettlementDetailView,
triggerEventTypeLabelMap
} from './utils/gameUi'
const busy = ref(false)
const error = ref('')
const info = ref('H5 对局原型已接入公共事件、私有动作与私有教学消息,当前开始补正式动作面板。')
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 reviewSummary = ref<ReviewSummaryResponse | null>(null)
const currentUserId = ref('host-1')
const wsStatus = ref<'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'>('idle')
const reviewLoading = ref(false)
const publicEvents = ref<PublicGameMessage[]>([])
const privateAction = ref<PrivateActionMessage | null>(null)
const privateTeaching = ref<PrivateTeachingMessage | null>(null)
let stompClient: Client | null = null
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed(
() =>
game.value?.phase === 'PLAYING' &&
!game.value.selfSeat.won &&
game.value.selfSeat.playerId === currentUserId.value &&
game.value.currentSeatNo === game.value.selfSeat.seatNo
)
const publicSeats = computed(() => game.value?.seats ?? [])
const privateActionSummary = computed(() => {
if (!privateAction.value) {
return ''
}
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
const readableActions = privateAction.value.availableActions.map(toActionLabel)
return `${scopeLabel}${readableActions.join(' / ')}`
})
const turnActionCandidates = computed(() => {
if (privateAction.value?.actionScope !== 'TURN') {
return []
}
// 当前回合的出牌依然直接通过点击手牌触发,动作面板只承载额外动作,避免一个动作出现两套入口。
return privateAction.value.candidates.filter((candidate) => candidate.actionType !== 'DISCARD')
})
const responseActionCandidates = computed(() => {
if (privateAction.value?.actionScope !== 'RESPONSE') {
return []
}
return privateAction.value.candidates
})
const responseContextSummary = computed(() => {
if (!privateAction.value || privateAction.value.actionScope !== 'RESPONSE') {
return ''
}
const triggerLabel = privateAction.value.triggerEventType
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
: '响应窗口'
const sourceSeatLabel =
privateAction.value.sourceSeatNo !== null ? `来源座位 ${privateAction.value.sourceSeatNo}` : '来源座位未知'
const triggerTileLabel = privateAction.value.triggerTile ? `目标牌 ${privateAction.value.triggerTile}` : '未携带目标牌'
return `${triggerLabel}${sourceSeatLabel}${triggerTileLabel}`
})
const actionPanelHint = computed(() => {
if (!privateAction.value) {
return '等待下一条私有动作消息。收到回合动作或响应动作后,这里会自动切换到对应面板。'
}
if (privateAction.value.actionScope === 'TURN') {
return '出牌通过上方手牌直接执行;若当前可自摸胡或可杠,则在这里统一提交。'
}
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
})
const recommendedDiscardTile = computed(() => {
// 当前教学服务只对出牌建议生效,因此 recommendedAction 在现阶段可直接视为“建议打出的牌名”。
if (!privateTeaching.value?.recommendedAction) {
return null
}
if (!game.value?.selfSeat.handTiles.includes(privateTeaching.value.recommendedAction)) {
return null
}
return privateTeaching.value.recommendedAction
})
const teachingHint = computed(() => {
if (!privateTeaching.value) {
return '尚未收到当前视角的教学建议。'
}
if (recommendedDiscardTile.value) {
return `教学建议优先打出 ${recommendedDiscardTile.value}`
}
return privateTeaching.value.explanation
})
const teachingCandidates = computed(() => privateTeaching.value?.candidates ?? [])
const currentViewLabel = computed(() => {
if (!game.value) {
return currentUserId.value
}
return `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`
})
const viewUserOptions = computed<ViewUserOption[]>(() => {
if (game.value) {
const options = new Map<string, ViewUserOption>()
options.set(game.value.selfSeat.playerId, {
userId: game.value.selfSeat.playerId,
label: `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`,
seatNo: game.value.selfSeat.seatNo
})
for (const seat of game.value.seats) {
options.set(seat.playerId, {
userId: seat.playerId,
label: `${seat.nickname} · 座位 ${seat.seatNo}`,
seatNo: seat.seatNo
})
}
return [...options.values()].sort((left, right) => left.seatNo - right.seatNo)
}
return [
{ userId: ownerId.value, label: `${ownerName.value} · 房主`, seatNo: 0 },
{ userId: joinUserId.value, label: `${joinUserName.value} · 玩家二`, seatNo: 1 }
]
})
const actionDiagnosticItems = computed<DiagnosticItem[]>(() => {
if (!privateAction.value) {
return []
}
return [
{
key: 'scope',
label: '作用域',
value: actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
},
{
key: 'window',
label: '窗口 ID',
value: privateAction.value.windowId ? privateAction.value.windowId.slice(0, 8) : '当前回合'
},
{
key: 'event',
label: '触发来源',
value: privateAction.value.triggerEventType
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
: '当前回合'
},
{
key: 'source',
label: '来源座位',
value: privateAction.value.sourceSeatNo !== null ? `座位 ${privateAction.value.sourceSeatNo}` : '无'
}
]
})
const latestSettlementCard = computed<SettlementCardView | null>(() => {
const settlementEvent = publicEvents.value.find((event) => event.eventType === 'SETTLEMENT_APPLIED')
if (!settlementEvent) {
return null
}
return {
settlementType: formatSettlementType(readString(settlementEvent.payload.settlementType)),
actionType: toActionLabel(readString(settlementEvent.payload.actionType) ?? '-'),
actorSeatNo: readNumber(settlementEvent.payload.actorSeatNo),
sourceSeatNo: readNumber(settlementEvent.payload.sourceSeatNo),
triggerTile: readString(settlementEvent.payload.triggerTile),
detail: toSettlementDetailView(settlementEvent.payload.settlementDetail)
}
})
const latestSettlementScoreChanges = computed<ScoreChangeCardView[]>(() => {
const settlementEvent = publicEvents.value.find((event) => event.eventType === 'SETTLEMENT_APPLIED')
if (!settlementEvent) {
return []
}
const latestSettlementType = readString(settlementEvent.payload.settlementType)
return publicEvents.value
.filter((event) => event.eventType === 'SCORE_CHANGED')
.filter((event) => readString(event.payload.settlementType) === latestSettlementType)
.slice(0, 4)
.map((event) => ({
seatNo: event.seatNo ?? -1,
delta: readNumber(event.payload.delta) ?? 0,
score: readNumber(event.payload.score),
settlementType: readString(event.payload.settlementType)
}))
})
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
}
}
async function loadCurrentReview() {
if (!game.value) {
error.value = '请先进入一局真实对局,再加载当前视角复盘'
return
}
reviewLoading.value = true
error.value = ''
try {
reviewSummary.value = await requestJson<ReviewSummaryResponse>(
`/api/games/${game.value.gameId}/review?userId=${encodeURIComponent(currentUserId.value)}`
)
info.value = '已加载当前视角的真实局后复盘摘要。'
} catch (err) {
error.value = err instanceof Error ? err.message : '复盘加载失败'
} finally {
reviewLoading.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
handlePublicEvent(payload)
})
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]) => {
reviewSummary.value = null
// 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。
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 switchUserView(userId: string) {
if (currentUserId.value === userId) {
return
}
currentUserId.value = userId
if (!game.value) {
info.value = `已切换到 ${userId} 视角。`
return
}
const targetGameId = game.value.gameId
await runTask(async () => {
game.value = await requestJson<GameStateResponse>(
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(userId)}`
)
info.value = `已切换到 ${userId} 视角,并同步最新对局状态。`
})
}
async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) {
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,
sourceSeatNo: sourceSeatNo ?? null
})
})
const readableAction = toActionLabel(actionType)
if (actionType === 'DISCARD' && tile) {
info.value = `已打出 ${tile}`
return
}
if (tile) {
info.value = `已提交 ${readableAction} ${tile}`
return
}
info.value = `已提交动作 ${readableAction}`
})
}
function selectLack() {
return submitAction('SELECT_LACK_SUIT', lackSuit.value)
}
function discard(tile: string) {
return submitAction('DISCARD', tile)
}
function submitCandidateAction(actionType: string, tile: string | null) {
const sourceSeatNo = privateAction.value?.sourceSeatNo ?? null
return submitAction(actionType, tile ?? undefined, sourceSeatNo)
}
function handlePublicEvent(event: PublicGameMessage) {
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
if (shouldClearPrivateActionByEvent(event)) {
privateAction.value = null
}
publicEvents.value = [event, ...publicEvents.value].slice(0, 16)
}
function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
if (!privateAction.value) {
return false
}
const currentAction = privateAction.value
const selfSeatNo = game.value?.selfSeat.seatNo ?? null
if (event.eventType === 'RESPONSE_WINDOW_CLOSED') {
const sourceSeatNo = readNumber(event.payload.sourceSeatNo)
return currentAction.actionScope === 'RESPONSE' && sourceSeatNo === currentAction.sourceSeatNo
}
if (event.eventType === 'GAME_PHASE_CHANGED') {
return readString(event.payload.phase) !== 'PLAYING'
}
if (event.eventType === 'TILE_DISCARDED') {
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && event.seatNo === selfSeatNo
}
if (event.eventType === 'TURN_SWITCHED') {
const nextSeatNo = readNumber(event.payload.currentSeatNo)
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && nextSeatNo !== selfSeatNo
}
return false
}
</script>
<template>
<AppShell :info="info" :error="error">
<RoomPageContainer
:busy="busy"
:owner-id="ownerId"
:owner-name="ownerName"
:join-user-id="joinUserId"
:join-user-name="joinUserName"
:room-id-input="roomIdInput"
:room="room"
:current-user-id="currentUserId"
@update:owner-id="ownerId = $event"
@update:owner-name="ownerName = $event"
@update:join-user-id="joinUserId = $event"
@update:join-user-name="joinUserName = $event"
@update:room-id-input="roomIdInput = $event"
@create-room="createRoom"
@load-room="loadRoom"
@switch-user-view="switchUserView"
@join-room="joinRoom"
@toggle-ready="toggleReady"
@start-game="startGame"
/>
<GamePageContainer
:game="game"
:current-user-id="currentUserId"
:current-view-label="currentViewLabel"
:view-user-options="viewUserOptions"
:lack-suit="lackSuit"
:can-select-lack="canSelectLack"
:can-discard="canDiscard"
:recommended-discard-tile="recommendedDiscardTile"
:private-teaching="privateTeaching"
:private-teaching-hint="teachingHint"
:private-teaching-candidates="teachingCandidates"
:private-action="privateAction"
:private-action-summary="privateActionSummary"
:action-panel-hint="actionPanelHint"
:turn-action-candidates="turnActionCandidates"
:response-action-candidates="responseActionCandidates"
:response-context-summary="responseContextSummary"
:action-diagnostic-items="actionDiagnosticItems"
:public-seats="publicSeats"
:public-events="publicEvents"
:latest-settlement-card="latestSettlementCard"
:latest-settlement-score-changes="latestSettlementScoreChanges"
:ws-status="wsStatus"
@update:lack-suit="lackSuit = $event"
@refresh-game-state="refreshGameState"
@select-lack="selectLack"
@switch-user-view="switchUserView"
@discard="discard"
@submit-candidate-action="submitCandidateAction"
/>
<ReviewPageContainer :review="reviewSummary" :loading="reviewLoading" @load-review="loadCurrentReview" />
</AppShell>
</template>