feat: 添加局后复盘服务与页面容器组件

新增复盘服务相关DTO、Controller和Service
实现复盘页面容器组件ReviewPageContainer
更新前端页面架构文档与开发计划
移除DemoGameController中的演示复盘接口
补充复盘服务单元测试
This commit is contained in:
hujun
2026-03-20 16:50:49 +08:00
parent 905565e7c4
commit faf87fe3d6
27 changed files with 1639 additions and 280 deletions

View File

@@ -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>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
const props = defineProps<{
info: string
error: string
}>()
// AppShell 只负责承载 H5 原型当前的页面外壳,不接业务状态,
// 这样顶层入口组件可以更专注在请求、订阅和工作区编排。
</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>{{ props.info }}</strong>
</div>
</section>
<div v-if="props.error" class="error-banner">{{ props.error }}</div>
<slot />
</div>
</template>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import GameActionDock from './GameActionDock.vue'
import GameMessageStack from './GameMessageStack.vue'
import PublicEventTimeline from './PublicEventTimeline.vue'
import PublicSeatBoard from './PublicSeatBoard.vue'
import SelfHandPanel from './SelfHandPanel.vue'
import ViewSwitchPanel from './ViewSwitchPanel.vue'
import type {
DiagnosticItem,
GameStateResponse,
PrivateActionCandidate,
PrivateActionMessage,
PrivateTeachingMessage,
PublicGameMessage,
ScoreChangeCardView,
SettlementCardView,
ViewUserOption
} from '../types/game'
import { phaseLabelMap } from '../utils/gameUi'
const props = defineProps<{
game: GameStateResponse | null
currentUserId: string
currentViewLabel: string
viewUserOptions: ViewUserOption[]
lackSuit: string
canSelectLack: boolean
canDiscard: boolean
recommendedDiscardTile: string | null
privateTeaching: PrivateTeachingMessage | null
privateTeachingHint: string
privateTeachingCandidates: { tile: string; score: number; reasonTags: string[] }[]
privateAction: PrivateActionMessage | null
privateActionSummary: string
actionPanelHint: string
turnActionCandidates: PrivateActionCandidate[]
responseActionCandidates: PrivateActionCandidate[]
responseContextSummary: string
actionDiagnosticItems: DiagnosticItem[]
publicSeats: GameStateResponse['seats']
publicEvents: PublicGameMessage[]
latestSettlementCard: SettlementCardView | null
latestSettlementScoreChanges: ScoreChangeCardView[]
wsStatus: string
}>()
const emit = defineEmits<{
'update:lackSuit': [value: string]
refreshGameState: []
selectLack: []
switchUserView: [userId: string]
discard: [tile: string]
submitCandidateAction: [actionType: string, tile: string | null]
}>()
function updateLackSuit(event: Event) {
emit('update:lackSuit', (event.target as HTMLSelectElement).value)
}
function handleSwitchUserView(userId: string) {
emit('switchUserView', userId)
}
function handleSubmitCandidateAction(actionType: string, tile: string | null) {
emit('submitCandidateAction', actionType, tile)
}
// 这是“对局工作区”级别的容器组件:统一组合手牌、动作、私有消息、公共桌面与事件时间线,
// 让 App 容器只保留接口、WebSocket 和状态清理逻辑。
</script>
<template>
<section v-if="props.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[props.game.phase] ?? props.game.phase }}</span>
</div>
<div class="room-meta">
<div>
<span class="meta-label">对局 ID</span>
<strong>{{ props.game.gameId }}</strong>
</div>
<div>
<span class="meta-label">当前视角</span>
<strong>{{ props.currentViewLabel }}</strong>
</div>
<div>
<span class="meta-label">剩余牌墙</span>
<strong>{{ props.game.remainingWallCount }}</strong>
</div>
</div>
<ViewSwitchPanel :options="props.viewUserOptions" :current-user-id="props.currentUserId" @switch="handleSwitchUserView" />
<div class="btn-row vertical-on-mobile">
<button class="secondary-btn" @click="emit('refreshGameState')">刷新对局</button>
<label class="field compact-field">
<span>定缺</span>
<select :value="props.lackSuit" @change="updateLackSuit">
<option value="WAN"></option>
<option value="TONG"></option>
<option value="TIAO"></option>
</select>
</label>
<button class="primary-btn" :disabled="!props.canSelectLack" @click="emit('selectLack')">提交定缺</button>
</div>
<SelfHandPanel
:self-seat="props.game.selfSeat"
:current-seat-no="props.game.currentSeatNo"
:can-discard="props.canDiscard"
:recommended-discard-tile="props.recommendedDiscardTile"
:teaching-explanation="props.privateTeaching?.explanation ?? null"
@discard="emit('discard', $event)"
/>
<GameActionDock
:private-action="props.privateAction"
:private-action-summary="props.privateActionSummary"
:action-panel-hint="props.actionPanelHint"
:turn-action-candidates="props.turnActionCandidates"
:response-action-candidates="props.responseActionCandidates"
:recommended-discard-tile="props.recommendedDiscardTile"
:response-context-summary="props.responseContextSummary"
@submit="handleSubmitCandidateAction"
/>
</article>
<article class="panel board-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 4</p>
<h2>消息与公共桌面</h2>
</div>
<span class="status-pill">{{ props.wsStatus }}</span>
</div>
<GameMessageStack
:private-action="props.privateAction"
:private-action-summary="props.privateActionSummary"
:action-diagnostic-items="props.actionDiagnosticItems"
:action-panel-hint="props.actionPanelHint"
:private-teaching="props.privateTeaching"
:recommended-discard-tile="props.recommendedDiscardTile"
:teaching-hint="props.privateTeachingHint"
:teaching-candidates="props.privateTeachingCandidates"
/>
<PublicSeatBoard :seats="props.publicSeats" />
<PublicEventTimeline
:public-events="props.publicEvents"
:latest-settlement-card="props.latestSettlementCard"
:latest-settlement-score-changes="props.latestSettlementScoreChanges"
/>
</article>
</section>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { PublicSeatView } from '../types/game'
const props = defineProps<{
seats: PublicSeatView[]
}>()
// 公共桌面区只展示所有人都能看到的信息,不接受任何私有教学或动作上下文,避免消息边界在 UI 层被破坏。
</script>
<template>
<div class="seat-list">
<article v-for="seat in props.seats" :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>
</template>

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
const props = defineProps<{
busy: boolean
ownerId: string
ownerName: string
joinUserId: string
joinUserName: string
roomIdInput: string
}>()
const emit = defineEmits<{
'update:ownerId': [value: string]
'update:ownerName': [value: string]
'update:joinUserId': [value: string]
'update:joinUserName': [value: string]
'update:roomIdInput': [value: string]
createRoom: []
loadRoom: []
switchUserView: [userId: string]
joinRoom: []
}>()
function updateOwnerId(event: Event) {
emit('update:ownerId', (event.target as HTMLInputElement).value)
}
function updateOwnerName(event: Event) {
emit('update:ownerName', (event.target as HTMLInputElement).value)
}
function updateJoinUserId(event: Event) {
emit('update:joinUserId', (event.target as HTMLInputElement).value)
}
function updateJoinUserName(event: Event) {
emit('update:joinUserName', (event.target as HTMLInputElement).value)
}
function updateRoomIdInput(event: Event) {
emit('update:roomIdInput', (event.target as HTMLInputElement).value)
}
// 控制面板只负责采集房间流表单输入与抛出操作意图,真正的请求、报错和状态刷新继续由页面容器掌管。
</script>
<template>
<section class="panel control-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 1</p>
<h2>建房与入房</h2>
</div>
<span class="status-pill" :class="{ busy: props.busy }">{{ props.busy ? '处理中' : '可操作' }}</span>
</div>
<div class="form-card">
<label class="field">
<span>房主 ID</span>
<input :value="props.ownerId" type="text" @input="updateOwnerId" />
</label>
<label class="field">
<span>房主昵称</span>
<input :value="props.ownerName" type="text" @input="updateOwnerName" />
</label>
<button class="primary-btn" @click="emit('createRoom')">创建房间</button>
</div>
<div class="form-card">
<label class="field">
<span>房间 ID</span>
<input
:value="props.roomIdInput"
type="text"
placeholder="创建后自动填充,也可手工输入"
@input="updateRoomIdInput"
/>
</label>
<div class="btn-row">
<button class="secondary-btn" @click="emit('loadRoom')">刷新房间</button>
<button class="secondary-btn" @click="emit('switchUserView', props.ownerId)">切到房主视角</button>
<button class="secondary-btn" @click="emit('switchUserView', props.joinUserId)">切到玩家二视角</button>
</div>
</div>
<div class="form-card">
<label class="field">
<span>加入用户 ID</span>
<input :value="props.joinUserId" type="text" @input="updateJoinUserId" />
</label>
<label class="field">
<span>加入昵称</span>
<input :value="props.joinUserName" type="text" @input="updateJoinUserName" />
</label>
<button class="primary-btn ghost-btn" @click="emit('joinRoom')">模拟加入房间</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import type { RoomSummaryResponse } from '../types/game'
import { phaseLabelMap } from '../utils/gameUi'
const props = defineProps<{
room: RoomSummaryResponse | null
currentUserId: string
ownerId: string
joinUserId: string
}>()
const emit = defineEmits<{
toggleReady: [userId: string, ready: boolean]
startGame: []
}>()
// 这个面板只展示“房间已建好但尚未进入牌局”的公共状态,不承接对局中的私有消息或动作按钮。
</script>
<template>
<section class="panel room-panel">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 2</p>
<h2>准备与开局</h2>
</div>
<span class="status-pill">{{ props.room ? phaseLabelMap[props.room.status] ?? props.room.status : '未建房' }}</span>
</div>
<div v-if="props.room" class="room-summary">
<div class="room-meta">
<div>
<span class="meta-label">房间 ID</span>
<strong>{{ props.room.roomId }}</strong>
</div>
<div>
<span class="meta-label">邀请码</span>
<strong>{{ props.room.inviteCode }}</strong>
</div>
<div>
<span class="meta-label">当前视角</span>
<strong>{{ props.currentUserId }}</strong>
</div>
</div>
<div class="seat-list">
<article v-for="seat in props.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="emit('toggleReady', props.ownerId, true)">房主准备</button>
<button class="secondary-btn" @click="emit('toggleReady', props.joinUserId, true)">玩家二准备</button>
<button class="primary-btn" @click="emit('startGame')">房主开局</button>
</div>
</div>
<div v-else class="placeholder-card">先创建或加载一个房间再进入准备和开局流程</div>
</section>
</template>

