羽球搭子 HarmonyOS 实战(17):比分撤销与边界校验 一、撤销不是把较大的一方减一分比分从 8:7 变成 8:8 后发现误触正确撤销结果应该回到 8:7仅比较当前大小会把 A 队减成 7:8。要恢复最近一次动作必须记录“哪支队伍获得了这一分”而不是根据最终比分猜测。多场比赛同时计分时历史还要按比赛标识隔离否则在第二场点击撤销可能改动第一场。一个轻量方案是MapmatchId, Team[]每次有效1将队伍压栈撤销时弹出栈顶并把对应比分减一。空栈、比赛不存在、比赛已结束都直接拒绝。ArkUI 的状态更新仍使用不可变数组替换具体状态管理原则可参考ArkUI 状态管理概述。二、先定义撤销的语义边界用户通常把“撤销”理解为回退最近一次按钮加分而不是恢复任意历史快照。直接录入 15:12、重置为 0:0、从云端覆盖比分都会建立新的基线旧的逐分历史已经无法解释应立即清空。比赛结束后也不继续撤销若产品需要修改完赛比分应走独立的“编辑结果”流程并记录审计事件。操作是否写入动作栈是否清空动作栈是否允许撤销有效 1是记录 A 或 B否是在 0 分执行 -1否否不改变状态直接录入比分否是从新基线重新记录重置比分否是重置前历史作废比赛结束否是否云端快照覆盖否是以服务端快照为基线这种定义简单且可向用户解释。如果要支持多步重做还需要双栈和更多动作类型但普通球场快记没有必要一开始就引入完整命令系统。三、只记录真正生效的加分动作栈必须在比分确认变化后写入。比赛已结束、标识无效或变化量为零时不能记录减分也不能伪装成可撤销的加分。记录函数同时接收旧比分和新比分只有目标队伍确实增加才压栈。private recordHistory( matchId: string, team: Team, delta: number, before: Score, after: Score ): void { if (delta 0) return const changed team A ? after.a before.a : after.b before.b if (!changed) return const stack this.scoreHistory.get(matchId) ?? [] stack.push(team) this.scoreHistory.set(matchId, stack) }如果未来支持“一次加 2 分”动作记录最好升级成{ team, delta, before }否则一次撤销只能减一。当前所有快捷按钮都以单分为单位队伍栈已经足够但要把这个前提写进模型约束。四、撤销按栈顶动作回退撤销先检查栈是否存在再检查比赛仍处于playing。顺序很重要不能先pop再发现比赛已结束否则历史被无声丢弃。比分回退使用非负收敛完成替换后才弹栈如果 UI 更新过程中抛错历史仍保留可再次尝试。undoLastScore(matchId: string): UndoResult { const stack this.scoreHistory.get(matchId) if (stack undefined || stack.length 0) { return { ok: false, message: 暂无可撤销的得分 } } const index this.matches.findIndex((item: ScoreMatch) item.id matchId) if (index 0 || this.matches[index].status ! playing) { return { ok: false, message: 当前比赛不可修改 } } const team stack[stack.length - 1] const current this.matches[index] const a team A ? Math.max(0, current.playerA.score - 1) : current.playerA.score const b team B ? Math.max(0, current.playerB.score - 1) : current.playerB.score this.replaceScore(index, a, b) stack.pop() return { ok: true, message: 已撤销 ${team} 队上一分 } }返回结构化结果比在领域函数里直接弹 Toast 更容易测试。页面根据ok决定反馈样式领域层只描述发生了什么。五、比分边界必须在所有入口一致除加减按钮外快捷比分、文本输入、语音指令和云端事件都可能改变比分。每个入口各写一套校验会逐渐分叉。可以把整数解析、范围收敛和状态检查封装成共同函数任何来源先得到ScoreValidation通过后再更新。function validateScore(rawA: number, rawB: number, maxScore: number): ScoreValidation { if (!Number.isFinite(rawA) || !Number.isFinite(rawB)) { return { valid: false, reason: 比分必须是数字 } } if (!Number.isInteger(rawA) || !Number.isInteger(rawB)) { return { valid: false, reason: 比分必须是整数 } } if (rawA 0 || rawB 0) { return { valid: false, reason: 比分不能为负数 } } if (rawA maxScore || rawB maxScore) { return { valid: false, reason: 比分不能超过 ${maxScore} } } return { valid: true, scoreA: rawA, scoreB: rawB } }边界输入处理原因8.5:7拒绝逐分计分只接受整数NaN:10拒绝文本解析失败51:20普通制拒绝或明确收敛超出产品保护上限22:20普通 21 分进入结束态满足到点且领先 2 分21:20抢 21进入结束态到点且不平分六、重置与直接录入要切断旧历史重置比分后若保留[A, B, A]下一次撤销会试图从 0:0 减分直接录入 18:16 后保留旧栈则撤销得到的“上一分”未必对应 18:16 的真实形成过程。两种操作都应先确认比赛可修改再替换比分并删除该场历史。resetScore(matchId: string): boolean { const match this.findPlayingMatch(matchId) if (match undefined) return false if (match.playerA.score 0 match.playerB.score 0) return false this.updateMatch(matchId, this.copyWithScore(match, 0, 0)) this.scoreHistory.delete(matchId) return true } applyDirectScore(matchId: string, a: number, b: number): boolean { const checked validateScore(a, b, this.maxScoreOf(matchId)) if (!checked.valid) return false this.updateScoreFromBaseline(matchId, checked.scoreA!, checked.scoreB!) this.scoreHistory.delete(matchId) return true }若需要撤销“重置”本身应把重置建模成完整快照命令而不是继续混用队伍栈。两种撤销语义不要同时隐藏在同一个按钮里。七、自动结束后的纠错要走独立流程普通 21 分制在 22:20 自动结束系统会清空动作栈并保存结果。如果用户此时发现最后一分误触直接撤销会让云端和统计页不知道比赛已从完成退回进行中。更安全的交互是进入“修正结果”弹窗展示原比分和新比分提交后产生score.updated事件并重新计算胜方、结束时间和统计。interface ScoreCorrection { matchId: string previousScoreA: number previousScoreB: number scoreA: number scoreB: number reason: string } function createCorrection(match: MatchItem, a: number, b: number): ScoreCorrection { return { matchId: match.id, previousScoreA: match.scoreA, previousScoreB: match.scoreB, scoreA: a, scoreB: b, reason: manual_correction } }这条路径比“让结束态重新可点击”多一步但能保留审计信息也便于云端按版本处理冲突。八、用动作序列而不是单点比分验收撤销测试应输入完整动作序列。只检查某个最终比分无法证明栈顺序正确也无法覆盖多场隔离和基线切换。1. A、B、B 依次得分确认比分 1:2连续撤销得到 1:1、1:0、0:0。 2. 在空栈继续撤销确认仅提示且比分不变化。 3. 同时创建两场比赛在两场分别加分确认撤销只影响目标场次。 4. 形成 6:4 后直接录入 15:12再撤销确认提示空栈而不是回到 14:12。 5. 重置后再次加分确认新动作栈从空开始。 6. 比赛结束后点击撤销确认被拒绝通过结果修正流程更新时产生独立记录。九、总结比分撤销的可靠性来自清晰语义它回退最近一次真实加分不猜测较大比分不跨比赛共享历史不越过直接录入和重置形成的新基线也不修改已经结束的比赛。按场次隔离的动作栈配合统一边界校验足以覆盖球场快记的大多数纠错场景需要修改完赛结果时再使用带审计信息的独立流程。

本月热点