feat: 添加局后复盘服务与页面容器组件
新增复盘服务相关DTO、Controller和Service 实现复盘页面容器组件ReviewPageContainer 更新前端页面架构文档与开发计划 移除DemoGameController中的演示复盘接口 补充复盘服务单元测试
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { Client, type IFrame, type IMessage } from '@stomp/stompjs'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import GameActionDock from './components/GameActionDock.vue'
|
||||
import GameMessageStack from './components/GameMessageStack.vue'
|
||||
import PublicEventTimeline from './components/PublicEventTimeline.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,
|
||||
@@ -16,6 +17,7 @@ import type {
|
||||
SettlementCardView,
|
||||
ViewUserOption
|
||||
} from './types/game'
|
||||
import type { ReviewSummaryResponse } from './types/review'
|
||||
import {
|
||||
actionScopeLabelMap,
|
||||
formatSettlementType,
|
||||
@@ -40,9 +42,11 @@ 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)
|
||||
@@ -257,6 +261,25 @@ async function runTask(task: () => Promise<void>) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -315,6 +338,7 @@ function connectWs(gameId: string, userId: string) {
|
||||
watch(
|
||||
() => [game.value?.gameId, currentUserId.value] as const,
|
||||
([gameId, userId]) => {
|
||||
reviewSummary.value = null
|
||||
// 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。
|
||||
if (gameId) {
|
||||
connectWs(gameId, userId)
|
||||
@@ -522,279 +546,61 @@ function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<section class="hero-panel">
|
||||
<div>
|
||||
<p class="eyebrow">XueZhanMaster H5</p>
|
||||
<h1>血战大师移动端对局台</h1>
|
||||
<p class="intro">
|
||||
当前页面不再只做房间流演示,而是开始承担正式联调职责:公共事件、私有动作、私有教学已经连通,下面的动作面板会按“当前回合动作”和“响应动作”自动切换。
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-card">
|
||||
<span class="signal-label">当前提示</span>
|
||||
<strong>{{ info }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<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="switchUserView(ownerId)">切到房主视角</button>
|
||||
<button class="secondary-btn" @click="switchUserView(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 v-if="game" class="play-grid">
|
||||
<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>{{ currentViewLabel }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">剩余牌墙</span>
|
||||
<strong>{{ game.remainingWallCount }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view-switch-card">
|
||||
<div class="section-title">
|
||||
<strong>视角切换</strong>
|
||||
<span class="mini-pill">点击即刷新该玩家视图</span>
|
||||
</div>
|
||||
<div class="view-switch-list">
|
||||
<button
|
||||
v-for="option in viewUserOptions"
|
||||
:key="option.userId"
|
||||
class="view-switch-chip"
|
||||
:class="{ active: option.userId === currentUserId }"
|
||||
@click="switchUserView(option.userId)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</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>
|
||||
<div class="mini-tags">
|
||||
<span class="mini-pill">当前回合 {{ game.currentSeatNo }}</span>
|
||||
<span class="mini-tag">积分 {{ game.selfSeat.score }}</span>
|
||||
<span class="mini-tag">{{ game.selfSeat.won ? '已胡' : '未胡' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="recommendedDiscardTile" class="teaching-strip">
|
||||
<span class="teaching-kicker">教学建议</span>
|
||||
<strong>建议先打 {{ recommendedDiscardTile }}</strong>
|
||||
<span class="message-copy">{{ privateTeaching?.explanation }}</span>
|
||||
</div>
|
||||
<div class="tile-grid">
|
||||
<button
|
||||
v-for="(tile, index) in game.selfSeat.handTiles"
|
||||
:key="`${tile}-${index}`"
|
||||
class="tile-chip"
|
||||
:class="{ recommended: tile === recommendedDiscardTile }"
|
||||
:disabled="!canDiscard"
|
||||
@click="discard(tile)"
|
||||
>
|
||||
{{ tile }}
|
||||
<span v-if="tile === recommendedDiscardTile" class="tile-tip">荐</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="discard-row" v-if="game.selfSeat.melds.length > 0">
|
||||
<span
|
||||
v-for="(meld, index) in game.selfSeat.melds"
|
||||
:key="`self-meld-${meld}-${index}`"
|
||||
class="discard-chip"
|
||||
>
|
||||
{{ meld }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GameActionDock
|
||||
:private-action="privateAction"
|
||||
:private-action-summary="privateActionSummary"
|
||||
:action-panel-hint="actionPanelHint"
|
||||
:turn-action-candidates="turnActionCandidates"
|
||||
:response-action-candidates="responseActionCandidates"
|
||||
:recommended-discard-tile="recommendedDiscardTile"
|
||||
:response-context-summary="responseContextSummary"
|
||||
@submit="submitCandidateAction"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<GameMessageStack
|
||||
:private-action="privateAction"
|
||||
:private-action-summary="privateActionSummary"
|
||||
:action-diagnostic-items="actionDiagnosticItems"
|
||||
:action-panel-hint="actionPanelHint"
|
||||
:private-teaching="privateTeaching"
|
||||
:recommended-discard-tile="recommendedDiscardTile"
|
||||
:teaching-hint="teachingHint"
|
||||
:teaching-candidates="teachingCandidates"
|
||||
/>
|
||||
|
||||
<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.won ? '已胡' : '在局' }}</span>
|
||||
<span class="mini-tag">积分 {{ seat.score }}</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>
|
||||
<div class="discard-row">
|
||||
<span v-for="(meld, index) in seat.melds" :key="`${seat.seatNo}-meld-${meld}-${index}`" class="discard-chip">
|
||||
{{ meld }}
|
||||
</span>
|
||||
<span v-if="seat.melds.length === 0" class="empty-copy">暂无副露</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<PublicEventTimeline
|
||||
:public-events="publicEvents"
|
||||
:latest-settlement-card="latestSettlementCard"
|
||||
:latest-settlement-score-changes="latestSettlementScoreChanges"
|
||||
/>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
<ReviewPageContainer :review="reviewSummary" :loading="reviewLoading" @load-review="loadCurrentReview" />
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user