
HarmonyOS 应用实战 53导入备份别直接覆盖先做差异预览和冲突选择用户把一段“题库名#答案1#答案2”的文本粘进《答案之书》时最危险的时刻并不是解析失败而是解析成功后直接把当前编辑内容替换掉。用户往往在这时才发现自己刚改的两条答案没了导入文本里还有重复项甚至根本分不清这次导入会新增、删掉还是覆盖什么。这个项目当前的导入链路其实很克制DeckImport.parseDeckImport只负责清洗文本ImportDeckDialog把成功结果回调给页面DeckCreatePage.applyImport仅把名称和答案填回编辑表单最终仍需用户点击“创建”才由DeckService.save写入仓储。它尚未实现备份合并或覆盖恢复。本文在这个真实边界上设计一层可审计的“差异预览”避免把建议能力误说成现有功能。先分清当前“导入文本”不是“恢复备份”两者都可能带来一组答案但提交语义不同场景当前项目的真实行为用户可撤回性不能省略的判断在新建页导入名称#答案…解析后回填本页表单关闭页面即可放弃文本格式、长度、去重后数量点击创建DeckService.save生成新题库并落库需删除新题库名称、最小/最大答案数从备份恢复到已有题库当前项目未实现风险高新增、修改、删除与冲突因此不能拿“解析成功”当作“可以覆盖”。当前parseDeckImport会trim每一段、跳过空项和超长项、用Set去重并在有效答案不足最小数量时返回错误它保护的是输入质量不知道“当前题库里有哪些旧数据”。差异判断必须在读到目标题库之后进行。为什么直接把解析结果塞回页面仍然不够DeckCreatePage.applyImport(name, answers)会为每条导入答案生成新的本地 key促使ForEach重新挂载TextInput这是为了解决输入框初始文本只读取一次的 UI 行为。它解决了“页面显示不刷新”却不代表可以承担恢复规则。如果以后把“导入”入口开放到编辑已有题库下面两种做法都会出事// 不要在确认导入时就把现有数据清空this.answersimportedAnswers.map((text:string)({text}));// 也不要让页面自己猜测是否可覆盖awaitDeckRepository.saveDeck(rawDeck);第一段会让用户失去比较机会第二段绕过了DeckService.save的名称、数量和刷新信号约束。页面适合收集选择Service 才应决定最终写什么。预览模型只描述差异不制造持久化副作用对于当前项目的文本格式答案没有跨导入稳定 id因此第一版只能以“清洗后的文本”识别重复。它足够解释新增与重复但不能可靠地判断“这条是改名后的旧答案”。这种限制应显式展示给用户。interfaceImportPreview{targetDeckId?:string;importedName:string;additions:string[];duplicates:string[];skipped:string[];canCommit:boolean;notices:string[];}typeImportStrategycreate|append|replace;这里没有Deck、AppStorage或 Repository 引用。预览阶段是纯计算同一份文本无论点几次都只能得到同一份结果只有用户选定策略后才进入写入路径。replace需要额外确认因为它会移除目标中未出现的答案。用现有解析器作为第一道门而不是另写一套规则建议能力应复用当前parseDeckImport否则“弹窗能导入、恢复入口却拒绝”的规则分叉迟早会发生。以下是可放进 HSP 服务层的示例其中DeckService.get是项目已有读入口。import{DeckService}from../services/DeckService;import{parseDeckImport}from../utils/DeckImport;asyncfunctionbuildImportPreview(raw:string,targetDeckId?:string):PromiseImportPreview{constparsedparseDeckImport(raw);if(!parsed.ok||!parsed.deck){return{importedName:,additions:[],duplicates:[],skipped:[],canCommit:false,notices:[parsed.error??导入文本无效]};}consttargettargetDeckId?awaitDeckService.get(targetDeckId):null;constexisting:SetstringnewSetstring(target?.answers.map((a)a.text)??[]);constadditionsparsed.deck.answers.filter((text)!existing.has(text));constduplicatesparsed.deck.answers.filter((text)existing.has(text));return{targetDeckId,importedName:parsed.deck.name,additions,duplicates,skipped:[],canCommit:additions.length0||!target,notices:target?[按文本比较改写后的答案会被视为新增]:[]};}这段代码的关键是读取目标题库后才建立existing集合。它信任解析器已完成的格式和长度校验却不信任页面传来的targetDeckId拿不到目标题库时不能悄悄降级为覆盖应停止提交并提示用户重新选择。三种策略的边界必须写在提交前差异预览不是多显示一行“发现 3 条变化”而是让用户在提交前看到操作含义。策略写入结果适合场景风险控制create调用DeckService.save新建题库从剪贴板导入一套新问题原题库完全不动append保留原答案只追加additions补充题目重复项不再次写入replace以导入后的集合为准用户明确恢复某一份快照二次确认并展示删除数量提交时不要直接调用DeckRepository。项目中的DeckService.save会清洗答案、校验最小/最大数量、生成答案 id、写入 Repository并在成功后更新AppStorageKey.LastDeckUpdateAt。这条顺序让列表页等订阅者在“最终事实已经落库”后才重新读取。asyncfunctioncommitPreview(preview:ImportPreview,strategy:ImportStrategy):Promisevoid{if(!preview.canCommit){thrownewError(preview.notices.join());}if(strategycreate){awaitDeckService.save({name:preview.importedName,answers:preview.additions});return;}consttargetpreview.targetDeckId?awaitDeckService.get(preview.targetDeckId):null;if(!target){thrownewError(目标题库不存在停止导入);}constanswersstrategyappend?[...target.answers.map((a)a.text),...preview.additions]:[...preview.duplicates,...preview.additions];awaitDeckService.save({id:target.id,name:target.name,answers,colorKey:target.colorKey});}示例故意把replace的结果写得直白它只保留本次导入解析到的答案。真实 UI 还应在按钮上标出“将删除 N 条未出现在导入文本中的答案”并让用户再次确认不要用一个模糊的“导入成功”掩盖数据删除。一个容易漏掉的约束保存会重建答案 id当前DeckService.save接收string[]然后为每条答案调用newId(a)创建新的Answer。这意味着用它更新已有题库时答案的 id 会整体变化。对于当前仅按答案文本收藏的项目这不一定立即出错但若后续收藏、分享或统计依赖answerIdappend/replace就不能直接复用这条保存接口。更稳的演进路线是先把“文本导入”限制在创建题库只有在领域模型为答案引入稳定身份、并设计清楚旧 id 的保留规则后再开放已有题库的合并恢复。不要为了一个导入按钮悄悄破坏收藏和路由参数的引用关系。页面只负责把预览和确认交给用户页面层可以保留一个State preview来显示数量与风险但它不应在onClick里自行计算并保存。一个安全的交互顺序是输入变化时重新预览点击“继续”时固定本次预览选择策略后展示确认文案确认按钮调用 Service成功后关闭弹窗并让页面按LastDeckUpdateAt重读。粘贴文本 → parseDeckImport → buildImportPreview → 用户选择 create / append / replace → 明确确认 → DeckService.save → Repository → LastDeckUpdateAt → 列表重新读取特别是不要把完整题库对象塞进AppStorage来“传递导入结果”。这里的AppStorage只承担刷新信号跨重启可信的数据仍在 Preferences/Repository 中页面需要时再从DeckService读取。验证时要故意制造反例以下检查覆盖的是当前源码事实与上文建议能力的衔接建议在实现后逐项完成输入空文本、没有#分隔、空题库名、少于最小答案数确认parseDeckImport返回可读错误且不出现写入。输入首尾空格、空项、重复答案和超长答案确认预览数量与解析后的有效答案一致。新建模式导入成功后退出并重新进入题库列表确认由DeckService.list读到新题库。目标题库在预览后被删除点击确认时应报“目标题库不存在”不能创建一个同名替代品。append与replace分别验证前者不减少旧答案后者的删除数与确认文案一致。如果以后接入收藏或分享验证保存后旧answerId的处理契约而不是只看页面能否显示。常见问题与定位顺序现象优先看哪里根因与修复提示导入成功却没新增题库DeckCreatePage是否只回填表单当前链路需用户继续点击创建文案应区分“已填入”和“已保存”同一答案出现两次比较前是否使用解析后的文本统一复用parseDeckImport的 trim/去重结果覆盖后收藏或深链失效是否通过save重建了答案 id先定义稳定身份和迁移规则别把覆盖能力提前上线列表未刷新刷新信号是否早于持久化只在DeckService.save成功后更新LastDeckUpdateAt预览与最终写入数量不同页面和 Service 各写了一份过滤逻辑把清洗与策略计算集中到同一服务排查时先打印“策略、目标题库是否存在、解析后数量、预计新增/删除数量”这些非敏感摘要不要记录用户完整问题、答案正文或整段导入文本。数量足以追踪状态链路也不会把用户内容带进日志。小结《答案之书》当前的导入能力是安全的“文本回填”它还不是备份恢复。要把它升级为恢复入口核心不是新增一个覆盖按钮而是把解析、差异、策略、提交分成四个不可混淆的阶段解析器保证输入可用预览服务解释变化用户确认策略DeckService最后保存并通知重读。这样做能让每一次删除、追加和新建都有来源也为以后处理稳定答案 id、收藏迁移和真正的备份文件版本打下边界。本文代码中的预览与策略层为设计示例是否已经在真机实现、恢复或发布仍需以实际工程验证为准。