feat: 添加局后复盘服务与页面容器组件
新增复盘服务相关DTO、Controller和Service 实现复盘页面容器组件ReviewPageContainer 更新前端页面架构文档与开发计划 移除DemoGameController中的演示复盘接口 补充复盘服务单元测试
This commit is contained in:
Binary file not shown.
@@ -6,12 +6,23 @@
|
||||
- 过水不胡:玩家在响应窗口里本可 `HU` 但选择 `PASS` 后,直到自己下一次真正摸牌前,不能再做响应胡;不影响碰、杠、自摸胡。
|
||||
- 教学建议链路已扩展为:`recommendedAction`、`explanation`、`candidates`,其中 `candidates` 透传到私有 WebSocket 教学消息,前端已支持展示候选牌、评分和原因标签中文映射。
|
||||
- 前端 H5 对局页已完成正式动作面板、响应动作面板、玩家视角切换、公共事件时间线、最近结算卡片、教学推荐高亮等联调。
|
||||
- 2026-03-20 当日新增一轮前端结构收口:
|
||||
- 2026-03-20 当日新增前端结构收口:
|
||||
- 新增 `docs/H5_GAME_PAGE_ARCHITECTURE.md`,完成 `S1-08` 页面信息架构与拆分方案。
|
||||
- 前端共享类型已抽到 `frontend/src/types/game.ts`。
|
||||
- 复盘类型已抽到 `frontend/src/types/review.ts`。
|
||||
- UI 标签映射与事件/结算格式化函数已抽到 `frontend/src/utils/gameUi.ts`。
|
||||
- 已拆出展示组件:`GameActionDock.vue`、`GameMessageStack.vue`、`PublicEventTimeline.vue`。
|
||||
- `App.vue` 当前定位已经收敛为“页面容器 + 请求/订阅协调层”,便于下一轮继续拆 `RoomPage / GamePage / ReviewPage`。
|
||||
- 顶层页面外壳已拆出:`AppShell.vue`。
|
||||
- 页面级区块组件已拆出:`RoomWorkspace.vue`、`GameWorkspace.vue`。
|
||||
- 房间流组件已拆出:`RoomControlPanel.vue`、`RoomLobbyPanel.vue`。
|
||||
- 对局展示组件已拆出:`GameActionDock.vue`、`GameMessageStack.vue`、`PublicEventTimeline.vue`、`ViewSwitchPanel.vue`、`SelfHandPanel.vue`、`PublicSeatBoard.vue`。
|
||||
- `pages/` 目录已建立,当前入口容器已命名为:`RoomPageContainer.vue`、`GamePageContainer.vue`。
|
||||
- `ReviewPageContainer.vue` 已对齐后端 `ReviewSummaryResponse` 协议,并支持手动加载 demo 复盘数据,但尚未接入真实复盘数据和路由。
|
||||
- 2026-03-20 当日新增最小复盘协议骨架:
|
||||
- 后端新增 `review` 包,包含 `ReviewSummaryResponse`、`ReviewSettlementItem`、`ReviewMistakeItem`、`ReviewTrainingFocusItem`。
|
||||
- 后端新增 `ReviewService`,当前提供演示复盘数据。
|
||||
- 示例接口已开放:`GET /api/demo/review`。
|
||||
- 前端 `App.vue` 已能手动加载这条接口并把数据传入 `ReviewPageContainer`。
|
||||
- `DemoGameServiceTest` 已新增复盘示例测试,当前总测试数为 55。
|
||||
- 2026-03-20 当日还修复了 `GameSessionServiceTest` 中两条“过水不胡”测试的脆弱构造,改为显式控制响应胡候选与后续安全弃牌,避免依赖随机初始牌与中间牌路。
|
||||
- 当前验证状态:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。
|
||||
- 注释约定继续有效:后端复杂规则、前端复杂交互、后续数据库脚本和迁移都要补适当偏多的中文注释,尤其说明规则判断、状态切换、消息边界与字段语义。
|
||||
16
.serena/memories/review_service_v1.md
Normal file
16
.serena/memories/review_service_v1.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 局后复盘服务 V1(2026-03-20)
|
||||
- 已移除 demo 复盘链路:`DemoGameController` 不再暴露 `GET /api/demo/review`,`DemoGameService` 也已移除 demo review 生成逻辑。
|
||||
- 新增真实复盘接口:`GET /api/games/{gameId}/review?userId={userId}`。
|
||||
- `ReviewController` 负责暴露接口,`ReviewService` 负责基于真实 `GameSession` 和 `settlementHistory` 生成 `ReviewSummaryResponse`。
|
||||
- `GameSessionService` 新增只读查询口 `getSessionForReview(String gameId)`,当前仅供复盘服务读取内存态会话;未引入新持久化或快照表。
|
||||
- 当前复盘算法范围(KISS/YAGNI 版本):
|
||||
- 个人总览:读取当前座位最终分数、结果标签、总结文案。
|
||||
- 关键结算:从 `settlementHistory` 中筛出与该座位直接相关的结算,生成标题、摘要、分值和番型/结算标签。
|
||||
- 关键失误:仅基于真实负向结算生成启发式问题项,当前覆盖点炮、自摸失分、抢杠胡、退税、查叫、明杠/杠类被动付分。
|
||||
- 训练方向:基于负向结算类型和是否有正向胡牌样本,生成风险控制、成叫效率、杠牌时机、基础番型等训练建议。
|
||||
- 当前限制:
|
||||
- 仍是内存态复盘,服务重启后对局与复盘数据会丢失。
|
||||
- 还没有逐手牌谱回放、动作级失误定位、个人可见信息回放时间线。
|
||||
- `mistakeInsights` 仍是启发式生成,不是严格牌效/安牌分析。
|
||||
- 前端已改为手动加载当前真实对局/当前视角复盘;切换视角或切换对局时会清空旧复盘,避免显示过期摘要。
|
||||
- 已验证:`cd backend && mvn test` 通过;`cd frontend && npm run build` 通过。
|
||||
@@ -28,4 +28,3 @@ public class DemoGameController {
|
||||
return ApiResponse.success(demoGameService.createDemoTeachingAdvice());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,14 @@ public class GameSessionService {
|
||||
return toStateResponse(session, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 复盘服务当前仍直接消费内存态对局与结算历史,
|
||||
* 这里先提供只读查询入口,避免 review 包直接接触会话仓储细节。
|
||||
*/
|
||||
public GameSession getSessionForReview(String gameId) {
|
||||
return getRequiredSession(gameId);
|
||||
}
|
||||
|
||||
public GameStateResponse performAction(String gameId, GameActionRequest request) {
|
||||
GameSession session = getRequiredSession(gameId);
|
||||
ActionType actionType = parseActionType(request.actionType());
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.xuezhanmaster.review.controller;
|
||||
|
||||
import com.xuezhanmaster.common.api.ApiResponse;
|
||||
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
|
||||
import com.xuezhanmaster.review.service.ReviewService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ReviewController {
|
||||
|
||||
private final ReviewService reviewService;
|
||||
|
||||
public ReviewController(ReviewService reviewService) {
|
||||
this.reviewService = reviewService;
|
||||
}
|
||||
|
||||
@GetMapping("/games/{gameId}/review")
|
||||
public ApiResponse<ReviewSummaryResponse> review(
|
||||
@PathVariable String gameId,
|
||||
@RequestParam String userId
|
||||
) {
|
||||
return ApiResponse.success(reviewService.createGameReviewSummary(gameId, userId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.xuezhanmaster.review.dto;
|
||||
|
||||
/**
|
||||
* 复盘页里的“关键失误”条目。
|
||||
* 字段口径优先服务 H5 展示与后续训练题沉淀,不先做复杂评分模型。
|
||||
*/
|
||||
public record ReviewMistakeItem(
|
||||
String severity,
|
||||
String title,
|
||||
String issue,
|
||||
String suggestion
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.xuezhanmaster.review.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 复盘页里的“关键结算节点”条目。
|
||||
* 当前先用扁平结构表达,后续接真实局后数据时再决定是否拆更细的事件层级。
|
||||
*/
|
||||
public record ReviewSettlementItem(
|
||||
String title,
|
||||
String summary,
|
||||
int scoreDelta,
|
||||
List<String> fanLabels
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.xuezhanmaster.review.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 局后复盘页的最小协议骨架。
|
||||
* 当前先覆盖“总览、关键失误、训练方向”三块正式页面必需信息,后续再按真实复盘算法逐步扩展。
|
||||
*/
|
||||
public record ReviewSummaryResponse(
|
||||
String gameId,
|
||||
String userId,
|
||||
String playerNickname,
|
||||
int seatNo,
|
||||
int finalScore,
|
||||
String resultLabel,
|
||||
String conclusion,
|
||||
List<ReviewSettlementItem> settlementTimeline,
|
||||
List<ReviewMistakeItem> mistakeInsights,
|
||||
List<ReviewTrainingFocusItem> trainingFocuses
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.xuezhanmaster.review.dto;
|
||||
|
||||
/**
|
||||
* 复盘页里的“后续训练方向”条目。
|
||||
* 当前先保留最小训练类型标签,避免过早引入大而全训练体系。
|
||||
*/
|
||||
public record ReviewTrainingFocusItem(
|
||||
String drillType,
|
||||
String title,
|
||||
String description
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
package com.xuezhanmaster.review.service;
|
||||
|
||||
import com.xuezhanmaster.common.exception.BusinessException;
|
||||
import com.xuezhanmaster.game.domain.GameSeat;
|
||||
import com.xuezhanmaster.game.domain.GameSession;
|
||||
import com.xuezhanmaster.game.domain.ScoreChange;
|
||||
import com.xuezhanmaster.game.domain.SettlementFan;
|
||||
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||
import com.xuezhanmaster.game.domain.SettlementType;
|
||||
import com.xuezhanmaster.game.service.GameSessionService;
|
||||
import com.xuezhanmaster.review.dto.ReviewMistakeItem;
|
||||
import com.xuezhanmaster.review.dto.ReviewSettlementItem;
|
||||
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
|
||||
import com.xuezhanmaster.review.dto.ReviewTrainingFocusItem;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
@Service
|
||||
public class ReviewService {
|
||||
|
||||
private final GameSessionService gameSessionService;
|
||||
|
||||
public ReviewService(GameSessionService gameSessionService) {
|
||||
this.gameSessionService = gameSessionService;
|
||||
}
|
||||
|
||||
public ReviewSummaryResponse createGameReviewSummary(String gameId, String userId) {
|
||||
GameSession session = gameSessionService.getSessionForReview(gameId);
|
||||
GameSeat reviewSeat = session.getTable().getSeats().stream()
|
||||
.filter(seat -> seat.getPlayerId().equals(userId))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new BusinessException("GAME_SEAT_NOT_FOUND", "当前玩家不在对局中"));
|
||||
|
||||
List<SettlementResult> settlementHistory = List.copyOf(session.getSettlementHistory());
|
||||
List<ReviewSettlementItem> settlementTimeline = buildSettlementTimeline(settlementHistory, reviewSeat.getSeatNo());
|
||||
List<ReviewMistakeItem> mistakeInsights = buildMistakeInsights(settlementHistory, reviewSeat.getSeatNo());
|
||||
List<ReviewTrainingFocusItem> trainingFocuses = buildTrainingFocuses(
|
||||
settlementHistory,
|
||||
reviewSeat.getSeatNo(),
|
||||
reviewSeat.getScore(),
|
||||
mistakeInsights
|
||||
);
|
||||
|
||||
return new ReviewSummaryResponse(
|
||||
session.getGameId(),
|
||||
reviewSeat.getPlayerId(),
|
||||
reviewSeat.getNickname(),
|
||||
reviewSeat.getSeatNo(),
|
||||
reviewSeat.getScore(),
|
||||
buildResultLabel(reviewSeat.getScore()),
|
||||
buildConclusion(reviewSeat, settlementTimeline, mistakeInsights),
|
||||
settlementTimeline,
|
||||
mistakeInsights,
|
||||
trainingFocuses
|
||||
);
|
||||
}
|
||||
|
||||
private List<ReviewSettlementItem> buildSettlementTimeline(List<SettlementResult> settlementHistory, int seatNo) {
|
||||
List<ReviewSettlementItem> items = new ArrayList<>();
|
||||
for (SettlementResult settlementResult : settlementHistory) {
|
||||
Integer scoreDelta = resolveSeatDelta(settlementResult, seatNo);
|
||||
if (scoreDelta == null) {
|
||||
continue;
|
||||
}
|
||||
List<String> fanLabels = buildFanLabels(settlementResult);
|
||||
items.add(new ReviewSettlementItem(
|
||||
buildSettlementTitle(settlementResult, scoreDelta),
|
||||
buildSettlementSummary(settlementResult, scoreDelta, fanLabels),
|
||||
scoreDelta,
|
||||
fanLabels
|
||||
));
|
||||
}
|
||||
if (items.isEmpty()) {
|
||||
items.add(new ReviewSettlementItem(
|
||||
"暂无个人结算",
|
||||
"当前对局还没有产生与你直接相关的结算变化,复盘摘要会在出现真实得失分后逐步丰富。",
|
||||
0,
|
||||
List.of("等待结算")
|
||||
));
|
||||
}
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private List<ReviewMistakeItem> buildMistakeInsights(List<SettlementResult> settlementHistory, int seatNo) {
|
||||
List<SettlementResult> negativeSettlements = settlementHistory.stream()
|
||||
.filter(result -> {
|
||||
Integer delta = resolveSeatDelta(result, seatNo);
|
||||
return delta != null && delta < 0;
|
||||
})
|
||||
.sorted(Comparator.comparingInt((SettlementResult result) -> Math.abs(resolveSeatDelta(result, seatNo))).reversed())
|
||||
.limit(3)
|
||||
.toList();
|
||||
|
||||
List<ReviewMistakeItem> items = new ArrayList<>();
|
||||
for (SettlementResult settlementResult : negativeSettlements) {
|
||||
int scoreDelta = resolveSeatDelta(settlementResult, seatNo);
|
||||
items.add(switch (settlementResult.settlementType()) {
|
||||
case DIAN_PAO_HU -> new ReviewMistakeItem(
|
||||
"HIGH",
|
||||
"点炮导致失分",
|
||||
"你在他人弃牌胡结算中承担了 " + formatScore(scoreDelta) + ",说明中后盘危险张控制仍有明显缺口。",
|
||||
"当对手已经副露成型或番型抬高时,优先保留现成安全张,避免继续压高危中张。"
|
||||
);
|
||||
case QIANG_GANG_HU -> new ReviewMistakeItem(
|
||||
"HIGH",
|
||||
"补杠时机过激",
|
||||
"你在补杠后被对手抢杠胡,直接产生 " + formatScore(scoreDelta) + " 的回撤。",
|
||||
"补杠前先检查场上是否已出现高压听牌信号,必要时放弃补杠收益,优先确保不放大失分。"
|
||||
);
|
||||
case ZI_MO_HU -> new ReviewMistakeItem(
|
||||
"MEDIUM",
|
||||
"未能压住对手自摸",
|
||||
"对手自摸时你被扣除 " + formatScore(scoreDelta) + ",说明本局在速度或成叫质量上没有形成足够压制。",
|
||||
"后续训练应强化中盘成型速度判断,避免在明显落后牌速时仍维持松散手型。"
|
||||
);
|
||||
case TUI_SHUI -> new ReviewMistakeItem(
|
||||
"MEDIUM",
|
||||
"杠分未守住",
|
||||
"本局出现退税,导致你回吐 " + formatScore(scoreDelta) + ",说明前序杠牌收益没有稳定兑现。",
|
||||
"练习在流局风险升高时复盘杠牌收益与听牌质量,避免为短期税分牺牲整体成叫。"
|
||||
);
|
||||
case CHA_JIAO -> new ReviewMistakeItem(
|
||||
"HIGH",
|
||||
"流局未成叫",
|
||||
"流局查叫阶段你承担了 " + formatScore(scoreDelta) + ",反映出收尾阶段的成叫效率不足。",
|
||||
"进入残局后要更早切换到保叫路线,优先确保最小可支付听牌,而不是继续追求高番远型。"
|
||||
);
|
||||
case MING_GANG -> new ReviewMistakeItem(
|
||||
"MEDIUM",
|
||||
"放杠失分",
|
||||
"你在他人明杠结算里承担了 " + formatScore(scoreDelta) + ",说明对明显碰后加杠的防范还不够。",
|
||||
"看到对手碰后,应更早记录其可能补强的牌张,减少继续喂牌或协助做大的情况。"
|
||||
);
|
||||
case BU_GANG, AN_GANG -> new ReviewMistakeItem(
|
||||
"LOW",
|
||||
"杠分压制不足",
|
||||
"对手通过杠牌从你这里拿走了 " + formatScore(scoreDelta) + ",本局资源交换略偏被动。",
|
||||
"后续可专项训练杠前后局势判断,明确什么时候该抢速度,什么时候该减少被动付分。"
|
||||
);
|
||||
});
|
||||
}
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private List<ReviewTrainingFocusItem> buildTrainingFocuses(
|
||||
List<SettlementResult> settlementHistory,
|
||||
int seatNo,
|
||||
int finalScore,
|
||||
List<ReviewMistakeItem> mistakeInsights
|
||||
) {
|
||||
List<ReviewTrainingFocusItem> items = new ArrayList<>();
|
||||
Set<String> addedDrillTypes = new LinkedHashSet<>();
|
||||
|
||||
if (hasNegativeSettlement(settlementHistory, seatNo, SettlementType.DIAN_PAO_HU, SettlementType.QIANG_GANG_HU, SettlementType.ZI_MO_HU)) {
|
||||
addTrainingFocus(
|
||||
items,
|
||||
addedDrillTypes,
|
||||
"RISK_CONTROL",
|
||||
"危险张与守备节奏训练",
|
||||
"围绕真实失分节点复盘中后盘危险张排序,建立“副露压力上升时先保底不放炮”的判断习惯。"
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNegativeSettlement(settlementHistory, seatNo, SettlementType.CHA_JIAO) || finalScore <= 0) {
|
||||
addTrainingFocus(
|
||||
items,
|
||||
addedDrillTypes,
|
||||
"READY_HAND",
|
||||
"成叫效率训练",
|
||||
"重点训练残局从做大切换到保叫的时机,避免在查叫阶段继续承担被动失分。"
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSettlement(settlementHistory, seatNo, SettlementType.MING_GANG, SettlementType.BU_GANG, SettlementType.AN_GANG, SettlementType.TUI_SHUI)) {
|
||||
addTrainingFocus(
|
||||
items,
|
||||
addedDrillTypes,
|
||||
"GANG_TIMING",
|
||||
"杠牌收益判断训练",
|
||||
"把本局杠牌、退税与抢杠相关节点串起来看,训练“能不能杠”和“杠完是否值得”的双重判断。"
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasPositiveHu(settlementHistory, seatNo)) {
|
||||
addTrainingFocus(
|
||||
items,
|
||||
addedDrillTypes,
|
||||
"PATTERN_BUILDING",
|
||||
"基础番型成型训练",
|
||||
"当前结算里你的直接胡牌样本不足,建议先从常见番型与听牌速度训练入手,提升稳定得分能力。"
|
||||
);
|
||||
}
|
||||
|
||||
if (items.isEmpty()) {
|
||||
addTrainingFocus(
|
||||
items,
|
||||
addedDrillTypes,
|
||||
"ADVANTAGE_CONVERT",
|
||||
"优势局扩大训练",
|
||||
"本局没有明显失误结算,后续可把重点放在如何把已有领先进一步转化为更稳定的收尾优势。"
|
||||
);
|
||||
}
|
||||
|
||||
if (items.size() == 1 && !mistakeInsights.isEmpty()) {
|
||||
addTrainingFocus(
|
||||
items,
|
||||
addedDrillTypes,
|
||||
"REVIEW_REPEAT",
|
||||
"关键失分复现训练",
|
||||
"把本局最高代价的 1 到 2 个结算节点单独复现,形成固定复盘模板,提升下一局的可迁移性。"
|
||||
);
|
||||
}
|
||||
|
||||
return List.copyOf(items);
|
||||
}
|
||||
|
||||
private void addTrainingFocus(
|
||||
List<ReviewTrainingFocusItem> items,
|
||||
Set<String> addedDrillTypes,
|
||||
String drillType,
|
||||
String title,
|
||||
String description
|
||||
) {
|
||||
if (addedDrillTypes.add(drillType)) {
|
||||
items.add(new ReviewTrainingFocusItem(drillType, title, description));
|
||||
}
|
||||
}
|
||||
|
||||
private String buildResultLabel(int finalScore) {
|
||||
if (finalScore > 0) {
|
||||
return "本局净胜";
|
||||
}
|
||||
if (finalScore < 0) {
|
||||
return "本局失分";
|
||||
}
|
||||
return "本局持平";
|
||||
}
|
||||
|
||||
private String buildConclusion(
|
||||
GameSeat reviewSeat,
|
||||
List<ReviewSettlementItem> settlementTimeline,
|
||||
List<ReviewMistakeItem> mistakeInsights
|
||||
) {
|
||||
long positiveSettlements = settlementTimeline.stream()
|
||||
.filter(item -> item.scoreDelta() > 0)
|
||||
.count();
|
||||
long negativeSettlements = settlementTimeline.stream()
|
||||
.filter(item -> item.scoreDelta() < 0)
|
||||
.count();
|
||||
|
||||
if (reviewSeat.getScore() > 0) {
|
||||
if (negativeSettlements > 0 && !mistakeInsights.isEmpty()) {
|
||||
return "本局净胜 " + formatScore(reviewSeat.getScore())
|
||||
+ ",虽然通过 " + positiveSettlements + " 次真实得分节点建立优势,但仍暴露出“"
|
||||
+ mistakeInsights.get(0).title() + "”这类回撤点。";
|
||||
}
|
||||
return "本局净胜 " + formatScore(reviewSeat.getScore())
|
||||
+ ",主要依靠 " + positiveSettlements + " 次真实结算建立优势,整体节奏相对稳定。";
|
||||
}
|
||||
if (reviewSeat.getScore() < 0) {
|
||||
if (!mistakeInsights.isEmpty()) {
|
||||
return "本局净失 " + formatScore(reviewSeat.getScore())
|
||||
+ ",关键回撤集中在“" + mistakeInsights.get(0).title()
|
||||
+ "”,建议优先围绕该节点做针对性训练。";
|
||||
}
|
||||
return "本局净失 " + formatScore(reviewSeat.getScore())
|
||||
+ ",当前直接失分样本不多,但结算收益不足,后续需要优先提升稳定成叫与基础得分能力。";
|
||||
}
|
||||
return "本局最终持平,期间共有 " + positiveSettlements + " 次得分与 " + negativeSettlements
|
||||
+ " 次失分节点,后续应重点复盘收益转化是否足够稳定。";
|
||||
}
|
||||
|
||||
private boolean hasNegativeSettlement(List<SettlementResult> settlementHistory, int seatNo, SettlementType... settlementTypes) {
|
||||
Set<SettlementType> acceptedTypes = Set.of(settlementTypes);
|
||||
return settlementHistory.stream()
|
||||
.filter(result -> acceptedTypes.contains(result.settlementType()))
|
||||
.anyMatch(result -> {
|
||||
Integer delta = resolveSeatDelta(result, seatNo);
|
||||
return delta != null && delta < 0;
|
||||
});
|
||||
}
|
||||
|
||||
private boolean hasSettlement(List<SettlementResult> settlementHistory, int seatNo, SettlementType... settlementTypes) {
|
||||
Set<SettlementType> acceptedTypes = Set.of(settlementTypes);
|
||||
return settlementHistory.stream()
|
||||
.filter(result -> acceptedTypes.contains(result.settlementType()))
|
||||
.anyMatch(result -> resolveSeatDelta(result, seatNo) != null);
|
||||
}
|
||||
|
||||
private boolean hasPositiveHu(List<SettlementResult> settlementHistory, int seatNo) {
|
||||
return settlementHistory.stream()
|
||||
.filter(result -> result.settlementType() == SettlementType.DIAN_PAO_HU
|
||||
|| result.settlementType() == SettlementType.ZI_MO_HU
|
||||
|| result.settlementType() == SettlementType.QIANG_GANG_HU)
|
||||
.anyMatch(result -> {
|
||||
Integer delta = resolveSeatDelta(result, seatNo);
|
||||
return delta != null && delta > 0;
|
||||
});
|
||||
}
|
||||
|
||||
private Integer resolveSeatDelta(SettlementResult settlementResult, int seatNo) {
|
||||
for (ScoreChange scoreChange : settlementResult.scoreChanges()) {
|
||||
if (scoreChange.seatNo() == seatNo) {
|
||||
return scoreChange.delta();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> buildFanLabels(SettlementResult settlementResult) {
|
||||
if (!settlementResult.settlementDetail().fans().isEmpty()) {
|
||||
return settlementResult.settlementDetail().fans().stream()
|
||||
.map(SettlementFan::label)
|
||||
.toList();
|
||||
}
|
||||
return List.of(settlementTypeLabel(settlementResult.settlementType()));
|
||||
}
|
||||
|
||||
private String buildSettlementTitle(SettlementResult settlementResult, int scoreDelta) {
|
||||
return switch (settlementResult.settlementType()) {
|
||||
case DIAN_PAO_HU -> scoreDelta > 0 ? "点炮胡得分" : "点炮失分";
|
||||
case ZI_MO_HU -> scoreDelta > 0 ? "自摸胡得分" : "对手自摸失分";
|
||||
case QIANG_GANG_HU -> scoreDelta > 0 ? "抢杠胡得分" : "补杠被抢";
|
||||
case MING_GANG -> scoreDelta > 0 ? "明杠收益" : "放杠失分";
|
||||
case BU_GANG -> scoreDelta > 0 ? "补杠收益" : "对手补杠失分";
|
||||
case AN_GANG -> scoreDelta > 0 ? "暗杠收益" : "对手暗杠失分";
|
||||
case TUI_SHUI -> scoreDelta > 0 ? "退税回补" : "杠分被退税";
|
||||
case CHA_JIAO -> scoreDelta > 0 ? "查叫收益" : "流局查叫失分";
|
||||
};
|
||||
}
|
||||
|
||||
private String buildSettlementSummary(
|
||||
SettlementResult settlementResult,
|
||||
int scoreDelta,
|
||||
List<String> fanLabels
|
||||
) {
|
||||
String fanSummary = fanLabels.isEmpty() ? "" : ",标签:" + String.join(" / ", fanLabels);
|
||||
return switch (settlementResult.settlementType()) {
|
||||
case DIAN_PAO_HU -> scoreDelta > 0
|
||||
? "你通过他人弃牌完成胡牌,获得 " + formatScore(scoreDelta)
|
||||
+ ",来源座位 " + settlementResult.sourceSeatNo() + fanSummary + "。"
|
||||
: "你向座位 " + settlementResult.actorSeatNo() + " 点炮,产生 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
case ZI_MO_HU -> scoreDelta > 0
|
||||
? "你完成自摸,从仍未胡牌对手处累计获得 " + formatScore(scoreDelta) + fanSummary + "。"
|
||||
: "座位 " + settlementResult.actorSeatNo() + " 自摸时,你承担了 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
case QIANG_GANG_HU -> scoreDelta > 0
|
||||
? "你在抢杠窗口完成胡牌,获得 " + formatScore(scoreDelta)
|
||||
+ ",目标来自座位 " + settlementResult.sourceSeatNo() + fanSummary + "。"
|
||||
: "你补杠时被座位 " + settlementResult.actorSeatNo() + " 抢杠胡,损失 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
case MING_GANG -> scoreDelta > 0
|
||||
? "你通过明杠从座位 " + settlementResult.sourceSeatNo() + " 取得 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。"
|
||||
: "你为座位 " + settlementResult.actorSeatNo() + " 的明杠支付了 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
case BU_GANG -> scoreDelta > 0
|
||||
? "你完成补杠,从其余未胡玩家处累计获得 " + formatScore(scoreDelta) + fanSummary + "。"
|
||||
: "座位 " + settlementResult.actorSeatNo() + " 补杠时,你承担了 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
case AN_GANG -> scoreDelta > 0
|
||||
? "你完成暗杠,从其余未胡玩家处累计获得 " + formatScore(scoreDelta) + fanSummary + "。"
|
||||
: "座位 " + settlementResult.actorSeatNo() + " 暗杠时,你承担了 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
case TUI_SHUI -> scoreDelta > 0
|
||||
? "你在局末获得退税回补,拿回 " + formatScore(scoreDelta)
|
||||
+ ",来源座位 " + settlementResult.sourceSeatNo() + "。"
|
||||
: "你被执行退税,向座位 " + settlementResult.actorSeatNo() + " 回吐了 "
|
||||
+ formatScore(scoreDelta) + "。";
|
||||
case CHA_JIAO -> scoreDelta > 0
|
||||
? "流局查叫阶段,你从座位 " + settlementResult.sourceSeatNo() + " 获得 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。"
|
||||
: "流局查叫阶段,你向座位 " + settlementResult.actorSeatNo() + " 支付了 "
|
||||
+ formatScore(scoreDelta) + fanSummary + "。";
|
||||
};
|
||||
}
|
||||
|
||||
private String settlementTypeLabel(SettlementType settlementType) {
|
||||
return switch (settlementType) {
|
||||
case DIAN_PAO_HU -> "点炮胡";
|
||||
case QIANG_GANG_HU -> "抢杠胡";
|
||||
case ZI_MO_HU -> "自摸胡";
|
||||
case BU_GANG -> "补杠";
|
||||
case MING_GANG -> "明杠";
|
||||
case AN_GANG -> "暗杠";
|
||||
case TUI_SHUI -> "退税";
|
||||
case CHA_JIAO -> "查叫";
|
||||
};
|
||||
}
|
||||
|
||||
private String formatScore(int scoreDelta) {
|
||||
return scoreDelta > 0 ? "+" + scoreDelta : String.valueOf(scoreDelta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.xuezhanmaster.review.service;
|
||||
|
||||
import com.xuezhanmaster.game.domain.GameSession;
|
||||
import com.xuezhanmaster.game.domain.SettlementDetail;
|
||||
import com.xuezhanmaster.game.domain.SettlementFan;
|
||||
import com.xuezhanmaster.game.domain.SettlementResult;
|
||||
import com.xuezhanmaster.game.domain.TileSuit;
|
||||
import com.xuezhanmaster.game.dto.GameStateResponse;
|
||||
import com.xuezhanmaster.game.engine.GameEngine;
|
||||
import com.xuezhanmaster.game.service.BloodBattleScoringService;
|
||||
import com.xuezhanmaster.game.service.DeckFactory;
|
||||
import com.xuezhanmaster.game.service.GameActionProcessor;
|
||||
import com.xuezhanmaster.game.service.GameSessionService;
|
||||
import com.xuezhanmaster.game.service.HuEvaluator;
|
||||
import com.xuezhanmaster.game.service.ResponseActionResolver;
|
||||
import com.xuezhanmaster.game.service.ResponseActionWindowBuilder;
|
||||
import com.xuezhanmaster.game.service.SettlementService;
|
||||
import com.xuezhanmaster.review.dto.ReviewSummaryResponse;
|
||||
import com.xuezhanmaster.room.dto.CreateRoomRequest;
|
||||
import com.xuezhanmaster.room.dto.RoomSummaryResponse;
|
||||
import com.xuezhanmaster.room.dto.ToggleReadyRequest;
|
||||
import com.xuezhanmaster.room.service.RoomService;
|
||||
import com.xuezhanmaster.strategy.service.StrategyService;
|
||||
import com.xuezhanmaster.teaching.service.PlayerVisibilityService;
|
||||
import com.xuezhanmaster.teaching.service.TeachingService;
|
||||
import com.xuezhanmaster.ws.service.GameMessagePublisher;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.messaging.simp.SimpMessagingTemplate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class ReviewServiceTest {
|
||||
|
||||
private final RoomService roomService = new RoomService();
|
||||
private final HuEvaluator huEvaluator = new HuEvaluator();
|
||||
private final BloodBattleScoringService bloodBattleScoringService = new BloodBattleScoringService(huEvaluator);
|
||||
private final SettlementService settlementService = new SettlementService(bloodBattleScoringService);
|
||||
private final GameSessionService gameSessionService = new GameSessionService(
|
||||
roomService,
|
||||
new GameEngine(new DeckFactory()),
|
||||
new GameActionProcessor(huEvaluator),
|
||||
new ResponseActionWindowBuilder(huEvaluator),
|
||||
new ResponseActionResolver(),
|
||||
settlementService,
|
||||
bloodBattleScoringService,
|
||||
huEvaluator,
|
||||
new StrategyService(),
|
||||
new PlayerVisibilityService(),
|
||||
new TeachingService(),
|
||||
new GameMessagePublisher(mock(SimpMessagingTemplate.class))
|
||||
);
|
||||
private final ReviewService reviewService = new ReviewService(gameSessionService);
|
||||
|
||||
@Test
|
||||
void shouldBuildReviewSummaryFromRealSessionSettlementHistory() {
|
||||
RoomSummaryResponse room = roomService.createRoom(new CreateRoomRequest("host-1", true));
|
||||
roomService.toggleReady(room.roomId(), new ToggleReadyRequest("host-1", true));
|
||||
|
||||
GameStateResponse started = gameSessionService.startGame(room.roomId(), "host-1");
|
||||
gameSessionService.selectLackSuit(started.gameId(), "host-1", TileSuit.WAN.name());
|
||||
GameSession session = gameSessionService.getSessionForReview(started.gameId());
|
||||
|
||||
SettlementResult positiveGang = settlementService.settleSupplementalGang(session.getTable(), 0, "三万");
|
||||
session.getSettlementHistory().add(positiveGang);
|
||||
SettlementResult negativeChaJiao = settlementService.settleChaJiao(
|
||||
session.getTable(),
|
||||
2,
|
||||
0,
|
||||
"七筒",
|
||||
new SettlementDetail(1, 2, 4, List.of(
|
||||
new SettlementFan("QING_YI_SE", "清一色", 2)
|
||||
))
|
||||
);
|
||||
session.getSettlementHistory().add(negativeChaJiao);
|
||||
|
||||
ReviewSummaryResponse review = reviewService.createGameReviewSummary(started.gameId(), "host-1");
|
||||
|
||||
assertThat(review.gameId()).isEqualTo(started.gameId());
|
||||
assertThat(review.userId()).isEqualTo("host-1");
|
||||
assertThat(review.playerNickname()).isEqualTo("Host");
|
||||
assertThat(review.finalScore()).isEqualTo(-1);
|
||||
assertThat(review.resultLabel()).isEqualTo("本局失分");
|
||||
assertThat(review.settlementTimeline()).hasSize(2);
|
||||
assertThat(review.settlementTimeline())
|
||||
.extracting(item -> item.title())
|
||||
.containsExactly("补杠收益", "流局查叫失分");
|
||||
assertThat(review.mistakeInsights())
|
||||
.extracting(item -> item.title())
|
||||
.contains("流局未成叫");
|
||||
assertThat(review.trainingFocuses())
|
||||
.extracting(item -> item.drillType())
|
||||
.contains("READY_HAND", "GANG_TIMING");
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,7 @@ room 房间、座位、加入、准备
|
||||
game 对局、动作、状态、事件、动作处理
|
||||
strategy 推荐动作、AI 决策
|
||||
teaching 教学建议、玩家可见状态
|
||||
review 局后复盘、错题与训练方向协议骨架
|
||||
web 演示或基础接口
|
||||
ws WebSocket 配置与消息发布
|
||||
```
|
||||
@@ -287,11 +288,24 @@ ws WebSocket 配置与消息发布
|
||||
同时,已完成第一轮最小拆分准备:
|
||||
|
||||
- 共享类型:`frontend/src/types/game.ts`
|
||||
- 复盘类型:`frontend/src/types/review.ts`
|
||||
- UI 格式化工具:`frontend/src/utils/gameUi.ts`
|
||||
- 展示组件:
|
||||
- `frontend/src/components/AppShell.vue`
|
||||
- `frontend/src/components/RoomWorkspace.vue`
|
||||
- `frontend/src/components/GameWorkspace.vue`
|
||||
- `frontend/src/components/RoomControlPanel.vue`
|
||||
- `frontend/src/components/RoomLobbyPanel.vue`
|
||||
- `frontend/src/components/GameActionDock.vue`
|
||||
- `frontend/src/components/GameMessageStack.vue`
|
||||
- `frontend/src/components/PublicEventTimeline.vue`
|
||||
- `frontend/src/components/ViewSwitchPanel.vue`
|
||||
- `frontend/src/components/SelfHandPanel.vue`
|
||||
- `frontend/src/components/PublicSeatBoard.vue`
|
||||
- 页面级容器目录已建立:
|
||||
- `frontend/src/pages/RoomPageContainer.vue`
|
||||
- `frontend/src/pages/GamePageContainer.vue`
|
||||
- `frontend/src/pages/ReviewPageContainer.vue`
|
||||
|
||||
后续建议按页面拆分为:
|
||||
|
||||
|
||||
@@ -65,6 +65,22 @@
|
||||
|
||||
当前阶段不要求一次性引入完整路由,但组件组织和文档命名要按这个终态准备。
|
||||
|
||||
当前代码层已经落下对应的页面级容器命名:
|
||||
|
||||
- `frontend/src/pages/RoomPageContainer.vue`
|
||||
- `frontend/src/pages/GamePageContainer.vue`
|
||||
- `frontend/src/pages/ReviewPageContainer.vue`
|
||||
|
||||
其中 `ReviewPageContainer` 当前仍是占位实现,用于固定页面职责,不代表已经接入真实复盘数据。
|
||||
|
||||
当前还补了一条最小演示协议入口:
|
||||
|
||||
- 后端真实接口:`GET /api/games/{gameId}/review?userId={userId}`
|
||||
|
||||
它的目标不是替代真实局后数据,而是先把前后端在 `ReviewSummaryResponse` 这套字段上的语义对齐。
|
||||
|
||||
当前前端已经能在不接入正式路由的前提下,手动加载这条 demo 复盘数据并渲染 `ReviewPageContainer`。
|
||||
|
||||
### 3.2 当前这一轮的落地点
|
||||
|
||||
本轮先聚焦 `GamePage`。
|
||||
|
||||
@@ -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>
|
||||
|
||||
31
frontend/src/components/AppShell.vue
Normal file
31
frontend/src/components/AppShell.vue
Normal 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>
|
||||
163
frontend/src/components/GameWorkspace.vue
Normal file
163
frontend/src/components/GameWorkspace.vue
Normal 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>
|
||||
39
frontend/src/components/PublicSeatBoard.vue
Normal file
39
frontend/src/components/PublicSeatBoard.vue
Normal 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>
|
||||
97
frontend/src/components/RoomControlPanel.vue
Normal file
97
frontend/src/components/RoomControlPanel.vue
Normal 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>
|
||||
68
frontend/src/components/RoomLobbyPanel.vue
Normal file
68
frontend/src/components/RoomLobbyPanel.vue
Normal 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>
|
||||
68
frontend/src/components/RoomWorkspace.vue
Normal file
68
frontend/src/components/RoomWorkspace.vue
Normal 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>
|
||||
60
frontend/src/components/SelfHandPanel.vue
Normal file
60
frontend/src/components/SelfHandPanel.vue
Normal 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>
|
||||
37
frontend/src/components/ViewSwitchPanel.vue
Normal file
37
frontend/src/components/ViewSwitchPanel.vue
Normal 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>
|
||||
90
frontend/src/pages/GamePageContainer.vue
Normal file
90
frontend/src/pages/GamePageContainer.vue
Normal 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>
|
||||
156
frontend/src/pages/ReviewPageContainer.vue
Normal file
156
frontend/src/pages/ReviewPageContainer.vue
Normal 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>
|
||||
59
frontend/src/pages/RoomPageContainer.vue
Normal file
59
frontend/src/pages/RoomPageContainer.vue
Normal 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>
|
||||
34
frontend/src/types/review.ts
Normal file
34
frontend/src/types/review.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user