HarmonyOS应用实战-启示散页-53-导入备份别直接覆盖:先做差异预览和冲突选择
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.answers = importedAnswers.map((text: string) => ({ text }));
// 也不要让页面自己猜测是否可覆盖
await DeckRepository.saveDeck(rawDeck);
第一段会让用户失去比较机会;第二段绕过了 DeckService.save 的名称、数量和刷新信号约束。页面适合收集选择,Service 才应决定最终写什么。
预览模型只描述差异,不制造持久化副作用
对于当前项目的文本格式,答案没有跨导入稳定 id,因此第一版只能以“清洗后的文本”识别重复。它足够解释新增与重复,但不能可靠地判断“这条是改名后的旧答案”。这种限制应显式展示给用户。
interface ImportPreview {
targetDeckId?: string;
importedName: string;
additions: string[];
duplicates: string[];
skipped: string[];
canCommit: boolean;
notices: string[];
}
type ImportStrategy = 'create' | 'append' | 'replace';
这里没有 Deck、AppStorage 或 Repository 引用。预览阶段是纯计算:同一份文本无论点几次,都只能得到同一份结果;只有用户选定策略后,才进入写入路径。replace 需要额外确认,因为它会移除目标中未出现的答案。
用现有解析器作为第一道门,而不是另写一套规则
建议能力应复用当前 parseDeckImport,否则“弹窗能导入、恢复入口却拒绝”的规则分叉迟早会发生。以下是可放进 HSP 服务层的示例;其中 DeckService.get 是项目已有读入口。
import { DeckService } from '../services/DeckService';
import { parseDeckImport } from '../utils/DeckImport';
async function buildImportPreview(raw: string, targetDeckId?: string): Promise<ImportPreview> {
const parsed = parseDeckImport(raw);
if (!parsed.ok || !parsed.deck) {
return {
importedName: '', additions: [], duplicates: [], skipped: [],
canCommit: false, notices: [parsed.error ?? '导入文本无效']
};
}
const target = targetDeckId ? await DeckService.get(targetDeckId) : null;
const existing: Set<string> = new Set<string>(target?.answers.map((a) => a.text) ?? []);
const additions = parsed.deck.answers.filter((text) => !existing.has(text));
const duplicates = parsed.deck.answers.filter((text) => existing.has(text));
return {
targetDeckId, importedName: parsed.deck.name, additions, duplicates, skipped: [],
canCommit: additions.length > 0 || !target,
notices: target ? ['按文本比较;改写后的答案会被视为新增'] : []
};
}
这段代码的关键是读取目标题库后才建立 existing 集合。它信任解析器已完成的格式和长度校验,却不信任页面传来的 targetDeckId:拿不到目标题库时不能悄悄降级为覆盖,应停止提交并提示用户重新选择。
三种策略的边界必须写在提交前
差异预览不是多显示一行“发现 3 条变化”,而是让用户在提交前看到操作含义。
| 策略 | 写入结果 | 适合场景 | 风险控制 |
|---|---|---|---|
create |
调用 DeckService.save 新建题库 |
从剪贴板导入一套新问题 | 原题库完全不动 |
append |
保留原答案,只追加 additions |
补充题目 | 重复项不再次写入 |
replace |
以导入后的集合为准 | 用户明确恢复某一份快照 | 二次确认并展示删除数量 |
提交时不要直接调用 DeckRepository。项目中的 DeckService.save 会清洗答案、校验最小/最大数量、生成答案 id、写入 Repository,并在成功后更新 AppStorageKey.LastDeckUpdateAt。这条顺序让列表页等订阅者在“最终事实已经落库”后才重新读取。
async function commitPreview(preview: ImportPreview, strategy: ImportStrategy): Promise<void> {
if (!preview.canCommit) {
throw new Error(preview.notices.join(';'));
}
if (strategy === 'create') {
await DeckService.save({ name: preview.importedName, answers: preview.additions });
return;
}
const target = preview.targetDeckId ? await DeckService.get(preview.targetDeckId) : null;
if (!target) {
throw new Error('目标题库不存在,停止导入');
}
const answers = strategy === 'append'
? [...target.answers.map((a) => a.text), ...preview.additions]
: [...preview.duplicates, ...preview.additions];
await DeckService.save({ id: target.id, name: target.name, answers, colorKey: target.colorKey });
}
示例故意把 replace 的结果写得直白:它只保留本次导入解析到的答案。真实 UI 还应在按钮上标出“将删除 N 条未出现在导入文本中的答案”,并让用户再次确认;不要用一个模糊的“导入成功”掩盖数据删除。
一个容易漏掉的约束:保存会重建答案 id
当前 DeckService.save 接收 string[],然后为每条答案调用 newId('a') 创建新的 Answer。这意味着用它更新已有题库时,答案的 id 会整体变化。对于当前仅按答案文本收藏的项目,这不一定立即出错;但若后续收藏、分享或统计依赖 answerId,append/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、收藏迁移和真正的备份文件版本打下边界。本文代码中的预览与策略层为设计示例;是否已经在真机实现、恢复或发布,仍需以实际工程验证为准。
更多推荐


所有评论(0)