:练习日志的本地记录设计)
一、练习记录需要同时满足录入、回看与统计一次羽毛球练习不仅有“做过”这个布尔值还包含日期、持续时间、训练类型、文字记录和自评。页面要支持新增、编辑、批量删除也要即时计算总次数、总时长、本周次数和分类平均评分。练习记录属于结构化列表但当前规模较小、查询规则简单因此采用内存响应状态加 Preferences 持久化。若未来需要上万条记录、多条件检索或跨表统计再迁移到关系型存储。二、PracticeLog 用稳定 id 区分每次训练模型将业务字段和审计时间分开。id不依赖数组位置编辑和删除都可以准确定位同一条记录。export interface PracticeLog { id: string date: string durationMin: number type: string content: string rating: number createdAt: number updatedAt?: number } export const PRACTICE_TYPES: string[] [ 单打对练, 双打对练, 步伐训练, 发球练习, 体能训练, 其他 ]字段约束页面用途dateYYYY-MM-DD周统计与列表展示durationMin正整数总时长type预设类型之一分类聚合content可为空复盘备注rating1–5星级和平均分createdAt/updatedAt毫秒时间戳稳定排序与刷新键三、写入前先复制数组AppStorage中的列表是页面响应状态。直接在原数组上unshift或替换成员可能不能稳定触发依赖该数组的界面更新。服务层先复制再写回完整新数组。private static save(logs: PracticeLog[]): void { AppStorage.setOrCreatePracticeLog[](StorageKeys.PRACTICE_LOGS, logs) PreferencesHelper.get().persist(StorageKeys.PRACTICE_LOGS, logs) } static add(log: PracticeLog): void { const next: PracticeLog[] PracticeLogService.list().slice() next.unshift(log) PracticeLogService.save(next) }同一次save同时更新响应状态和持久化层当前页面立即刷新应用重启后又能恢复。页面不直接调用 Preferences避免各处使用不同键名或序列化格式。Preferences 的键值存储、内存加载与flush行为可参考华为官方 用户首选项持久化指南。练习列表规模增长后应重新评估整份 JSON 读写的成本。四、编辑和删除都通过 id 收敛编辑先复制当前数组再找到相同 id删除则使用过滤生成新数组。找不到待编辑记录时保持原状态不凭空新增重复项。static update(log: PracticeLog): void { const next: PracticeLog[] PracticeLogService.list().slice() const index: number next.findIndex(item item.id log.id) if (index 0) { next[index] log PracticeLogService.save(next) } } static remove(id: string): void { const next PracticeLogService.list().filter(item item.id ! id) PracticeLogService.save(next) }新增记录的 id 由时间与随机片段组合编辑记录保留原createdAt只更新updatedAt。列表的ForEach键包含 id 与更新时间因此内容变化后对应卡片会重新渲染。五、编辑页在提交前归一化输入页面字段只保存用户的临时输入提交时再组装PracticeLog。日期为空、时长不合法或评分越界都应在写入前拦截。const log: PracticeLog { id: existing?.id ?? PracticeLogService.genId(), date: this.date.trim(), durationMin: Number(this.durationText), type: this.type, content: this.content.trim(), rating: this.rating, createdAt: existing?.createdAt ?? Date.now(), updatedAt: existing ? Date.now() : undefined } if (existing) { PracticeLogService.update(log) } else { PracticeLogService.add(log) }页面跳转只携带可选logId。编辑已有记录时用 id 回读新建时没有 id使用默认日期、类型和评分。这样路由参数不会携带一整份可能过期的对象。六、统计值从当前记录重新计算总次数直接取数组长度总时长对durationMin求和平均评分按当前记录计算本周次数则比较每条日期与本周一。删除或编辑后不手工递增、递减多个计数器而是从新数组重新投影。private avgRating(): string { if (this.logs.length 0) return - let sum: number 0 for (const log of this.logs) { sum log.rating } return (sum / this.logs.length).toFixed(1) } private totalMinutes(): number { return this.logs.reduce((sum, log) sum log.durationMin, 0) }这种计算牺牲了极少量遍历成本却消除了“记录删除了总时长没减”“类型改了旧分类仍计数”等同步错误。七、空状态、批量删除与恢复边界首次进入且没有记录时页面显示明确空状态和“开始记录”入口。进入编辑模式后选中项保存在页面局部状态退出编辑模式立即清空避免下一次误删。异常或边界处理方式不允许的结果持久化数据为空返回空数组页面崩溃编辑 id 不存在不写入静默新增重复记录时长不是正数提交前提示写入NaN或负数批量删除未选中隐藏删除操作删除全部Preferences 写入失败保留当前状态并提示重试假装已永久保存当前实现是本地单端模型不应把它描述成云同步。跨设备共享需要额外的账号范围、冲突策略、重试队列和服务端标识。八、运行界面与验收路径现有运行界面验证了练习记录页面的空状态、主标题和新增入口。完整验收按以下顺序进行1. 从空状态新增一条练习返回后列表立即出现。 2. 重启应用记录仍存在且字段完整。 3. 编辑时长与评分统计卡片和分类平均分同步变化。 4. 新增不同类型记录分类统计分别归组。 5. 批量选择两条记录后删除未选中的记录保留。 6. 输入空日期、零时长或越界评分写入被拦截并可继续修正。持久化验收不能只看当前列表。保存后要彻底退出并重新进入应用核对日期、类型、备注、评分与统计是否一致随后编辑同一条记录并再次重启确认只存在一个 idcreatedAt保留而updatedAt变化。批量删除后也要重启验证防止 AppStorage 已删除但 Preferences 仍保留旧数组。若 JSON 解析失败页面应进入可恢复空状态并记录诊断信息不能在后台自动覆盖损坏数据而让排查线索消失。九、小列表也需要明确的数据合同练习日志规模不大但新增、编辑、删除、统计和持久化已经形成完整闭环。把数组复制、统一写入、id 定位和派生统计放进服务边界后页面只负责交互和展示未来迁移关系型存储时也能保持页面模型基本不变。迁移时可先为服务层增加 Repository 接口再把 Preferences 实现替换为关系型实现。页面继续调用新增、更新、删除和查询不感知表结构历史数据通过一次性迁移读取旧 JSON、校验每条字段、批量写入新库全部成功后再写迁移标记。任何一条失败都保留旧数据并允许重试避免半迁移状态。