羽球搭子 HarmonyOS 实战(17):比分撤销与边界校验

📅 2026/7/22 23:35:39 👁️ 阅读次数 📝 编程学习
羽球搭子 HarmonyOS 实战(17):比分撤销与边界校验

一、撤销不是把较大的一方减一分

比分从 8:7 变成 8:8 后发现误触,正确撤销结果应该回到 8:7;仅比较当前大小会把 A 队减成 7:8。要恢复最近一次动作,必须记录“哪支队伍获得了这一分”,而不是根据最终比分猜测。多场比赛同时计分时,历史还要按比赛标识隔离,否则在第二场点击撤销可能改动第一场。

一个轻量方案是Map<matchId, 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. 比赛结束后点击撤销,确认被拒绝;通过结果修正流程更新时产生独立记录。

九、总结

比分撤销的可靠性来自清晰语义:它回退最近一次真实加分,不猜测较大比分,不跨比赛共享历史,不越过直接录入和重置形成的新基线,也不修改已经结束的比赛。按场次隔离的动作栈配合统一边界校验,足以覆盖球场快记的大多数纠错场景;需要修改完赛结果时,再使用带审计信息的独立流程。