羽毛球工具 HarmonyOS 实战(16):比分撤销与边界校验
一、撤销不是把较大的一方减一分
比分从 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. 比赛结束后点击撤销,确认被拒绝;通过结果修正流程更新时产生独立记录。
九、总结
比分撤销的可靠性来自清晰语义:它回退最近一次真实加分,不猜测较大比分,不跨比赛共享历史,不越过直接录入和重置形成的新基线,也不修改已经结束的比赛。按场次隔离的动作栈配合统一边界校验,足以覆盖球场快记的大多数纠错场景;需要修改完赛结果时,再使用带审计信息的独立流程。
更多推荐


所有评论(0)