feat(教学): 扩展教学建议链路以支持候选牌列表
扩展教学建议链路,在 PrivateTeachingMessage 中增加 candidates 字段,支持前端展示候选牌、评分和原因标签。同时优化前端组件结构,抽离共享类型和工具函数,为后续页面拆分做准备。 - 后端:在 GameSessionService 和 GameMessagePublisher 中透传候选牌列表 - 前端:新增 GameMessageStack 组件展示教学候选,优化手牌区推荐牌高亮 - 测试:补充 GameMessagePublisherTest 验证候选牌消息结构 - 文档:更新 DEVELOPMENT_PLAN 和 H5_GAME_PAGE_ARCHITECTURE 说明当前前端结构
This commit is contained in:
@@ -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>
|
||||
|
||||
85
frontend/src/components/GameActionDock.vue
Normal file
85
frontend/src/components/GameActionDock.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrivateActionCandidate, PrivateActionMessage } from '../types/game'
|
||||
import { actionScopeLabelMap, formatCandidateLabel } from '../utils/gameUi'
|
||||
|
||||
const props = defineProps<{
|
||||
privateAction: PrivateActionMessage | null
|
||||
privateActionSummary: string
|
||||
actionPanelHint: string
|
||||
turnActionCandidates: PrivateActionCandidate[]
|
||||
responseActionCandidates: PrivateActionCandidate[]
|
||||
recommendedDiscardTile: string | null
|
||||
responseContextSummary: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [actionType: string, tile: string | null]
|
||||
}>()
|
||||
|
||||
function submitCandidate(candidate: PrivateActionCandidate) {
|
||||
// 动作面板只负责把候选动作原样抛回页面容器,不在子组件里自行拼接来源座位或调用接口。
|
||||
emit('submit', candidate.actionType, candidate.tile)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-dock">
|
||||
<div class="section-title">
|
||||
<strong>动作面板</strong>
|
||||
<span class="mini-pill">{{ privateActionSummary || '等待动作' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.privateAction" class="action-panel" :class="{ 'response-panel': props.privateAction.actionScope === 'RESPONSE' }">
|
||||
<div class="mini-tags action-meta-row">
|
||||
<span class="mini-tag">当前操作座位 {{ props.privateAction.currentSeatNo }}</span>
|
||||
<span class="mini-tag">{{ actionScopeLabelMap[props.privateAction.actionScope] ?? props.privateAction.actionScope }}</span>
|
||||
<span v-if="props.privateAction.windowId" class="mini-tag">窗口 {{ props.privateAction.windowId.slice(0, 8) }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="props.privateAction.actionScope === 'TURN'" class="turn-panel">
|
||||
<p class="message-copy">{{ props.actionPanelHint }}</p>
|
||||
<div class="candidate-list" v-if="props.turnActionCandidates.length > 0">
|
||||
<button
|
||||
v-for="(candidate, index) in props.turnActionCandidates"
|
||||
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
||||
class="candidate-chip action-call-chip"
|
||||
type="button"
|
||||
@click="submitCandidate(candidate)"
|
||||
>
|
||||
{{ formatCandidateLabel(candidate) }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="helper-strip">
|
||||
<span class="helper-chip">出牌:直接点击上方手牌</span>
|
||||
<span class="helper-chip">杠 / 自摸胡:点击这里的动作按钮</span>
|
||||
<span v-if="props.recommendedDiscardTile" class="helper-chip helper-chip-accent">
|
||||
建议先打 {{ props.recommendedDiscardTile }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="response-panel-body">
|
||||
<div class="response-banner">
|
||||
<span class="response-kicker">响应窗口</span>
|
||||
<strong>{{ props.responseContextSummary }}</strong>
|
||||
<span class="message-copy">{{ props.actionPanelHint }}</span>
|
||||
</div>
|
||||
<div class="candidate-list" v-if="props.responseActionCandidates.length > 0">
|
||||
<button
|
||||
v-for="(candidate, index) in props.responseActionCandidates"
|
||||
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
||||
class="candidate-chip action-call-chip"
|
||||
type="button"
|
||||
@click="submitCandidate(candidate)"
|
||||
>
|
||||
{{ formatCandidateLabel(candidate) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="placeholder-card action-placeholder">
|
||||
当前还没有可执行动作。若轮到你出牌、可自摸胡、可杠,或遇到碰杠胡响应窗口,这里会自动出现对应按钮。
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
70
frontend/src/components/GameMessageStack.vue
Normal file
70
frontend/src/components/GameMessageStack.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
CandidateAdviceItem,
|
||||
DiagnosticItem,
|
||||
PrivateActionMessage,
|
||||
PrivateTeachingMessage
|
||||
} from '../types/game'
|
||||
import { formatReasonTag } from '../utils/gameUi'
|
||||
|
||||
const props = defineProps<{
|
||||
privateAction: PrivateActionMessage | null
|
||||
privateActionSummary: string
|
||||
actionDiagnosticItems: DiagnosticItem[]
|
||||
actionPanelHint: string
|
||||
privateTeaching: PrivateTeachingMessage | null
|
||||
recommendedDiscardTile: string | null
|
||||
teachingHint: string
|
||||
teachingCandidates: CandidateAdviceItem[]
|
||||
}>()
|
||||
|
||||
// 这个组件只承接“私有消息栈”展示,确保教学消息不会和公共事件时间线混到同一块区域里。
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message-stack">
|
||||
<div class="message-card">
|
||||
<span class="meta-label">私有动作消息</span>
|
||||
<template v-if="props.privateAction">
|
||||
<strong>{{ props.privateActionSummary }}</strong>
|
||||
<div class="message-grid">
|
||||
<div v-for="item in props.actionDiagnosticItems" :key="item.key" class="metric-cell">
|
||||
<span class="meta-label">{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<span class="message-copy">{{ props.actionPanelHint }}</span>
|
||||
</template>
|
||||
<span v-else class="empty-copy">尚未收到私有动作消息</span>
|
||||
</div>
|
||||
|
||||
<div class="message-card">
|
||||
<span class="meta-label">私有教学消息</span>
|
||||
<strong v-if="props.privateTeaching">{{ props.privateTeaching.recommendedAction }}</strong>
|
||||
<div v-if="props.recommendedDiscardTile" class="teaching-pill-row">
|
||||
<span class="fan-chip">当前推荐 {{ props.recommendedDiscardTile }}</span>
|
||||
</div>
|
||||
<span v-if="props.privateTeaching" class="message-copy">{{ props.privateTeaching.explanation }}</span>
|
||||
<span v-if="props.privateTeaching" class="message-copy">{{ props.teachingHint }}</span>
|
||||
<div v-if="props.teachingCandidates.length > 0" class="teaching-candidate-list">
|
||||
<article
|
||||
v-for="candidate in props.teachingCandidates"
|
||||
:key="`${candidate.tile}-${candidate.score}`"
|
||||
class="teaching-candidate-card"
|
||||
:class="{ active: candidate.tile === props.recommendedDiscardTile }"
|
||||
>
|
||||
<div class="seat-top">
|
||||
<strong>{{ candidate.tile }}</strong>
|
||||
<span class="mini-pill">评分 {{ candidate.score }}</span>
|
||||
</div>
|
||||
<div class="mini-tags">
|
||||
<span v-for="reasonTag in candidate.reasonTags" :key="`${candidate.tile}-${reasonTag}`" class="mini-tag">
|
||||
{{ formatReasonTag(reasonTag) }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<span v-else class="empty-copy">尚未收到私有教学消息</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
101
frontend/src/components/PublicEventTimeline.vue
Normal file
101
frontend/src/components/PublicEventTimeline.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import type { PublicGameMessage, ScoreChangeCardView, SettlementCardView } from '../types/game'
|
||||
import {
|
||||
formatEventPayload,
|
||||
formatEventTime,
|
||||
formatPublicEventSummary,
|
||||
formatPublicEventTitle,
|
||||
formatScoreDelta,
|
||||
formatSeatLabel
|
||||
} from '../utils/gameUi'
|
||||
|
||||
const props = defineProps<{
|
||||
publicEvents: PublicGameMessage[]
|
||||
latestSettlementCard: SettlementCardView | null
|
||||
latestSettlementScoreChanges: ScoreChangeCardView[]
|
||||
}>()
|
||||
|
||||
// 公共事件时间线是“调试信息 + 正式公共桌面回放”的统一出口,
|
||||
// 因此把最近结算卡和原始事件流放在同一组件中维护展示顺序。
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="event-timeline">
|
||||
<div class="section-title">
|
||||
<strong>公共事件</strong>
|
||||
<span class="mini-pill">{{ props.publicEvents.length }} 条</span>
|
||||
</div>
|
||||
<div v-if="props.latestSettlementCard" class="settlement-board">
|
||||
<article class="settlement-card">
|
||||
<div class="seat-top">
|
||||
<strong>最近结算</strong>
|
||||
<span class="mini-pill">{{ props.latestSettlementCard.settlementType }}</span>
|
||||
</div>
|
||||
<div class="timeline-meta">
|
||||
<span class="mini-tag">动作 {{ props.latestSettlementCard.actionType }}</span>
|
||||
<span class="mini-tag">执行 {{ formatSeatLabel(props.latestSettlementCard.actorSeatNo) }}</span>
|
||||
<span v-if="props.latestSettlementCard.sourceSeatNo !== null" class="mini-tag">
|
||||
来源 {{ formatSeatLabel(props.latestSettlementCard.sourceSeatNo) }}
|
||||
</span>
|
||||
<span v-if="props.latestSettlementCard.triggerTile" class="mini-tag">
|
||||
目标牌 {{ props.latestSettlementCard.triggerTile }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="props.latestSettlementCard.detail" class="settlement-metrics">
|
||||
<div class="metric-cell">
|
||||
<span class="meta-label">基础分</span>
|
||||
<strong>{{ props.latestSettlementCard.detail.baseScore }}</strong>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<span class="meta-label">总番数</span>
|
||||
<strong>{{ props.latestSettlementCard.detail.totalFan }}</strong>
|
||||
</div>
|
||||
<div class="metric-cell">
|
||||
<span class="meta-label">赔付分</span>
|
||||
<strong>{{ props.latestSettlementCard.detail.paymentScore }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="props.latestSettlementCard.detail?.fans.length" class="fan-list">
|
||||
<span v-for="fan in props.latestSettlementCard.detail.fans" :key="`${fan.code}-${fan.fan}`" class="fan-chip">
|
||||
{{ fan.label }} · {{ fan.fan }} 番
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
<article v-if="props.latestSettlementScoreChanges.length > 0" class="settlement-card score-card">
|
||||
<div class="seat-top">
|
||||
<strong>对应分数变化</strong>
|
||||
<span class="mini-pill">{{ props.latestSettlementScoreChanges.length }} 条</span>
|
||||
</div>
|
||||
<div class="score-change-list">
|
||||
<div
|
||||
v-for="change in props.latestSettlementScoreChanges"
|
||||
:key="`${change.seatNo}-${change.delta}-${change.score}`"
|
||||
class="score-change-row"
|
||||
>
|
||||
<span class="mini-tag">{{ formatSeatLabel(change.seatNo) }}</span>
|
||||
<strong :class="change.delta >= 0 ? 'score-up' : 'score-down'">{{ formatScoreDelta(change.delta) }}</strong>
|
||||
<span class="meta-label">当前 {{ change.score ?? '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="props.publicEvents.length === 0" class="placeholder-card">还没有收到公共事件,开局或执行动作后会自动出现。</div>
|
||||
<div v-else class="timeline-list">
|
||||
<article v-for="(event, index) in props.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>
|
||||
</template>
|
||||
@@ -320,6 +320,32 @@ h2 {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tile-chip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tile-chip.recommended {
|
||||
border-color: rgba(182, 69, 31, 0.42);
|
||||
box-shadow: 0 14px 28px rgba(182, 69, 31, 0.18);
|
||||
background: linear-gradient(180deg, #fff6e8 0%, #f6d9b7 100%);
|
||||
}
|
||||
|
||||
.tile-tip {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: #b6451f;
|
||||
color: #fffaf2;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.discard-chip {
|
||||
min-height: auto;
|
||||
padding: 8px 12px;
|
||||
@@ -336,7 +362,8 @@ h2 {
|
||||
|
||||
.message-stack,
|
||||
.event-timeline,
|
||||
.timeline-list {
|
||||
.timeline-list,
|
||||
.settlement-board {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@@ -360,6 +387,98 @@ h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.settlement-board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settlement-card {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(180deg, rgba(255, 248, 237, 0.98) 0%, rgba(247, 236, 220, 0.94) 100%);
|
||||
border: 1px solid rgba(113, 82, 47, 0.14);
|
||||
}
|
||||
|
||||
.score-card {
|
||||
background: linear-gradient(180deg, rgba(255, 252, 246, 0.98) 0%, rgba(243, 235, 225, 0.96) 100%);
|
||||
}
|
||||
|
||||
.settlement-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.fan-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.fan-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(182, 69, 31, 0.1);
|
||||
color: var(--accent-deep);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.teaching-pill-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.teaching-candidate-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.teaching-candidate-card {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.54);
|
||||
border: 1px solid rgba(113, 82, 47, 0.12);
|
||||
}
|
||||
|
||||
.teaching-candidate-card.active {
|
||||
border-color: rgba(182, 69, 31, 0.26);
|
||||
box-shadow: 0 12px 24px rgba(182, 69, 31, 0.1);
|
||||
}
|
||||
|
||||
.score-change-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.score-change-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
border: 1px solid rgba(113, 82, 47, 0.1);
|
||||
}
|
||||
|
||||
.score-up {
|
||||
color: #0f7b45;
|
||||
}
|
||||
|
||||
.score-down {
|
||||
color: #a33620;
|
||||
}
|
||||
|
||||
.message-copy {
|
||||
margin-top: 8px;
|
||||
color: var(--muted);
|
||||
@@ -435,6 +554,28 @@ h2 {
|
||||
box-shadow: 0 10px 22px rgba(113, 82, 47, 0.08);
|
||||
}
|
||||
|
||||
.teaching-strip {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 18px;
|
||||
background: rgba(182, 69, 31, 0.08);
|
||||
border: 1px solid rgba(182, 69, 31, 0.16);
|
||||
}
|
||||
|
||||
.teaching-kicker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(182, 69, 31, 0.14);
|
||||
color: var(--accent-deep);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.helper-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -454,6 +595,11 @@ h2 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.helper-chip-accent {
|
||||
background: rgba(182, 69, 31, 0.12);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.response-banner {
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
@@ -543,4 +689,8 @@ h2 {
|
||||
.message-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settlement-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
143
frontend/src/types/game.ts
Normal file
143
frontend/src/types/game.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// 前端对局页当前仍处于“单页逐步拆分”阶段,因此先把跨组件共享的数据结构集中到这里,
|
||||
// 避免后续拆页时在多个 SFC 中重复维护同一份消息和视图类型。
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
success: boolean
|
||||
code: string
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
export type RoomSeatView = {
|
||||
seatNo: number
|
||||
participantType: string
|
||||
displayName: string
|
||||
botLevel: string | null
|
||||
readyStatus: string
|
||||
teachingEnabled: boolean
|
||||
}
|
||||
|
||||
export type RoomSummaryResponse = {
|
||||
roomId: string
|
||||
inviteCode: string
|
||||
status: string
|
||||
allowBotFill: boolean
|
||||
seats: RoomSeatView[]
|
||||
}
|
||||
|
||||
export type SelfSeatView = {
|
||||
seatNo: number
|
||||
playerId: string
|
||||
nickname: string
|
||||
won: boolean
|
||||
lackSuit: string | null
|
||||
score: number
|
||||
handTiles: string[]
|
||||
discardTiles: string[]
|
||||
melds: string[]
|
||||
}
|
||||
|
||||
export type PublicSeatView = {
|
||||
seatNo: number
|
||||
playerId: string
|
||||
nickname: string
|
||||
ai: boolean
|
||||
won: boolean
|
||||
lackSuit: string | null
|
||||
score: number
|
||||
handCount: number
|
||||
discardTiles: string[]
|
||||
melds: string[]
|
||||
}
|
||||
|
||||
export type GameStateResponse = {
|
||||
gameId: string
|
||||
phase: string
|
||||
dealerSeatNo: number
|
||||
currentSeatNo: number
|
||||
remainingWallCount: number
|
||||
selfSeat: SelfSeatView
|
||||
seats: PublicSeatView[]
|
||||
}
|
||||
|
||||
export type PublicGameMessage = {
|
||||
gameId: string
|
||||
eventType: string
|
||||
seatNo: number | null
|
||||
payload: Record<string, unknown>
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type PrivateActionCandidate = {
|
||||
actionType: string
|
||||
tile: string | null
|
||||
}
|
||||
|
||||
export 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[]
|
||||
}
|
||||
|
||||
export type CandidateAdviceItem = {
|
||||
tile: string
|
||||
score: number
|
||||
reasonTags: string[]
|
||||
}
|
||||
|
||||
export type PrivateTeachingMessage = {
|
||||
gameId: string
|
||||
userId: string
|
||||
teachingMode: string
|
||||
recommendedAction: string
|
||||
explanation: string
|
||||
candidates: CandidateAdviceItem[]
|
||||
}
|
||||
|
||||
export type ViewUserOption = {
|
||||
userId: string
|
||||
label: string
|
||||
seatNo: number
|
||||
}
|
||||
|
||||
export type SettlementFanView = {
|
||||
code: string
|
||||
label: string
|
||||
fan: number
|
||||
}
|
||||
|
||||
export type SettlementDetailView = {
|
||||
baseScore: number
|
||||
totalFan: number
|
||||
paymentScore: number
|
||||
fans: SettlementFanView[]
|
||||
}
|
||||
|
||||
export type SettlementCardView = {
|
||||
settlementType: string
|
||||
actionType: string
|
||||
actorSeatNo: number | null
|
||||
sourceSeatNo: number | null
|
||||
triggerTile: string | null
|
||||
detail: SettlementDetailView | null
|
||||
}
|
||||
|
||||
export type ScoreChangeCardView = {
|
||||
seatNo: number
|
||||
delta: number
|
||||
score: number | null
|
||||
settlementType: string | null
|
||||
}
|
||||
|
||||
export type DiagnosticItem = {
|
||||
key: string
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
220
frontend/src/utils/gameUi.ts
Normal file
220
frontend/src/utils/gameUi.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import type {
|
||||
PrivateActionCandidate,
|
||||
PublicGameMessage,
|
||||
SettlementDetailView,
|
||||
SettlementFanView
|
||||
} from '../types/game'
|
||||
|
||||
export const phaseLabelMap: Record<string, string> = {
|
||||
WAITING: '等待中',
|
||||
READY: '全部就绪',
|
||||
PLAYING: '对局中',
|
||||
FINISHED: '已结束',
|
||||
LACK_SELECTION: '定缺阶段'
|
||||
}
|
||||
|
||||
export const actionScopeLabelMap: Record<string, string> = {
|
||||
TURN: '当前回合动作',
|
||||
RESPONSE: '响应候选动作'
|
||||
}
|
||||
|
||||
export const actionTypeLabelMap: Record<string, string> = {
|
||||
SELECT_LACK_SUIT: '定缺',
|
||||
DISCARD: '出牌',
|
||||
PENG: '碰',
|
||||
GANG: '杠',
|
||||
HU: '胡',
|
||||
PASS: '过'
|
||||
}
|
||||
|
||||
export const triggerEventTypeLabelMap: Record<string, string> = {
|
||||
TILE_DISCARDED: '弃牌后响应',
|
||||
SUPPLEMENTAL_GANG_DECLARED: '补杠后抢杠胡'
|
||||
}
|
||||
|
||||
export 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: '动作提醒'
|
||||
}
|
||||
|
||||
export const settlementTypeLabelMap: Record<string, string> = {
|
||||
DIAN_PAO_HU: '点炮胡',
|
||||
QIANG_GANG_HU: '抢杠胡',
|
||||
ZI_MO_HU: '自摸胡',
|
||||
EXPOSED_GANG: '明杠',
|
||||
SUPPLEMENTAL_GANG: '补杠',
|
||||
CONCEALED_GANG: '暗杠',
|
||||
TUI_SHUI: '退税',
|
||||
CHA_JIAO: '查叫'
|
||||
}
|
||||
|
||||
export const reasonTagLabelMap: Record<string, string> = {
|
||||
LACK_SUIT_PRIORITY: '优先处理定缺',
|
||||
ISOLATED_TILE: '孤张优先打出',
|
||||
KEEP_PAIR: '保留对子',
|
||||
EDGE_TILE: '边张优先处理',
|
||||
KEEP_SEQUENCE_POTENTIAL: '保留顺子潜力'
|
||||
}
|
||||
|
||||
export function readString(value: unknown) {
|
||||
return typeof value === 'string' ? value : null
|
||||
}
|
||||
|
||||
export function readNumber(value: unknown) {
|
||||
return typeof value === 'number' ? value : null
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return null
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
export function toActionLabel(actionType: string) {
|
||||
return actionTypeLabelMap[actionType] ?? actionType
|
||||
}
|
||||
|
||||
export function formatCandidateLabel(candidate: PrivateActionCandidate) {
|
||||
const actionLabel = toActionLabel(candidate.actionType)
|
||||
if (!candidate.tile) {
|
||||
return actionLabel
|
||||
}
|
||||
return `${actionLabel} · ${candidate.tile}`
|
||||
}
|
||||
|
||||
export function formatSeatLabel(seatNo: number | null | undefined) {
|
||||
if (seatNo === null || seatNo === undefined) {
|
||||
return '未知座位'
|
||||
}
|
||||
return `座位 ${seatNo}`
|
||||
}
|
||||
|
||||
export function formatPhaseLabel(phase: string | null) {
|
||||
if (!phase) {
|
||||
return '未知阶段'
|
||||
}
|
||||
return phaseLabelMap[phase] ?? phase
|
||||
}
|
||||
|
||||
export function formatPublicEventTitle(event: PublicGameMessage) {
|
||||
return publicEventLabelMap[event.eventType] ?? event.eventType
|
||||
}
|
||||
|
||||
export function formatSettlementType(settlementType: string | null) {
|
||||
if (!settlementType) {
|
||||
return '未知结算'
|
||||
}
|
||||
return settlementTypeLabelMap[settlementType] ?? settlementType
|
||||
}
|
||||
|
||||
export function formatScoreDelta(delta: number | null) {
|
||||
if (delta === null) {
|
||||
return '-'
|
||||
}
|
||||
return delta > 0 ? `+${delta}` : `${delta}`
|
||||
}
|
||||
|
||||
export function toSettlementDetailView(value: unknown): SettlementDetailView | null {
|
||||
const detail = asRecord(value)
|
||||
if (!detail) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
baseScore: readNumber(detail.baseScore) ?? 0,
|
||||
totalFan: readNumber(detail.totalFan) ?? 0,
|
||||
paymentScore: readNumber(detail.paymentScore) ?? 0,
|
||||
fans: toSettlementFans(detail.fans)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSettlementSummary(event: PublicGameMessage) {
|
||||
const settlementType = formatSettlementType(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} 番。`
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatEventPayload(event: PublicGameMessage) {
|
||||
return JSON.stringify(event.payload, null, 2)
|
||||
}
|
||||
|
||||
export 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
|
||||
})
|
||||
}
|
||||
|
||||
export function formatReasonTag(reasonTag: string) {
|
||||
return reasonTagLabelMap[reasonTag] ?? reasonTag
|
||||
}
|
||||
|
||||
function toSettlementFans(value: unknown): SettlementFanView[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => asRecord(item))
|
||||
.filter((item): item is Record<string, unknown> => item !== null)
|
||||
.map((item) => ({
|
||||
code: readString(item.code) ?? '-',
|
||||
label: readString(item.label) ?? readString(item.code) ?? '-',
|
||||
fan: readNumber(item.fan) ?? 0
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user