项目工作台最初只是左侧列表和右侧详情的分栏页面,后来逐步加入空态占位、筛选身份保持、可拖动宽度、窗口恢复、详情草稿、多标签以及外部链接复用。单个窗口里的这些规则已经比较清晰:活动项目由稳定 ID 决定,输入草稿归属于项目,异步回执要与请求代际匹配。但在大屏工作环境中,用户可能同时打开两个工作窗口,分别修改“质量复核”的不同字段。两个窗口看到的都是同一项目,保存动作却可能在很短时间内先后抵达。

若仍然使用“最后保存覆盖之前内容”的策略,后一个窗口会把前一个窗口刚提交的修改擦掉。更糟糕的是,关闭其中一个窗口时如果清理的是按项目 ID 保存的全局草稿,另一个窗口正在编辑的内容也会一起消失。多窗口适配的收口不在于增加几个界面断点,而是确定草稿拥有者、服务器修订号、字段冲突与退出清理之间的职责边界。

多窗口草稿冲突与退出状态收口

项目身份相同,不等于草稿属于同一窗口

沿用前面工作台的 openIds、activeId 和 drafts 思路,业务实体仍由 tenantId + projectId 唯一标识;窗口内的编辑会话则再添加 windowId。projectId 负责访问控制、路由复用和详情数据定位,windowId 负责分配输入草稿、关闭提示和临时订阅。把所有草稿都写到 drafts[projectId],在单窗口看似方便,到了双窗口却失去了判断“是谁修改了文本”的依据。

每份草稿记录三个版本相关字段:baseRevision 是打开编辑器时读取的权威修订号;baseFields 是当时的字段快照;fields 是当前窗口修改后的内容。这样就能区分“我改了什么”与“其他窗口改了什么”。两个窗口都从修订号 12 开始,不意味着它们最终都可以写成修订号 13;业务仓库必须在提交时重新比较当前修订,并以事务性的条件更新保证只有一个提交成功。

页面结构继续由 Navigation 和 NavDestination 承担,选中的项目不会因为窗口数量增加而改变意义。多窗口是否使用独立 UIContext,取决于应用窗口的创建方式;不能为了共享草稿在两个窗口之间传递某个页面组件实例。领域仓库存稳定项目身份和修订数据,窗口组件只持有本窗口的输入状态,属于更容易隔离和释放的结构。

用字段三方比较取代覆盖写入

冲突解决的核心是基线、当前权威值、窗口草稿三方比较。对于某个字段,如果草稿与基线相同,说明用户没有改它,应该接受权威新值;如果权威值与基线相同,说明其他窗口没有动它,可以保留本窗口改动;如果双方都修改了同一字段而结果不同,就不能自动决定谁胜出。对于已经产生冲突的草稿,界面应当展示双方文本并让用户选择,而不是静默覆盖。

示例项目的复核详情具有 owner、note、status 三个字段。下面使用 Node.js 可直接运行的 JavaScript 表达领域层逻辑,实际 ArkTS 页面可以沿用相同输入输出结构。函数没有全局可变对象,比较结果包含字段名,方便在详情页做逐字段提示。

const KEYS = ['owner', 'note', 'status'];
function openDraft(tenantId, projectId, windowId, snapshot) {
  return { tenantId, projectId, windowId,
    baseRevision: snapshot.revision, baseFields: { ...snapshot.fields },
    fields: { ...snapshot.fields }, dirty: false, closed: false };
}
function editField(draft, field, value) {
  if (draft.closed || !KEYS.includes(field) || typeof value !== 'string') {
    return draft;
  }
  return { ...draft, fields: { ...draft.fields, [field]: value }, dirty: true };
}
function threeWayMerge(base, local, current) {
  const merged = { ...current }, conflicts = [];
  for (const field of KEYS) {
    const mineChanged = local[field] !== base[field];
    const remoteChanged = current[field] !== base[field];
    if (mineChanged && remoteChanged && local[field] !== current[field]) {
      conflicts.push(field);
    } else if (mineChanged) merged[field] = local[field];
  }
  return { merged, conflicts };
}

三方比较看似简单,落地时却必须明确字段的业务语义。例如状态字段从“处理中”到“已通过”可能受流程权限约束,不能因为文本三方比较无冲突就自动合并。对于富文本、成员集合和附件列表,按整段字符串判断通常过于粗糙,最好使用专门的补丁格式或者由服务端合并操作。对不可合并的字段,保留草稿并提示人工选择,比误覆盖更可靠。

提交时重新校验修订号与账号作用域

领域层的保存入口要带上 tenantId、projectId 与草稿的 baseRevision。服务端或持久仓库先验证账号访问权限,再对项目修订号执行比较更新。当前修订与草稿基线一致时可以提交;若已经变化,则先计算候选合并内容,只有没有字段冲突时才可尝试以最新修订再次提交。再次提交之前,权威修订可能又被其他窗口推进,所以仓库仍需做条件更新,而不能把三方比较的结果当成独占锁。

下面的函数把仓库表示为普通对象,用于说明条件提交和冲突返回。对象中带项目身份,避免同名项目或切换租户后错误命中。CONFLICT 返回原草稿,不会强行覆盖输入内容;SAVED 则用新的权威快照回填该窗口的基线,另一个窗口要收到变更通知后自行重算可提交内容。