View File

@@ -0,0 +1,68 @@
<script setup lang="ts">
import RoomControlPanel from './RoomControlPanel.vue'
import RoomLobbyPanel from './RoomLobbyPanel.vue'
import type { RoomSummaryResponse } from '../types/game'
const props = defineProps<{
busy: boolean
ownerId: string
ownerName: string
joinUserId: string
joinUserName: string
roomIdInput: string
room: RoomSummaryResponse | null
currentUserId: string
}>()
const emit = defineEmits<{
'update:ownerId': [value: string]
'update:ownerName': [value: string]
'update:joinUserId': [value: string]
'update:joinUserName': [value: string]
'update:roomIdInput': [value: string]
createRoom: []
loadRoom: []
switchUserView: [userId: string]
joinRoom: []
toggleReady: [userId: string, ready: boolean]
startGame: []
}>()
function handleToggleReady(userId: string, ready: boolean) {
emit('toggleReady', userId, ready)
}
// 这是“房间工作区”级别的容器组件:负责组合建房、入房、准备、开局两个面板,
// 但真正的数据请求和状态变更仍然留在 App 容器层统一处理。
</script>
<template>
<main class="workspace-grid">
<RoomControlPanel
:busy="props.busy"
:owner-id="props.ownerId"
:owner-name="props.ownerName"
:join-user-id="props.joinUserId"
:join-user-name="props.joinUserName"
:room-id-input="props.roomIdInput"
@update:owner-id="emit('update:ownerId', $event)"
@update:owner-name="emit('update:ownerName', $event)"
@update:join-user-id="emit('update:joinUserId', $event)"
@update:join-user-name="emit('update:joinUserName', $event)"
@update:room-id-input="emit('update:roomIdInput', $event)"
@create-room="emit('createRoom')"
@load-room="emit('loadRoom')"
@switch-user-view="emit('switchUserView', $event)"
@join-room="emit('joinRoom')"
/>
<RoomLobbyPanel
:room="props.room"
:current-user-id="props.currentUserId"
:owner-id="props.ownerId"
:join-user-id="props.joinUserId"
@toggle-ready="handleToggleReady"
@start-game="emit('startGame')"
/>
</main>
</template>

