feat(教学): 扩展教学建议链路以支持候选牌列表

扩展教学建议链路,在 PrivateTeachingMessage 中增加 candidates 字段,支持前端展示候选牌、评分和原因标签。同时优化前端组件结构,抽离共享类型和工具函数,为后续页面拆分做准备。

- 后端:在 GameSessionService 和 GameMessagePublisher 中透传候选牌列表
- 前端:新增 GameMessageStack 组件展示教学候选,优化手牌区推荐牌高亮
- 测试:补充 GameMessagePublisherTest 验证候选牌消息结构
- 文档:更新 DEVELOPMENT_PLAN 和 H5_GAME_PAGE_ARCHITECTURE 说明当前前端结构
This commit is contained in:
hujun
2026-03-20 15:54:05 +08:00
parent 6bcdf26fca
commit 905565e7c4
17 changed files with 1325 additions and 433 deletions

View File

@@ -1,105 +1,31 @@
<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
won: boolean
lackSuit: string | null
score: number
handTiles: string[]
discardTiles: string[]
melds: string[]
}
type PublicSeatView = {
seatNo: number
playerId: string
nickname: string
ai: boolean
won: boolean
lackSuit: string | null
score: number
handCount: number
discardTiles: string[]
melds: 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 PrivateActionCandidate = {
actionType: string
tile: string | null
}
type PrivateActionMessage = {
gameId: string
userId: string
actionScope: string
availableActions: string[]
currentSeatNo: number
windowId: string | null
triggerEventType: string | null
sourceSeatNo: number | null
triggerTile: string | null
candidates: PrivateActionCandidate[]
}
type PrivateTeachingMessage = {
gameId: string
userId: string
teachingMode: string
recommendedAction: string
explanation: string
}
type ViewUserOption = {
userId: string
label: string
seatNo: number
}
import GameActionDock from './components/GameActionDock.vue'
import GameMessageStack from './components/GameMessageStack.vue'
import PublicEventTimeline from './components/PublicEventTimeline.vue'
import type {
ApiResponse,
DiagnosticItem,
GameStateResponse,
PrivateActionMessage,
PrivateTeachingMessage,
PublicGameMessage,
RoomSummaryResponse,
ScoreChangeCardView,
SettlementCardView,
ViewUserOption
} from './types/game'
import {
actionScopeLabelMap,
formatSettlementType,
readNumber,
readString,
phaseLabelMap,
toActionLabel,
toSettlementDetailView,
triggerEventTypeLabelMap
} from './utils/gameUi'
const busy = ref(false)
const error = ref('')
@@ -122,51 +48,6 @@ 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 actionScopeLabelMap: Record<string, string> = {
TURN: '当前回合动作',
RESPONSE: '响应候选动作'
}
const actionTypeLabelMap: Record<string, string> = {
SELECT_LACK_SUIT: '定缺',
DISCARD: '出牌',
PENG: '碰',
GANG: '杠',
HU: '胡',
PASS: '过'
}
const triggerEventTypeLabelMap: Record<string, string> = {
TILE_DISCARDED: '弃牌后响应',
SUPPLEMENTAL_GANG_DECLARED: '补杠后抢杠胡'
}
const publicEventLabelMap: Record<string, string> = {
GAME_STARTED: '对局开始',
LACK_SELECTED: '定缺完成',
GAME_PHASE_CHANGED: '阶段切换',
TILE_DISCARDED: '出牌',
TILE_DRAWN: '摸牌',
TURN_SWITCHED: '轮转',
RESPONSE_WINDOW_OPENED: '响应窗口开启',
RESPONSE_WINDOW_CLOSED: '响应窗口关闭',
PENG_DECLARED: '碰牌宣告',
GANG_DECLARED: '杠牌宣告',
HU_DECLARED: '胡牌宣告',
PASS_DECLARED: '过牌宣告',
SETTLEMENT_APPLIED: '结算应用',
SCORE_CHANGED: '分数变化',
ACTION_REQUIRED: '动作提醒'
}
const canSelectLack = computed(() => game.value?.phase === 'LACK_SELECTION')
const canDiscard = computed(
() =>
@@ -178,8 +59,6 @@ const canDiscard = computed(
const publicSeats = computed(() => game.value?.seats ?? [])
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
const privateActionSummary = computed(() => {
if (!privateAction.value) {
return ''
@@ -227,6 +106,29 @@ const actionPanelHint = computed(() => {
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
@@ -258,7 +160,7 @@ const viewUserOptions = computed<ViewUserOption[]>(() => {
]
})
const actionDiagnosticItems = computed(() => {
const actionDiagnosticItems = computed<DiagnosticItem[]>(() => {
if (!privateAction.value) {
return []
}
@@ -288,6 +190,41 @@ const actionDiagnosticItems = computed(() => {
]
})
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: {
@@ -546,18 +483,6 @@ function submitCandidateAction(actionType: string, tile: string | null) {
return submitAction(actionType, tile ?? undefined, sourceSeatNo)
}
function toActionLabel(actionType: string) {
return actionTypeLabelMap[actionType] ?? actionType
}
function formatCandidateLabel(candidate: PrivateActionCandidate) {
const actionLabel = toActionLabel(candidate.actionType)
if (!candidate.tile) {
return actionLabel
}
return `${actionLabel} · ${candidate.tile}`
}
function handlePublicEvent(event: PublicGameMessage) {
// 公共事件除了进入时间线,也负责驱动前端把已经失效的私有动作面板收起来。
if (shouldClearPrivateActionByEvent(event)) {
@@ -594,104 +519,6 @@ function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
return false
}
function readString(value: unknown) {
return typeof value === 'string' ? value : null
}
function readNumber(value: unknown) {
return typeof value === 'number' ? value : null
}
function formatSeatLabel(seatNo: number | null | undefined) {
if (seatNo === null || seatNo === undefined) {
return '未知座位'
}
return `座位 ${seatNo}`
}
function formatPhaseLabel(phase: string | null) {
if (!phase) {
return '未知阶段'
}
return phaseLabelMap[phase] ?? phase
}
function formatPublicEventTitle(event: PublicGameMessage) {
return publicEventLabelMap[event.eventType] ?? event.eventType
}
function formatPublicEventSummary(event: PublicGameMessage) {
switch (event.eventType) {
case 'GAME_STARTED':
return `房间 ${readString(event.payload.roomId) ?? '-'} 已开局。`
case 'LACK_SELECTED':
return `${formatSeatLabel(event.seatNo)} 已完成定缺 ${readString(event.payload.lackSuit) ?? '-' }`
case 'GAME_PHASE_CHANGED':
return `阶段切换为 ${formatPhaseLabel(readString(event.payload.phase))}`
case 'TILE_DISCARDED':
return `${formatSeatLabel(event.seatNo)} 打出 ${readString(event.payload.tile) ?? '-'}`
case 'TILE_DRAWN':
return `${formatSeatLabel(event.seatNo)} 完成摸牌,牌墙剩余 ${readNumber(event.payload.remainingWallCount) ?? '-'} 张。`
case 'TURN_SWITCHED':
return `当前轮到 ${formatSeatLabel(readNumber(event.payload.currentSeatNo) ?? event.seatNo)}`
case 'RESPONSE_WINDOW_OPENED':
return `${formatSeatLabel(readNumber(event.payload.sourceSeatNo))}${readString(event.payload.tile) ?? '-'} 打开响应窗口。`
case 'RESPONSE_WINDOW_CLOSED':
return `响应窗口已关闭,最终裁决 ${toActionLabel(readString(event.payload.resolvedActionType) ?? '-')}`
case 'PENG_DECLARED':
case 'GANG_DECLARED':
case 'HU_DECLARED':
case 'PASS_DECLARED':
return `${formatSeatLabel(event.seatNo)} 执行 ${toActionLabel(readString(event.payload.actionType) ?? event.eventType.replace('_DECLARED', ''))}${readString(event.payload.tile) ? ` · ${readString(event.payload.tile)}` : ''}`
case 'SETTLEMENT_APPLIED':
return formatSettlementSummary(event)
case 'SCORE_CHANGED':
return `${formatSeatLabel(event.seatNo)} 分数变化 ${formatScoreDelta(readNumber(event.payload.delta))},当前 ${readNumber(event.payload.score) ?? '-'}`
default:
return JSON.stringify(event.payload)
}
}
function formatSettlementSummary(event: PublicGameMessage) {
const settlementType = readString(event.payload.settlementType) ?? '未知结算'
const triggerTile = readString(event.payload.triggerTile)
const detail = asRecord(event.payload.settlementDetail)
const paymentScore = readNumber(detail?.paymentScore)
const totalFan = readNumber(detail?.totalFan)
return `${settlementType}${triggerTile ? ` · ${triggerTile}` : ''}${paymentScore ?? '-'} 分,${totalFan ?? 0} 番。`
}
function formatScoreDelta(delta: number | null) {
if (delta === null) {
return '-'
}
return delta > 0 ? `+${delta}` : `${delta}`
}
function asRecord(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as Record<string, unknown>
}
function formatEventPayload(event: PublicGameMessage) {
return JSON.stringify(event.payload, null, 2)
}
function formatEventTime(createdAt: string) {
const date = new Date(createdAt)
if (Number.isNaN(date.getTime())) {
return createdAt
}
return date.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
</script>
<template>
@@ -873,15 +700,22 @@ function formatEventTime(createdAt: string) {
<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">
@@ -895,62 +729,16 @@ function formatEventTime(createdAt: string) {
</div>
</div>
<div class="action-dock">
<div class="section-title">
<strong>动作面板</strong>
<span class="mini-pill">{{ privateActionSummary || '等待动作' }}</span>
</div>
<div v-if="privateAction" class="action-panel" :class="{ 'response-panel': privateAction.actionScope === 'RESPONSE' }">
<div class="mini-tags action-meta-row">
<span class="mini-tag">当前操作座位 {{ privateAction.currentSeatNo }}</span>
<span class="mini-tag">{{ actionScopeLabelMap[privateAction.actionScope] ?? privateAction.actionScope }}</span>
<span v-if="privateAction.windowId" class="mini-tag">窗口 {{ privateAction.windowId.slice(0, 8) }}</span>
</div>
<div v-if="privateAction.actionScope === 'TURN'" class="turn-panel">
<p class="message-copy">{{ actionPanelHint }}</p>
<div class="candidate-list" v-if="turnActionCandidates.length > 0">
<button
v-for="(candidate, index) in turnActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip action-call-chip"
type="button"
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
>
{{ formatCandidateLabel(candidate) }}
</button>
</div>
<div class="helper-strip">
<span class="helper-chip">出牌直接点击上方手牌</span>
<span class="helper-chip"> / 自摸胡点击这里的动作按钮</span>
</div>
</div>
<div v-else class="response-panel-body">
<div class="response-banner">
<span class="response-kicker">响应窗口</span>
<strong>{{ responseContextSummary }}</strong>
<span class="message-copy">{{ actionPanelHint }}</span>
</div>
<div class="candidate-list" v-if="responseActionCandidates.length > 0">
<button
v-for="(candidate, index) in responseActionCandidates"
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
class="candidate-chip action-call-chip"
type="button"
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
>
{{ formatCandidateLabel(candidate) }}
</button>
</div>
</div>
</div>
<div v-else class="placeholder-card action-placeholder">
当前还没有可执行动作若轮到你出牌可自摸胡可杠或遇到碰杠胡响应窗口这里会自动出现对应按钮
</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">
@@ -962,29 +750,16 @@ function formatEventTime(createdAt: string) {
<span class="status-pill">{{ wsStatus }}</span>
</div>
<div class="message-stack">
<div class="message-card">
<span class="meta-label">私有动作消息</span>
<template v-if="privateAction">
<strong>{{ privateActionSummary }}</strong>
<div class="message-grid">
<div v-for="item in actionDiagnosticItems" :key="item.key" class="metric-cell">
<span class="meta-label">{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<span class="message-copy">{{ actionPanelHint }}</span>
</template>
<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>
<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">
@@ -1014,30 +789,11 @@ function formatEventTime(createdAt: string) {
</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>{{ formatPublicEventTitle(event) }}</strong>
<span class="mini-pill">{{ formatEventTime(event.createdAt) }}</span>
</div>
<div class="timeline-meta">
<span class="mini-tag">{{ formatSeatLabel(event.seatNo) }}</span>
<span class="mini-tag">{{ event.eventType }}</span>
</div>
<div class="message-copy">{{ formatPublicEventSummary(event) }}</div>
<details class="payload-details">
<summary>查看原始载荷</summary>
<pre class="payload-code">{{ formatEventPayload(event) }}</pre>
</details>
</article>
</div>
</div>
<PublicEventTimeline
:public-events="publicEvents"
:latest-settlement-card="latestSettlementCard"
:latest-settlement-score-changes="latestSettlementScoreChanges"
/>
</article>
</section>
</div>