function trySave(repository, draft) {
  if (draft.closed || repository.tenantId !== draft.tenantId ||
      repository.projectId !== draft.projectId) {
    return { status: 'REJECTED', repository, draft };
  }
  const result = threeWayMerge(draft.baseFields, draft.fields, repository.fields);
  if (result.conflicts.length > 0) {
    return { status: 'CONFLICT', fields: result.conflicts, repository, draft };
  }
  const changed = KEYS.some(k => result.merged[k] !== repository.fields[k]);
  const nextRepo = changed ? { ...repository, revision: repository.revision + 1,
    fields: result.merged } : repository;
  return { status: 'SAVED', repository: nextRepo,
    draft: openDraft(draft.tenantId, draft.projectId, draft.windowId, nextRepo) };
}
function closeEditor(draft, decision) {
  if (draft.dirty && decision !== 'discard' && decision !== 'keep') {
    return { status: 'ASK', draft };
  }
  if (decision === 'keep') return { status: 'PRESERVED', draft };
  return { status: 'CLOSED', draft: { ...draft, closed: true } };
}

真实工程中,trySave 的修订检查与写入要由数据库或业务服务提供原子操作。示例函数面对的是一份已获得的仓库快照,不承担跨进程锁定工作。如果按这种分层把仓库写入封装为 saveIfRevisionMatches,就能在条件失败时重新读快照、再次做字段比较;重试次数也应该有限,并在持续冲突时给用户明确反馈。

开发环境中的详情草稿与冲突提示

两个窗口交错保存时如何核查

测试的重点不在连续点击“保存”是否成功,而在两份旧基线并存时会出现什么。构造两个编辑窗口,都读取修订号 12。窗口甲更改负责人,窗口乙更改备注;甲提交后,乙在自己的旧基线上保存,三方比较应保留甲的新负责人,同时写入乙的新备注。再构造两个窗口同时修改备注为不同内容的情况,第二次提交应该返回冲突而不是静默覆盖。

代码块按顺序保存为一个 .cjs 脚本即可运行;断言的字段值与 UI 文案可以保持对应。关闭窗口的检查也放在同一组数据里,确保草稿弹窗只作用于当前编辑会话。

const assert = require('node:assert/strict');
const start = { tenantId: 'team', projectId: 'review', revision: 12,
  fields: { owner: '甲', note: '待复核', status: '处理中' } };
let left = editField(openDraft('team', 'review', 'window-left', start), 'owner', '乙');
let right = editField(openDraft('team', 'review', 'window-right', start), 'note', '补充照片');
const first = trySave(start, left);
assert.equal(first.status, 'SAVED');
const second = trySave(first.repository, right);
assert.equal(second.status, 'SAVED');
assert.deepEqual(second.repository.fields,
  { owner: '乙', note: '补充照片', status: '处理中' });
assert.equal(second.repository.revision, 14);
left = editField(openDraft('team', 'review', 'window-left', start), 'note', '意见甲');
right = editField(openDraft('team', 'review', 'window-right', start), 'note', '意见乙');
const ahead = trySave(start, left);
const collision = trySave(ahead.repository, right);
assert.equal(collision.status, 'CONFLICT');
assert.deepEqual(collision.fields, ['note']);
assert.equal(closeEditor(right, '').status, 'ASK');
assert.equal(closeEditor(right, 'keep').status, 'PRESERVED');
assert.equal(closeEditor(right, 'discard').status, 'CLOSED');
console.log('multi-window draft checks passed');

此外要覆盖字段空字符串、权限突然撤销、项目被删除、链接进入另一租户、相同窗口 ID 重建、两次保存响应逆序以及冲突解决弹窗尚未关闭时窗口退出。closeEditor 只表达页面退出决策,不负责清除整个项目的共享缓存;已保存的权威记录与另一个窗口仍使用的订阅应继续存在。窗口关闭后,属于该窗口的监听器、计时器和临时请求句柄需要解除,未保存草稿由用户选择保留或放弃。保留草稿时应写到带窗口会话标识的持久区,并设置合理的恢复期限。

Navigation 路径和编辑会话分开收口

多窗口的每个视图拥有自己的活动详情、导航栈和草稿输入区,共享仓库则持有项目的权威修订。外部链接进入工作台时,仍沿用上一章的来源校验与目标标签复用逻辑;区别在于目标若已在另一个窗口打开,可以选择聚焦现有视图,也可以建立新的窗口会话,但不能直接复用另一个视图中的可变草稿对象。NavPathStack 管理路径跳转,不能承担跨窗口的版本仲裁。

冲突提示建议放在当前详情页内:顶部显示“项目已在另一窗口更新”,字段级列表对比“当前输入”和“最新版本”,底部给出“查看差异”“保留草稿”“重新载入”等操作。对于无冲突字段合并,可显示保存后的修订号;对于待人工处理的字段,不要自动关闭编辑页。小屏单栏时这些提示仍围绕业务 ID 和字段数据工作,只调整容器排版,避免因窗口宽度变化重新初始化草稿。

多窗口详情草稿冲突处理界面

退出路径需要一个稳定收尾顺序:先检查本窗口 dirty 标记,再让用户决定保存、暂存或放弃;确定退出后解绑本窗口订阅和异步回调;最后才移除窗口的导航路径。若保存动作仍在进行,应让页面保持“提交处理中”,或使用明确的中止策略等待仓库给出最终答复,而不是把窗口关闭当作保存成功。账号切换是另一类更强的边界,此时应撤销旧账号访问资格,并由仓库清理跨租户的数据视图,不能继续从过期草稿恢复敏感内容。

HarmonyOS 的 Navigation 分栏开发 介绍分栏模式与宽度控制,组件导航和页面路由概述 解释 Navigation 和 NavDestination 的页面职责;使用 UI 上下文接口操作界面 对多实例界面的独立上下文有更具体的讨论。把导航层、窗口草稿和业务仓库分开,最终解决的是相同项目在不同窗口里可以被安全编辑,而不是让两个页面看上去恰好显示同一份文本。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