“每日一题”看起来只有四个选项和一个提交按钮但它同时跨越日期、页面状态、本地存储、错题本和学习统计。任何一层用自己的布尔值推断结果都会出现用户能直接感知的矛盾页面提示“已加入错题本”实际写入失败当天重新答对后错题本仍保留旧记录北京时间零点已经换日题目却到早上八点才变化连续点击提交页面和持久化任务交错执行。本文基于化学实验室 1.0.5的真实 ArkTS 源码检查DailyQuizPage.ets、DailyQuiz.ets与DataStore.ets。现有实现已经完成五道离线题、日期答案恢复、错题写入和解释展示基本功能可运行。问题集中在状态边界页面先把hasSubmitted设为true再等待保存答案与错题分两次写入数据层吞掉异常并返回void题目轮换使用 UTC 日序而历史键使用本地日期。本文唯一复核标记CHEM-LAB-ONE12-DAILY-QUIZ-SYNC-20260729更稳的改造目标是一次答题提交必须形成不可重复、可恢复、可核对的结果快照。页面只收集选择并渲染状态答题服务决定状态迁移本地仓库负责幂等写入统计从已保存历史派生下一题推进必须明确区分“每日模式”和“练习模式”。本文将解决以下问题让题目轮换和答案历史使用同一个本地日期键。用状态机替换互相组合的hasSubmitted、isCorrect和feedback。阻止重复提交并在保存失败时保留用户选择。让答错、重答正确、错题本和统计结果保持一致。给“下一题”建立明确推进规则不破坏每日唯一题目的产品语义。提供跨日、重复点击、损坏 JSON 和写入失败的验证矩阵。一、先核对真实数据链路当前工程的答题链路由三层组成。层级真实文件已有能力主要风险题库模型model/DailyQuiz.ets5 道题、选项、答案、解析、日期函数题目索引与日期键时区不一致页面状态DailyQuizPage.ets恢复答案、选择、提交、反馈、错题入口布尔状态组合、重复提交、先成功后保存本地存储utils/DataStore.etsPreferences 字符串读写异常被吞掉调用方无法判断失败页面首次出现时执行aboutToAppear(): void { this.question getTodayQuestion() this.todayKey getTodayKey() this.loadTodayAnswer() }这段代码的意图清晰先确定今天的题和日期再恢复历史。但loadTodayAnswer()是异步函数页面没有先重置旧状态也没有验证异步结果仍属于当前日期。后续如果页面实例跨日复用或恢复期间用户已经进行选择旧请求可能覆盖新会话。二、同一天必须只有一个时间定义getTodayKey()使用本地年月日export function getTodayKey(): string { const date new Date() const month ${date.getMonth() 1}.padStart(2, 0) const day ${date.getDate()}.padStart(2, 0) return ${date.getFullYear()}-${month}-${day} }但getTodayQuestion()用的是从 Unix 时间戳计算的 UTC 日序export function getTodayQuestion(): DailyQuizQuestion { const dayIndex Math.floor(Date.now() / 86400000) return DAILY_QUIZ_QUESTIONS[dayIndex % DAILY_QUIZ_QUESTIONS.length] }在中国时区UTC 零点对应北京时间早上八点。因此本地日期在零点已经变成新的一天题目索引却可能仍是前一天。此时历史键和题目身份不再同步用户会在新日期下保存旧题答案。更稳的做法是先生成本地日期键再从同一个键计算稳定日序export function getLocalDateKey(now: Date new Date()): string { const year now.getFullYear() const month ${now.getMonth() 1}.padStart(2, 0) const day ${now.getDate()}.padStart(2, 0) return ${year}-${month}-${day} } export function dateKeyToDayIndex(dateKey: string): number { const [year, month, day] dateKey.split(-).map(Number) return Math.floor(Date.UTC(year, month - 1, day) / 86400000) } export function getQuestionForDate(dateKey: string): DailyQuizQuestion { const dayIndex dateKeyToDayIndex(dateKey) return DAILY_QUIZ_QUESTIONS[dayIndex % DAILY_QUIZ_QUESTIONS.length] }这里用Date.UTC不是为了按 UTC 换题而是把已经确定的本地年月日转换为不受运行时偏移影响的整数。日期键和题目都来自同一输入零点边界就不会分叉。三、答题记录需要可追踪身份现有DailyQuizAnswer包含日期、题目 ID、用户选项和正确性已经具备最小恢复能力export interface DailyQuizAnswer { date: string questionId: string selectedOptionId: string isCorrect: boolean }如果要可靠处理重复提交、版本升级和统计建议增加提交身份与时间export interface DailyQuizAnswerV2 { schemaVersion: 2 submissionId: string dateKey: string questionId: string selectedOptionId: string correctOptionId: string isCorrect: boolean submittedAt: number }submissionId可以由dateKey questionId生成因为每日模式同一天同一道题只保留一份最终结果export function buildSubmissionId(dateKey: string, questionId: string): string { return ${dateKey}::${questionId} }这比Date.now()更适合作为幂等键。用户重复点击、页面重试或应用重启后再次提交都会覆盖同一个逻辑记录而不是产生多个难以统计的答案。四、用状态机替换布尔值组合页面当前同时维护State selectedOptionId: string State hasSubmitted: boolean false State isCorrect: boolean false State feedback: string 四个字段可以组合出页面并不支持的状态例如hasSubmittedfalse但isCorrecttrue或hasSubmittedtrue但反馈为空。更适合的模型是一个带阶段的会话状态。export type QuizPhase loading | answering | submitting | resolved | save_failed export interface QuizResultSnapshot { selectedOptionId: string correctOptionId: string isCorrect: boolean explanation: string } export interface QuizSessionState { dateKey: string question: DailyQuizQuestion selectedOptionId: string phase: QuizPhase result?: QuizResultSnapshot errorMessage: string }状态迁移被限制为loading - answering - submitting - resolved \- save_failed - submitting resolved - answering仅显式重新作答页面不再单独推断颜色和按钮文案而是根据phase和result渲染。这样“正在保存”期间不会再次提交“保存失败”也不会显示“已加入错题本”。五、初始化时先清空旧会话跨日或重新进入页面时必须在启动异步恢复前建立新的会话。否则没有历史答案的新一天会继承昨天的已提交状态。private beginSession(dateKey: string): void { this.session { dateKey, question: getQuestionForDate(dateKey), selectedOptionId: , phase: loading, result: undefined, errorMessage: } } aboutToAppear(): void { const dateKey getLocalDateKey() this.beginSession(dateKey) this.restoreSession(dateKey, this.session.question.id) }初始化先同步完成页面可以立即展示加载占位而不是短暂显示上一次题目。恢复函数的参数是快照不直接依赖随后可能变化的this.todayKey。六、异步恢复要防止旧请求覆盖新页面Preferences 读取通常很快但状态安全不能依赖“它应该很快”。可以使用递增令牌确认返回结果仍属于当前恢复任务。private restoreToken: number 0 private async restoreSession(dateKey: string, questionId: string): Promisevoid { const token this.restoreToken const answer await this.quizRepository.findAnswer(dateKey, questionId) if (token ! this.restoreToken) return if (this.session.dateKey ! dateKey || this.session.question.id ! questionId) return if (!answer) { this.session { ...this.session, phase: answering } return } this.session { ...this.session, selectedOptionId: answer.selectedOptionId, phase: resolved, result: this.toResultSnapshot(answer, this.session.question) } }令牌解决的是页面生命周期竞态日期和题目检查解决的是业务身份竞态。两层条件都满足后恢复结果才可以写入 UI。七、选项点击只改变答题草稿现有OptionRow在未提交时修改selectedOptionId这个边界是合理的。状态机版本只需限制可编辑阶段private selectOption(optionId: string): void { if (this.session.phase ! answering this.session.phase ! save_failed) return const exists this.session.question.options.some( (option: QuizOption) option.id optionId ) if (!exists) return this.session { ...this.session, selectedOptionId: optionId, phase: answering, errorMessage: } }失败重试时允许用户更换答案但不会修改已经持久化的历史。选项 ID 还要在题目选项集合中验证避免损坏的恢复数据或错误参数进入评分逻辑。八、提交前先加并发锁当前submitAnswer()没有isSubmitting按钮在第一次点击后会立刻显示“重新作答”快速连点可能让第二次点击执行resetCurrent()而第一次保存仍在进行。最终 UI 已被清空存储却写入了答案。状态机本身可以承担并发锁private async submitAnswer(): Promisevoid { if (this.session.phase submitting) return if (!this.session.selectedOptionId) { this.session { ...this.session, errorMessage: 请先选择一个答案。 } return } const submission this.buildSubmission(this.session) this.session { ...this.session, phase: submitting, errorMessage: } const result await this.quizService.commit(submission) this.applyCommitResult(submission, result) }按钮根据阶段禁用Button(this.session.phase submitting ? 正在保存 : 提交答案) .enabled(this.session.phase answering || this.session.phase save_failed) .onClick(() this.submitAnswer())用户可以清楚知道当前动作尚未完成重复事件也会在方法入口直接返回。九、判题应是纯函数判题不需要访问页面、Preferences 或路由。把它写成纯函数可以独立验证所有题目数据。export function evaluateAnswer( question: DailyQuizQuestion, selectedOptionId: string ): QuizResultSnapshot { const optionExists question.options.some( (option: QuizOption) option.id selectedOptionId ) if (!optionExists) { throw new Error(Unknown option: ${selectedOptionId}) } return { selectedOptionId, correctOptionId: question.answerId, isCorrect: selectedOptionId question.answerId, explanation: question.explanation } }纯函数只信任经过验证的题目和选项输出不可变结果快照。页面颜色、反馈文字、错题记录和统计都引用这个结果而不是再次比较字符串。十、仓库层必须把解析失败说清楚当前页面每次都读取 JSON 并在catch中使用空数组。这能避免崩溃却会把“没有记录”和“记录损坏”混成同一状态。更稳的仓库返回结构化结果export interface RepositoryResultT { ok: boolean value?: T error?: read_failed | invalid_json | write_failed } export class QuizRepository { async loadAnswers(): PromiseRepositoryResultDailyQuizAnswerV2[] { const json await DataStore.getString(daily_quiz_answers_v2, []) try { const parsed JSON.parse(json) as DailyQuizAnswerV2[] return { ok: true, value: parsed } } catch (_) { return { ok: false, error: invalid_json } } } }如果底层DataStore.putString()继续吞掉写入异常仓库仍无法确认保存成功。应为需要一致性的业务增加可返回结果的写入方法保留旧方法兼容其他页面。static async tryPutString(key: string, value: string): Promiseboolean { if (!DataStore.prefInstance) return false try { await DataStore.prefInstance.put(key, value) await DataStore.prefInstance.flush() DataStore.notifyStatsChanged(key, value) return true } catch (_) { return false } }页面只有在仓库返回成功后才能显示“今日答案已保存”。十一、答案与错题要作为一次提交处理现有流程先保存答案再在答错时保存错题。两次写入之间如果失败就会出现答案已存在但错题缺失。Preferences 没有跨键事务时可以把每日答案与错题索引组合成一个文档或通过服务层执行可恢复的顺序写入。组合文档示例export interface QuizLocalDocument { schemaVersion: 2 answers: DailyQuizAnswerV2[] wrongQuestions: WrongQuestionRecord[] updatedAt: number }一次提交只做一次put flushasync commit(submission: QuizSubmission): PromiseQuizCommitResult { const loaded await this.repository.loadDocument() if (!loaded.ok || !loaded.value) return { ok: false, reason: read_failed } const next reconcileQuizDocument(loaded.value, submission) const saved await this.repository.saveDocument(next) return saved ? { ok: true, snapshot: submission.result } : { ok: false, reason: write_failed } }如果必须保留两个旧键则提交服务要记录中间状态并提供补偿但对小型离线应用一个版本化组合文档更容易验证。十二、重新答对应同步修正错题本当前逻辑在答错时写入错题但重新作答正确后不会移除旧错题。页面可能显示“回答正确”错题本仍保留同一道题。一致性规则应该明确export function reconcileWrongQuestions( records: WrongQuestionRecord[], submission: QuizSubmission ): WrongQuestionRecord[] { const withoutCurrent records.filter( (item: WrongQuestionRecord) item.questionId ! submission.question.id ) if (submission.result.isCorrect) return withoutCurrent return [buildWrongQuestion(submission), ...withoutCurrent].slice(0, 50) }这里选择“答对后移出错题本”。如果产品希望保留首次错误历史应把“历史错误”和“当前待复习错题”拆成两个字段而不是让同一数组同时承担两种语义。十三、统计从答案历史派生不手工累加统计值最容易因重复提交而漂移。例如每点击一次就把答题数加一重新作答会被计算两次。幂等答案列表已经包含完整事实统计可以在保存后直接投影。export interface DailyQuizStats { answeredDays: number correctDays: number wrongDays: number accuracy: number } export function projectStats(answers: DailyQuizAnswerV2[]): DailyQuizStats { const unique new Mapstring, DailyQuizAnswerV2() for (const answer of answers) { unique.set(answer.submissionId, answer) } const values Array.from(unique.values()) const correct values.filter((answer: DailyQuizAnswerV2) answer.isCorrect).length return { answeredDays: values.length, correctDays: correct, wrongDays: values.length - correct, accuracy: values.length 0 ? 0 : correct / values.length } }统计不是另一个需要维护的事实源而是答案历史的派生结果。即使用户重试或旧数据迁移也能重新计算出一致值。十四、“下一题”要区分每日模式和练习模式当前模型名称是每日一题产品语义通常是当天只有一题。此时提交后的主动作应是“查看解析”或“明日再来”不能悄悄切到下一题并覆盖当天历史。如果确实需要连续练习应显式增加模式export type QuizMode daily | practice export interface QuizRouteParams { mode?: QuizMode startQuestionId?: string }推进逻辑只在练习模式执行private moveNext(): void { if (this.mode ! practice || this.session.phase ! resolved) return const questions getDailyQuizQuestions() const currentIndex questions.findIndex( (item: DailyQuizQuestion) item.id this.session.question.id ) const nextIndex (Math.max(currentIndex, 0) 1) % questions.length this.beginPracticeQuestion(questions[nextIndex]) }每日模式的记录键是dateKey questionId练习模式应该使用独立的练习记录模型避免一轮连续练习污染每日统计。十五、下一题推进必须一次性重置相关状态只更换question而忘记清空selectedOptionId、结果或错误信息会让新题继承旧题状态。推进应使用单一入口private beginPracticeQuestion(question: DailyQuizQuestion): void { this.restoreToken this.session { dateKey: getLocalDateKey(), question, selectedOptionId: , phase: answering, result: undefined, errorMessage: } }递增restoreToken同时让上一题未完成的异步恢复失效。状态替换采用完整对象不依赖逐字段赋值顺序。十六、反馈文字必须反映真实持久化结果现有代码在保存前设置this.feedback this.isCorrect ? 回答正确知识点掌握不错。 : 回答错误已加入错题本。“已加入”是一个持久化事实不能在写入前承诺。可以根据提交结果生成反馈private applyCommitResult( submission: QuizSubmission, commit: QuizCommitResult ): void { if (!commit.ok) { this.session { ...this.session, phase: save_failed, errorMessage: 结果尚未保存请重试。 } return } this.session { ...this.session, phase: resolved, result: submission.result, errorMessage: } }展示层再根据result.isCorrect输出“回答正确”或“回答错误已记录到错题本”。这样文案和真实数据状态一致。十七、页面要覆盖完整交互状态每日一题页面至少需要以下状态阶段页面表现主按钮选项是否可改loading骨架或加载提示禁用否answering题目与选项提交答案是submitting保留选择并显示保存中禁用否resolved正误、正确答案、解析每日模式不推进否save_failed明确失败原因重试保存是空题库说明暂无题目返回否选项文本目前限制为两行。化学方程式或实验安全题可能更长应验证小屏和大字体场景必要时使用最小高度而不是固定height(58)让内容撑开但保持触控区域稳定。十八、验证矩阵覆盖跨日与重复操作核心逻辑可以通过纯函数测试和页面手工验证组合完成。const dateKey 2026-07-16 const question getQuestionForDate(dateKey) const result evaluateAnswer(question, question.answerId) console.info(${dateKey}:${question.id}:${result.isCorrect})场景操作预期结果北京时间 23:59 → 00:00重新进入页面日期键与题目同时切换北京时间 07:59 → 08:00保持页面不发生第二次换题未选择答案点击提交不写存储提示先选择快速连续点击连点提交只执行一次commit写入失败提交答案保留选项显示可重试状态答错提交答案与错题在同一文档成功保存重新答对再次提交覆盖每日答案并移出待复习错题应用重启进入当天页面恢复选择、结果和解析JSON 损坏进入页面显示恢复失败不伪装为空历史练习下一题点击推进新题所有会话状态一次性重置十九、常见故障与排查顺序现象优先检查典型原因修复方向零点题目没变日期键与题目索引UTC 日序和本地日期混用从同一dateKey选题页面说保存成功但重启丢失仓库返回值putString吞掉异常提供布尔或结果类型连点后页面被重置提交阶段第一次保存中触发重新作答submitting阶段禁用按钮答对后错题仍存在错题协调只在答错时新增不在答对时移除用reconcileWrongQuestions统计答题数偏大统计来源每次点击都累加按submissionId从历史投影新题带着旧选项推进函数逐字段更新遗漏替换完整会话对象返回页面显示昨天结果异步恢复旧请求覆盖新会话恢复令牌加身份校验排查顺序应是日期身份、题目身份、提交阶段、持久化结果、派生统计最后才看颜色和按钮文案。UI 往往只是状态错误的表面。二十、建议的落地顺序统一getLocalDateKey()与getQuestionForDate()先消除跨日错位。建立QuizSessionState把页面布尔值收敛为阶段状态。为提交增加submitting防重并在保存成功后进入resolved。给 DataStore 增加可报告写入结果的方法不再让页面猜测成功。把答案和错题迁移到版本化组合文档使用幂等提交 ID。重答正确时同步移出待复习错题。从答案历史投影每日统计不维护重复计数器。将“每日模式”和“练习模式”拆开再实现下一题推进。执行跨日、连点、失败重试、重启恢复和损坏数据验证。这套顺序优先修复身份和持久化事实再处理页面体验不需要一次性重写全部学习模块。二十一、源码事实与建议实现的边界每日答题涉及日期、页面、Preferences 和错题记录最容易把“设计上应该一致”误写成“当前已经一致”。下面将真实实现与建议方案分开列出便于逐项打开源码复核。类别当前源码可直接确认本文建议但未声称已合入离线题库DAILY_QUIZ_QUESTIONS当前包含 5 道题扩展题库、版本号和题目迁移策略每日选题Date.now() / 86400000计算 UTC 日序并取模从同一个本地dateKey推导题目与历史身份历史键getTodayKey()使用设备本地年月日明确时区策略并为跨日恢复增加身份校验页面状态使用selectedOptionId、hasSubmitted、isCorrect、feedback收敛为 idle/selecting/submitting/resolved/error 状态机提交顺序页面先写已提交和反馈再异步保存答案与错题保存成功后再进入 resolved失败时保留草稿并可重试答案存储daily_quiz_answers去重后最多保留 60 条使用submissionId和版本化组合文档增强幂等性错题存储答错时写入按questionId替换并最多保留 50 条重答正确时同步移出待复习错题数据层结果putString()捕获异常并返回Promisevoid返回明确成功/失败结果页面据此显示反馈下一题当前页面没有“下一题”推进能力只有重新作答区分每日模式与练习模式后再设计推进规则学习统计当前每日一题文件没有维护专属统计投影从已保存答案历史派生答题数与正确数文本编码当前目标源码中的部分中文字符串呈现编码异常先统一源文件编码并通过构建、模拟器或真机确认显示本文给出的状态机、仓库和提交流程是面向现有问题的改造设计没有声称已经完成 ArkTS 编译、跨日运行、Preferences 故障注入或真机验证。落地时应按第二十节逐步实施并保存测试证据。总结每日一题真正困难的不是判定 A、B、C、D而是让日期、题目、选择、提交、错题和统计共同描述同一次答题。把本地日期键作为题目和历史的共同身份用状态机限制提交阶段用幂等结果快照协调答案与错题再从持久化历史派生统计页面就不需要依靠多个布尔值猜测业务结果。完成这些边界后用户快速连点、跨日打开、答错后重答、应用重启或本地写入失败时系统都能回到可解释状态。下一题也不再是简单地替换题目对象而是在明确的每日或练习模式下创建一个新的、干净的答题会话。AI 辅助声明本文由 AI 辅助完成源码梳理、结构校对与配图设计技术结论均以文中列出的本地 ArkTS 源码为依据并由作者复核后发布。