View File

@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { SelfSeatView } from '../types/game'
const props = defineProps<{
selfSeat: SelfSeatView
currentSeatNo: number
canDiscard: boolean
recommendedDiscardTile: string | null
teachingExplanation: string | null
}>()
const emit = defineEmits<{
discard: [tile: string]
}>()
function discard(tile: string) {
// 手牌点击是 H5 最高频操作,继续保留“点击即出牌”的单一入口,不把它混入动作按钮列表。
emit('discard', tile)
}
</script>
<template>
<div class="self-card">
<div class="section-title">
<strong>我的手牌</strong>
<div class="mini-tags">
<span class="mini-pill">当前回合 {{ props.currentSeatNo }}</span>
<span class="mini-tag">积分 {{ props.selfSeat.score }}</span>
<span class="mini-tag">{{ props.selfSeat.won ? '已胡' : '未胡' }}</span>
</div>
</div>
<div v-if="props.recommendedDiscardTile" class="teaching-strip">
<span class="teaching-kicker">教学建议</span>
<strong>建议先打 {{ props.recommendedDiscardTile }}</strong>
<span class="message-copy">{{ props.teachingExplanation }}</span>
</div>
<div class="tile-grid">
<button
v-for="(tile, index) in props.selfSeat.handTiles"
:key="`${tile}-${index}`"
class="tile-chip"
:class="{ recommended: tile === props.recommendedDiscardTile }"
:disabled="!props.canDiscard"
@click="discard(tile)"
>
{{ tile }}
<span v-if="tile === props.recommendedDiscardTile" class="tile-tip"></span>
</button>
</div>
<div class="discard-row" v-if="props.selfSeat.melds.length > 0">
<span
v-for="(meld, index) in props.selfSeat.melds"
:key="`self-meld-${meld}-${index}`"
class="discard-chip"
>
{{ meld }}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { ViewUserOption } from '../types/game'
const props = defineProps<{
options: ViewUserOption[]
currentUserId: string
}>()
const emit = defineEmits<{
switch: [userId: string]
}>()
function switchView(userId: string) {
// 视角切换会触发页面容器重新拉取 state 并重连私有主题,因此子组件只发出意图,不自行处理副作用。
emit('switch', userId)
}
</script>
<template>
<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 props.options"
:key="option.userId"
class="view-switch-chip"
:class="{ active: option.userId === props.currentUserId }"
@click="switchView(option.userId)"
>
{{ option.label }}
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import GameWorkspace from '../components/GameWorkspace.vue'
import type {
CandidateAdviceItem,
DiagnosticItem,
GameStateResponse,
PrivateActionCandidate,
PrivateActionMessage,
PrivateTeachingMessage,
PublicGameMessage,
ScoreChangeCardView,
SettlementCardView,
ViewUserOption
} from '../types/game'
const props = defineProps<{
game: GameStateResponse | null
currentUserId: string
currentViewLabel: string
viewUserOptions: ViewUserOption[]
lackSuit: string
canSelectLack: boolean
canDiscard: boolean
recommendedDiscardTile: string | null
privateTeaching: PrivateTeachingMessage | null
privateTeachingHint: string
privateTeachingCandidates: CandidateAdviceItem[]
privateAction: PrivateActionMessage | null
privateActionSummary: string
actionPanelHint: string
turnActionCandidates: PrivateActionCandidate[]
responseActionCandidates: PrivateActionCandidate[]
responseContextSummary: string
actionDiagnosticItems: DiagnosticItem[]
publicSeats: GameStateResponse['seats']
publicEvents: PublicGameMessage[]
latestSettlementCard: SettlementCardView | null
latestSettlementScoreChanges: ScoreChangeCardView[]
wsStatus: string
}>()
const emit = defineEmits<{
'update:lackSuit': [value: string]
refreshGameState: []
selectLack: []
switchUserView: [userId: string]
discard: [tile: string]
submitCandidateAction: [actionType: string, tile: string | null]
}>()
function handleSubmitCandidateAction(actionType: string, tile: string | null) {
emit('submitCandidateAction', actionType, tile)
}
// 这里把“对局工作区”正式提升为页面级容器命名,后续接入真正的 GamePage 时可以保持现有 props / emits 边界不变。
</script>
<template>
<GameWorkspace
:game="props.game"
:current-user-id="props.currentUserId"
:current-view-label="props.currentViewLabel"
:view-user-options="props.viewUserOptions"
:lack-suit="props.lackSuit"
:can-select-lack="props.canSelectLack"
:can-discard="props.canDiscard"
:recommended-discard-tile="props.recommendedDiscardTile"
:private-teaching="props.privateTeaching"
:private-teaching-hint="props.privateTeachingHint"
:private-teaching-candidates="props.privateTeachingCandidates"
:private-action="props.privateAction"
:private-action-summary="props.privateActionSummary"
:action-panel-hint="props.actionPanelHint"
:turn-action-candidates="props.turnActionCandidates"
:response-action-candidates="props.responseActionCandidates"
:response-context-summary="props.responseContextSummary"
:action-diagnostic-items="props.actionDiagnosticItems"
:public-seats="props.publicSeats"
:public-events="props.publicEvents"
:latest-settlement-card="props.latestSettlementCard"
:latest-settlement-score-changes="props.latestSettlementScoreChanges"
:ws-status="props.wsStatus"
@update:lack-suit="emit('update:lackSuit', $event)"
@refresh-game-state="emit('refreshGameState')"
@select-lack="emit('selectLack')"
@switch-user-view="emit('switchUserView', $event)"
@discard="emit('discard', $event)"
@submit-candidate-action="handleSubmitCandidateAction"
/>
</template>

