feat: 实现最小正式版过水不胡规则并完善前端动作面板
- 后端实现最小正式版过水不胡规则:玩家在响应窗口选择PASS后,直到下次摸牌前不能响应胡 - 完善GameSeat状态管理,新增passedHuBlocked字段及相关方法 - 在ResponseActionWindowBuilder和GameActionProcessor中增加过水不胡校验 - 前端重构动作面板,区分回合动作和响应动作,支持多用户视角切换 - 优化公共事件处理逻辑,自动清理失效的私有动作面板 - 更新相关文档说明当前实现的规则范围和工程取舍 - 补充测试用例验证过水不胡规则的正确性
This commit is contained in:
@@ -95,9 +95,15 @@ type PrivateTeachingMessage = {
|
||||
explanation: string
|
||||
}
|
||||
|
||||
type ViewUserOption = {
|
||||
userId: string
|
||||
label: string
|
||||
seatNo: number
|
||||
}
|
||||
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const info = ref('H5 房间流原型已就位,现在会在进入对局后自动订阅 WebSocket 公共事件和私有消息。')
|
||||
const info = ref('H5 对局原型已接入公共事件、私有动作与私有教学消息,当前开始补正式动作面板。')
|
||||
|
||||
const ownerId = ref('host-1')
|
||||
const ownerName = ref('房主')
|
||||
@@ -129,6 +135,38 @@ const actionScopeLabelMap: Record<string, string> = {
|
||||
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(
|
||||
() =>
|
||||
@@ -137,14 +175,117 @@ const canDiscard = computed(
|
||||
game.value.selfSeat.playerId === currentUserId.value &&
|
||||
game.value.currentSeatNo === game.value.selfSeat.seatNo
|
||||
)
|
||||
|
||||
const publicSeats = computed(() => game.value?.seats ?? [])
|
||||
|
||||
const privateActionCandidates = computed(() => privateAction.value?.candidates ?? [])
|
||||
|
||||
const privateActionSummary = computed(() => {
|
||||
if (!privateAction.value) {
|
||||
return ''
|
||||
}
|
||||
const scopeLabel = actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
|
||||
return `${scopeLabel}:${privateAction.value.availableActions.join(' / ')}`
|
||||
const readableActions = privateAction.value.availableActions.map(toActionLabel)
|
||||
return `${scopeLabel}:${readableActions.join(' / ')}`
|
||||
})
|
||||
|
||||
const turnActionCandidates = computed(() => {
|
||||
if (privateAction.value?.actionScope !== 'TURN') {
|
||||
return []
|
||||
}
|
||||
// 当前回合的出牌依然直接通过点击手牌触发,动作面板只承载额外动作,避免一个动作出现两套入口。
|
||||
return privateAction.value.candidates.filter((candidate) => candidate.actionType !== 'DISCARD')
|
||||
})
|
||||
|
||||
const responseActionCandidates = computed(() => {
|
||||
if (privateAction.value?.actionScope !== 'RESPONSE') {
|
||||
return []
|
||||
}
|
||||
return privateAction.value.candidates
|
||||
})
|
||||
|
||||
const responseContextSummary = computed(() => {
|
||||
if (!privateAction.value || privateAction.value.actionScope !== 'RESPONSE') {
|
||||
return ''
|
||||
}
|
||||
const triggerLabel = privateAction.value.triggerEventType
|
||||
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
|
||||
: '响应窗口'
|
||||
const sourceSeatLabel =
|
||||
privateAction.value.sourceSeatNo !== null ? `来源座位 ${privateAction.value.sourceSeatNo}` : '来源座位未知'
|
||||
const triggerTileLabel = privateAction.value.triggerTile ? `目标牌 ${privateAction.value.triggerTile}` : '未携带目标牌'
|
||||
return `${triggerLabel},${sourceSeatLabel},${triggerTileLabel}`
|
||||
})
|
||||
|
||||
const actionPanelHint = computed(() => {
|
||||
if (!privateAction.value) {
|
||||
return '等待下一条私有动作消息。收到回合动作或响应动作后,这里会自动切换到对应面板。'
|
||||
}
|
||||
if (privateAction.value.actionScope === 'TURN') {
|
||||
return '出牌通过上方手牌直接执行;若当前可自摸胡或可杠,则在这里统一提交。'
|
||||
}
|
||||
return `${responseContextSummary.value}。请在当前响应窗口关闭前完成选择。`
|
||||
})
|
||||
|
||||
const currentViewLabel = computed(() => {
|
||||
if (!game.value) {
|
||||
return currentUserId.value
|
||||
}
|
||||
return `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`
|
||||
})
|
||||
|
||||
const viewUserOptions = computed<ViewUserOption[]>(() => {
|
||||
if (game.value) {
|
||||
const options = new Map<string, ViewUserOption>()
|
||||
options.set(game.value.selfSeat.playerId, {
|
||||
userId: game.value.selfSeat.playerId,
|
||||
label: `${game.value.selfSeat.nickname} · 座位 ${game.value.selfSeat.seatNo}`,
|
||||
seatNo: game.value.selfSeat.seatNo
|
||||
})
|
||||
for (const seat of game.value.seats) {
|
||||
options.set(seat.playerId, {
|
||||
userId: seat.playerId,
|
||||
label: `${seat.nickname} · 座位 ${seat.seatNo}`,
|
||||
seatNo: seat.seatNo
|
||||
})
|
||||
}
|
||||
return [...options.values()].sort((left, right) => left.seatNo - right.seatNo)
|
||||
}
|
||||
|
||||
return [
|
||||
{ userId: ownerId.value, label: `${ownerName.value} · 房主`, seatNo: 0 },
|
||||
{ userId: joinUserId.value, label: `${joinUserName.value} · 玩家二`, seatNo: 1 }
|
||||
]
|
||||
})
|
||||
|
||||
const actionDiagnosticItems = computed(() => {
|
||||
if (!privateAction.value) {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
{
|
||||
key: 'scope',
|
||||
label: '作用域',
|
||||
value: actionScopeLabelMap[privateAction.value.actionScope] ?? privateAction.value.actionScope
|
||||
},
|
||||
{
|
||||
key: 'window',
|
||||
label: '窗口 ID',
|
||||
value: privateAction.value.windowId ? privateAction.value.windowId.slice(0, 8) : '当前回合'
|
||||
},
|
||||
{
|
||||
key: 'event',
|
||||
label: '触发来源',
|
||||
value: privateAction.value.triggerEventType
|
||||
? triggerEventTypeLabelMap[privateAction.value.triggerEventType] ?? privateAction.value.triggerEventType
|
||||
: '当前回合'
|
||||
},
|
||||
{
|
||||
key: 'source',
|
||||
label: '来源座位',
|
||||
value: privateAction.value.sourceSeatNo !== null ? `座位 ${privateAction.value.sourceSeatNo}` : '无'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
async function requestJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
@@ -208,9 +349,10 @@ function connectWs(gameId: string, userId: string) {
|
||||
wsStatus.value = 'connected'
|
||||
client.subscribe(`/topic/games/${gameId}/events`, (message: IMessage) => {
|
||||
const payload = JSON.parse(message.body) as PublicGameMessage
|
||||
publicEvents.value = [payload, ...publicEvents.value].slice(0, 16)
|
||||
handlePublicEvent(payload)
|
||||
})
|
||||
client.subscribe(`/topic/users/${userId}/actions`, (message: IMessage) => {
|
||||
// 私有动作消息是前端动作面板的唯一事实来源,后续所有按钮启用状态都以这里为准。
|
||||
privateAction.value = JSON.parse(message.body) as PrivateActionMessage
|
||||
})
|
||||
client.subscribe(`/topic/users/${userId}/teaching`, (message: IMessage) => {
|
||||
@@ -236,6 +378,7 @@ function connectWs(gameId: string, userId: string) {
|
||||
watch(
|
||||
() => [game.value?.gameId, currentUserId.value] as const,
|
||||
([gameId, userId]) => {
|
||||
// 视角一旦切换,必须重新订阅对应用户的私有主题,避免沿用上一个玩家的动作/教学消息。
|
||||
if (gameId) {
|
||||
connectWs(gameId, userId)
|
||||
} else {
|
||||
@@ -340,6 +483,27 @@ async function refreshGameState() {
|
||||
})
|
||||
}
|
||||
|
||||
async function switchUserView(userId: string) {
|
||||
if (currentUserId.value === userId) {
|
||||
return
|
||||
}
|
||||
|
||||
currentUserId.value = userId
|
||||
|
||||
if (!game.value) {
|
||||
info.value = `已切换到 ${userId} 视角。`
|
||||
return
|
||||
}
|
||||
|
||||
const targetGameId = game.value.gameId
|
||||
await runTask(async () => {
|
||||
game.value = await requestJson<GameStateResponse>(
|
||||
`/api/games/${targetGameId}/state?userId=${encodeURIComponent(userId)}`
|
||||
)
|
||||
info.value = `已切换到 ${userId} 视角,并同步最新对局状态。`
|
||||
})
|
||||
}
|
||||
|
||||
async function submitAction(actionType: string, tile?: string, sourceSeatNo?: number | null) {
|
||||
if (!game.value) {
|
||||
return
|
||||
@@ -355,7 +519,17 @@ async function submitAction(actionType: string, tile?: string, sourceSeatNo?: nu
|
||||
sourceSeatNo: sourceSeatNo ?? null
|
||||
})
|
||||
})
|
||||
info.value = actionType === 'DISCARD' ? `已打出 ${tile}。` : `已提交动作 ${actionType}。`
|
||||
|
||||
const readableAction = toActionLabel(actionType)
|
||||
if (actionType === 'DISCARD' && tile) {
|
||||
info.value = `已打出 ${tile}。`
|
||||
return
|
||||
}
|
||||
if (tile) {
|
||||
info.value = `已提交 ${readableAction} ${tile}。`
|
||||
return
|
||||
}
|
||||
info.value = `已提交动作 ${readableAction}。`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -371,6 +545,153 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
const sourceSeatNo = privateAction.value?.sourceSeatNo ?? 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)) {
|
||||
privateAction.value = null
|
||||
}
|
||||
publicEvents.value = [event, ...publicEvents.value].slice(0, 16)
|
||||
}
|
||||
|
||||
function shouldClearPrivateActionByEvent(event: PublicGameMessage) {
|
||||
if (!privateAction.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
const currentAction = privateAction.value
|
||||
const selfSeatNo = game.value?.selfSeat.seatNo ?? null
|
||||
|
||||
if (event.eventType === 'RESPONSE_WINDOW_CLOSED') {
|
||||
const sourceSeatNo = readNumber(event.payload.sourceSeatNo)
|
||||
return currentAction.actionScope === 'RESPONSE' && sourceSeatNo === currentAction.sourceSeatNo
|
||||
}
|
||||
|
||||
if (event.eventType === 'GAME_PHASE_CHANGED') {
|
||||
return readString(event.payload.phase) !== 'PLAYING'
|
||||
}
|
||||
|
||||
if (event.eventType === 'TILE_DISCARDED') {
|
||||
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && event.seatNo === selfSeatNo
|
||||
}
|
||||
|
||||
if (event.eventType === 'TURN_SWITCHED') {
|
||||
const nextSeatNo = readNumber(event.payload.currentSeatNo)
|
||||
return currentAction.actionScope === 'TURN' && selfSeatNo !== null && nextSeatNo !== selfSeatNo
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -378,9 +699,9 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
<section class="hero-panel">
|
||||
<div>
|
||||
<p class="eyebrow">XueZhanMaster H5</p>
|
||||
<h1>血战大师移动端房间流原型</h1>
|
||||
<h1>血战大师移动端对局台</h1>
|
||||
<p class="intro">
|
||||
当前页面已切成 H5 操作台,并接入 WebSocket 骨架。目标是让后续手机端新对话也能快速恢复上下文:房间流、对局流、消息流都能看见。
|
||||
当前页面不再只做房间流演示,而是开始承担正式联调职责:公共事件、私有动作、私有教学已经连通,下面的动作面板会按“当前回合动作”和“响应动作”自动切换。
|
||||
</p>
|
||||
</div>
|
||||
<div class="signal-card">
|
||||
@@ -420,8 +741,8 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</label>
|
||||
<div class="btn-row">
|
||||
<button class="secondary-btn" @click="loadRoom">刷新房间</button>
|
||||
<button class="secondary-btn" @click="currentUserId = ownerId">切到房主视角</button>
|
||||
<button class="secondary-btn" @click="currentUserId = joinUserId">切到玩家二视角</button>
|
||||
<button class="secondary-btn" @click="switchUserView(ownerId)">切到房主视角</button>
|
||||
<button class="secondary-btn" @click="switchUserView(joinUserId)">切到玩家二视角</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -487,7 +808,7 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<section class="play-grid" v-if="game">
|
||||
<section v-if="game" class="play-grid">
|
||||
<article class="panel game-panel">
|
||||
<div class="panel-head">
|
||||
<div>
|
||||
@@ -504,7 +825,7 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">当前视角</span>
|
||||
<strong>{{ currentUserId }}</strong>
|
||||
<strong>{{ currentViewLabel }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">剩余牌墙</span>
|
||||
@@ -512,6 +833,24 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</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">
|
||||
@@ -546,11 +885,72 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
</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">
|
||||
<span
|
||||
v-for="(meld, index) in game.selfSeat.melds"
|
||||
:key="`self-meld-${meld}-${index}`"
|
||||
class="discard-chip"
|
||||
>
|
||||
{{ meld }}
|
||||
</span>
|
||||
</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>
|
||||
</article>
|
||||
|
||||
<article class="panel board-panel">
|
||||
@@ -567,29 +967,17 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
<span class="meta-label">私有动作消息</span>
|
||||
<template v-if="privateAction">
|
||||
<strong>{{ privateActionSummary }}</strong>
|
||||
<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.triggerTile" class="mini-tag">目标牌 {{ privateAction.triggerTile }}</span>
|
||||
<span v-if="privateAction.sourceSeatNo !== null" class="mini-tag">来源座位 {{ privateAction.sourceSeatNo }}</span>
|
||||
<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>
|
||||
<div class="candidate-list" v-if="privateActionCandidates.length > 0">
|
||||
<button
|
||||
v-for="(candidate, index) in privateActionCandidates"
|
||||
:key="`${candidate.actionType}-${candidate.tile ?? 'none'}-${index}`"
|
||||
class="candidate-chip"
|
||||
type="button"
|
||||
@click="submitCandidateAction(candidate.actionType, candidate.tile)"
|
||||
>
|
||||
{{ candidate.actionType }}<template v-if="candidate.tile"> · {{ candidate.tile }}</template>
|
||||
</button>
|
||||
</div>
|
||||
<span v-if="privateAction.actionScope === 'RESPONSE'" class="message-copy">
|
||||
当前原型页已能识别响应候选消息,后续会继续补正式动作面板和真实响应流程。
|
||||
</span>
|
||||
<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>
|
||||
@@ -635,10 +1023,18 @@ function submitCandidateAction(actionType: string, tile: string | null) {
|
||||
<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>{{ event.eventType }}</strong>
|
||||
<span class="mini-pill">{{ event.seatNo ?? '-' }}</span>
|
||||
<strong>{{ formatPublicEventTitle(event) }}</strong>
|
||||
<span class="mini-pill">{{ formatEventTime(event.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="message-copy">{{ JSON.stringify(event.payload) }}</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>
|
||||
|
||||
Reference in New Issue
Block a user