View File

@@ -0,0 +1,156 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { ReviewSummaryResponse } from '../types/review'
const props = withDefaults(
defineProps<{
review?: ReviewSummaryResponse | null
loading?: boolean
}>(),
{
review: null,
loading: false
}
)
const emit = defineEmits<{
loadReview: []
}>()
const reviewModules = [
{
title: '个人结算总览',
description: '汇总本局得分变化、胡牌来源、番型结构和关键结算节点。'
},
{
title: '关键失误回看',
description: '按个人视角标注高风险弃牌、错失胡牌窗口和更优替代动作。'
},
{
title: '错题与练习沉淀',
description: '把可复用的问题局面沉淀为后续训练题,而不是停留在一次性结果页。'
}
] as const
const hasReviewData = computed(() => props.review !== null)
function formatScore(score: number) {
return score > 0 ? `+${score}` : `${score}`
}
function formatSeverity(severity: string) {
switch (severity) {
case 'HIGH':
return '高风险'
case 'MEDIUM':
return '中风险'
case 'LOW':
return '低风险'
default:
return severity
}
}
// 当前组件已经接入真实复盘协议,
// 但仍允许“暂无个人结算”的空态,避免首版服务在对局前期没有样本时出现空白页。
</script>
<template>
<section class="panel review-panel-placeholder">
<div class="panel-head">
<div>
<p class="eyebrow">步骤 5</p>
<h2>局后复盘页骨架</h2>
</div>
<div class="mini-tags">
<span class="status-pill">{{ hasReviewData ? '真实复盘已加载' : '待加载' }}</span>
<button class="secondary-btn review-load-btn" :disabled="props.loading" @click="emit('loadReview')">
{{ props.loading ? '加载中' : hasReviewData ? '刷新当前视角复盘' : '加载当前视角复盘' }}
</button>
</div>
</div>
<div v-if="props.review" class="review-stack">
<article class="placeholder-card">
<div class="section-title">
<strong>{{ props.review.playerNickname }} · 座位 {{ props.review.seatNo }}</strong>
<span class="mini-pill">{{ props.review.resultLabel }}</span>
</div>
<div class="mini-tags">
<span class="mini-tag">对局 {{ props.review.gameId }}</span>
<span class="mini-tag">用户 {{ props.review.userId }}</span>
<span class="mini-tag">净分 {{ formatScore(props.review.finalScore) }}</span>
</div>
<p class="message-copy">{{ props.review.conclusion }}</p>
</article>
<article class="placeholder-card">
<div class="section-title">
<strong>关键结算</strong>
<span class="mini-pill">{{ props.review.settlementTimeline.length }} </span>
</div>
<div class="seat-list">
<article v-for="item in props.review.settlementTimeline" :key="item.title" class="seat-card seat-card-wide">
<div class="seat-top">
<strong>{{ item.title }}</strong>
<span class="mini-pill">{{ formatScore(item.scoreDelta) }}</span>
</div>
<span class="message-copy">{{ item.summary }}</span>
<div class="mini-tags">
<span v-for="fanLabel in item.fanLabels" :key="`${item.title}-${fanLabel}`" class="mini-tag">{{ fanLabel }}</span>
</div>
</article>
</div>
</article>
<article class="placeholder-card">
<div class="section-title">
<strong>关键失误</strong>
<span class="mini-pill">{{ props.review.mistakeInsights.length }} </span>
</div>
<div class="seat-list">
<article v-for="item in props.review.mistakeInsights" :key="item.title" class="seat-card seat-card-wide">
<div class="seat-top">
<strong>{{ item.title }}</strong>
<span class="mini-pill">{{ formatSeverity(item.severity) }}</span>
</div>
<span class="message-copy">{{ item.issue }}</span>
<span class="message-copy">{{ item.suggestion }}</span>
</article>
</div>
</article>
<article class="placeholder-card">
<div class="section-title">
<strong>训练方向</strong>
<span class="mini-pill">{{ props.review.trainingFocuses.length }} </span>
</div>
<div class="seat-list">
<article v-for="item in props.review.trainingFocuses" :key="item.title" class="seat-card seat-card-wide">
<div class="seat-top">
<strong>{{ item.title }}</strong>
<span class="mini-pill">{{ item.drillType }}</span>
</div>
<span class="message-copy">{{ item.description }}</span>
</article>
</div>
</article>
</div>
<div v-else class="placeholder-card">
<p class="intro compact-copy">
当前复盘页已经接入真实 `ReviewSummaryResponse` 协议并会按当前对局当前视角读取真实结算历史生成摘要
若还没有开始对局或尚未产生个人相关结算这里会显示最小占位信息而不是继续使用 demo 文案
</p>
<div class="seat-list">
<article v-for="module in reviewModules" :key="module.title" class="seat-card seat-card-wide">
<div class="seat-top">
<strong>{{ module.title }}</strong>
<span class="mini-pill">规划中</span>
</div>
<span class="message-copy">{{ module.description }}</span>
</article>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import RoomWorkspace from '../components/RoomWorkspace.vue'
import type { RoomSummaryResponse } from '../types/game'
const props = defineProps<{
busy: boolean
ownerId: string
ownerName: string
joinUserId: string
joinUserName: string
roomIdInput: string
room: RoomSummaryResponse | null
currentUserId: string
}>()
const emit = defineEmits<{
'update:ownerId': [value: string]
'update:ownerName': [value: string]
'update:joinUserId': [value: string]
'update:joinUserName': [value: string]
'update:roomIdInput': [value: string]
createRoom: []
loadRoom: []
switchUserView: [userId: string]
joinRoom: []
toggleReady: [userId: string, ready: boolean]
startGame: []
}>()
function handleToggleReady(userId: string, ready: boolean) {
emit('toggleReady', userId, ready)
}
// 这里开始把“房间工作区”正式提升为页面级容器命名,后续即使接入路由,也可以直接以这个组件作为 RoomPage 入口。
</script>
<template>
<RoomWorkspace
:busy="props.busy"
:owner-id="props.ownerId"
:owner-name="props.ownerName"
:join-user-id="props.joinUserId"
:join-user-name="props.joinUserName"
:room-id-input="props.roomIdInput"
:room="props.room"
:current-user-id="props.currentUserId"
@update:owner-id="emit('update:ownerId', $event)"
@update:owner-name="emit('update:ownerName', $event)"
@update:join-user-id="emit('update:joinUserId', $event)"
@update:join-user-name="emit('update:joinUserName', $event)"
@update:room-id-input="emit('update:roomIdInput', $event)"
@create-room="emit('createRoom')"
@load-room="emit('loadRoom')"
@switch-user-view="emit('switchUserView', $event)"
@join-room="emit('joinRoom')"
@toggle-ready="handleToggleReady"
@start-game="emit('startGame')"
/>
</template>

View File

@@ -0,0 +1,34 @@
// 复盘页单独维护一份类型定义,避免把“局中状态”和“局后分析”继续混在同一个类型文件里。
export type ReviewSettlementItem = {
title: string
summary: string
scoreDelta: number
fanLabels: string[]
}
export type ReviewMistakeItem = {
severity: string
title: string
issue: string
suggestion: string
}
export type ReviewTrainingFocusItem = {
drillType: string
title: string
description: string
}
export type ReviewSummaryResponse = {
gameId: string
userId: string
playerNickname: string
seatNo: number
finalScore: number
resultLabel: string
conclusion: string
settlementTimeline: ReviewSettlementItem[]
mistakeInsights: ReviewMistakeItem[]
trainingFocuses: ReviewTrainingFocusItem[]
}