在建筑设施维修工作台里,搜索框通常贴着工单列表,详情面板却不一定跟着列表消失。上一节解决了平板双栏与窄屏单栏之间的宽度切换:WO-1047电梯异响始终是同一张业务工单。现在增加关键词和状态筛选后,新的问题是:左栏看起来只有一张符合条件的工单,右栏可能还停留在上次打开的WO-1048。再遇到网络较慢的搜索请求,旧结果晚于新结果返回,错位会持续到下一次选择。

工作台继续使用WO-1047电梯异响、WO-1048 B栋二层照明异常、WO-1046消防设施巡查。WO-1047位置为A栋一层电梯厅,当前状态为处理中。典型操作是输入“电梯”,再选择“处理中”,列表只剩WO-1047,右栏立即按WO-1047的身份展示详情。另一种操作是先打开WO-1048,再输入“电梯”:列表不再包含WO-1048,但用户没有发出“打开另一张工单”的命令,这时不应该偷偷替换选中ID,而应清楚提示当前详情不在筛选结果中。

工单搜索筛选与详情一致性的工程封面

搜索条件与选中对象必须分成两种状态

我将搜索关键字、状态筛选和选中工单分别存放。查询条件是一种视图投影:它决定左栏此刻展示哪些候选项;选中工单则是独立的业务身份,用于详情请求、处理记录和持久化恢复。不能通过“筛选后的第零行”去推导详情,因为状态更新和排序变化会改变行的位置。对WO-1047而言,列表从第三行移动到第一行,它仍然是同一条工单,详情的业务主键没有变化。

搜索匹配范围限定为工单标题、工单号和位置,避免把备注里偶然包含的字词也混进检索结果。输入前后空白应该被清理;纯空白查询等价于全部关键词;状态选择“全部”不额外施加条件。为了对常见的全角英数字输入保持一致,查询可先做Unicode NFKC规整,再做大小写归一。这个规则属于本工作台的业务选择,不需要依赖额外系统搜索接口。匹配时应保留原始数据的引用,不在查询过程中修改工单的状态字段。

下面的Node.js业务函数只做确定性筛选。三条数据包含当前工程的真实字段约定,既能检验关键词与状态联合条件,也能检验空搜索词。测试通过后,页面可以直接调用同样的数据投影思路,不必把输入控件与业务规则耦合在一起。

const assert = require('node:assert/strict');
const orders = [
  {id:'WO-1047', title:'电梯异响', location:'A栋一层电梯厅', status:'处理中'},
  {id:'WO-1048', title:'照明异常', location:'B栋二层公共区', status:'待处理'},
  {id:'WO-1046', title:'消防设施巡查', location:'A栋地下一层', status:'已完成'}
];
function normalize(value) {
  return String(value ?? '').normalize('NFKC').trim().toLocaleLowerCase('zh-CN');
}
function selectVisible(rows, keyword, status='全部') {
  const query = normalize(keyword);
  return rows.filter(row => {
    const text = normalize([row.id, row.title, row.location].join(' '));
    return (!query || text.includes(query)) &&
      (status === '全部' || row.status === status);
  });
}
assert.deepEqual(selectVisible(orders, ' 电梯 ', '处理中').map(x=>x.id), ['WO-1047']);
assert.equal(selectVisible(orders, '   ').length, 3);
assert.equal(orders[0].status, '处理中');

当前详情不在结果里,也不能改变业务归属

产品设计里需要区分“被选中”和“在当前筛选结果中可见”。用户已经打开WO-1048,随后搜索“电梯”时,右栏可以继续显示WO-1048,但列表顶部应出现“当前详情不在筛选结果中”的轻提示,并提供“清除筛选”或“返回列表”的明确动作。若团队希望在筛选后自动清空详情,也要通过专门的交互规则来实现,不能让组件卸载时偶然清理选中值。我们选择保留选中对象,原因是维修人员经常对照另一张工单搜索相似问题。

仅保存selectedId还不够,详情对象自身需要由工单ID索引,不能保存一个随列表索引偏移而改变的引用。工单关闭、权限变动、数据删除时,详情读取会返回不同的业务结果,此时要展示不可访问或已关闭状态,并阻止继续修改,而非拿另一条列表数据填充面板。对筛选操作来说,数据源仍可以保留完整的工单集合,visibleOrders只是派生数组,详情读取继续使用原业务ID。

下面的投影函数会输出左栏结果、当前详情身份和不可见提示状态。在测试中,筛选消除了WO-1048的列表入口,但详情仍指向WO-1048;用户明确点击WO-1047之后,两边才重新对齐。这个模型也延续了第三篇的选中态持久化设计和第四篇的布局投影方式。

const assert = require('node:assert/strict');
const rows = [
  {id:'WO-1047',title:'电梯异响',status:'处理中'},
  {id:'WO-1048',title:'照明异常',status:'待处理'}
];
function project(rows, selectedId, keyword) {
  const visible = rows.filter(row => row.title.includes(keyword));
  const selected = rows.find(row => row.id === selectedId) ?? null;
  return { visible, selected,
    hiddenSelection: Boolean(selected && !visible.some(row => row.id === selectedId)) };
}
const first = project(rows, 'WO-1048', '电梯');
assert.equal(first.visible[0].id, 'WO-1047');
assert.equal(first.selected.id, 'WO-1048');
assert.equal(first.hiddenSelection, true);
const second = project(rows, 'WO-1047', '电梯');
assert.equal(second.hiddenSelection, false);

开发环境中的筛选投影与详情身份核对

搜索请求、详情请求和路由切换各自维护代际

当搜索接入远端数据源后,用户很可能快速输入“电”“电梯”,或者连续从“全部”切换到“处理中”。假设每一次输入都触发请求,第一条请求的返回时间可能比第二条晚。把每一次返回都写进visibleOrders,会导致搜索结果在用户停止输入后仍跳回旧条件。这里应为搜索会话维护queryRevision,只有返回时的代际与当前查询条件相符,才允许提交可见列表。

详情请求沿用第二篇的独立detailRevision,不能直接复用queryRevision。搜索条件发生变化,并不意味着用户已切换工单;反过来,用户主动点击WO-1047,必须提升详情代际并抛弃旧详情结果。两个数字的生命周期不同,混在一起会让用户打字时反复取消本来有效的详情加载。业务层的AbortController可用于取消自身持有的异步工作,但最终仍需要结果归属检查,防止取消后已排队的回调继续更新页面。

还有一种常见时序:列表搜索返回三张工单,用户在它们之间切换;此时上一条详情回包才到达。即使那条回包的数据字段完全合法,也不能提交给新的selectedId。写入前要同时比较请求代际和目标工单号。清空搜索条件后列表恢复全量,selectedId保持不变,而不是把第一条结果自动设置为当前详情。

下方控制器抽象了搜索提交过程。它接收页面外部提供的异步检索函数,旧检索即便较晚完成也无法写回结果。退出页面后调用dispose,当前代际立即失效。这里的测试刻意让后启动的查询先完成,以覆盖最容易漏掉的过期返回路径。

const assert = require('node:assert/strict');
class SearchSession {
  constructor(fetcher) { this.fetcher=fetcher; this.epoch=0; this.closed=false; this.items=[]; }
  async search(query) {
    if (this.closed) return false;
    const token=++this.epoch;
    const result=await this.fetcher(query);
    if (this.closed || token!==this.epoch) return false;
    this.items=result;
    return true;
  }
  dispose() { this.closed=true; this.epoch++; this.items=[]; }
}
(async () => {
  const pending=new Map();
  const session=new SearchSession(q=>new Promise(resolve=>pending.set(q,resolve)));
  const old=session.search('电');
  const current=session.search('电梯');
  pending.get('电梯')([{id:'WO-1047'}]);
  assert.equal(await current,true);
  pending.get('电')([{id:'WO-1048'}]);
  assert.equal(await old,false);
  assert.equal(session.items[0].id,'WO-1047');
  session.dispose();
  assert.equal(session.items.length,0);
})().catch(error=>{console.error(error);process.exitCode=1;});

界面事件与资源归属的落点

在ArkUI页面中,Search或输入组件只负责收集文字,状态筛选控件只负责记录当前选项。数据查询交给页面状态模型执行,列表用稳定工单ID生成子项键,双栏中的详情面板使用selectedId查找;窄屏则把同一selectedId传入详情路由。不要让筛选后生成的新数组成为另一份可以独立修改的业务仓储。这样搜索输入框改变时,左栏与右栏虽然可能显示不同的数据集合,却不会出现工单号和详情字段交叉污染。

华为开发者的Navigation分栏资料说明了单栏、分栏和自适应布局的配置,也介绍了NavPathStack管理子页面的基本方式;List资料说明ForEach、LazyForEach、Repeat的列表项创建差异。这里引用的平台能力限于导航与列表展示,queryRevision、detailRevision及工单可见性都是应用业务层实现。把它们分开之后,即使系统调整显示形态,也不需要改变后台工单查询协议。

排查时我优先记录查询关键字摘要、状态条件、queryRevision、selectedId、detailRevision和响应归属,而不是把所有完整工单描述都写进输出。发现详情与列表不一致时,先判断是否属于合法的“当前选择被筛除”,再核对详情是否来自旧代际;最后检查在恢复布局、恢复页面时有没有重新按列表下标选工单。逐项排查通常比盲目清空所有缓存更有价值。

页面离开后,输入事件订阅、搜索延迟计时器、进行中的检索和详情监听应解除;持久选中ID与可追溯的业务工单历史不能因为页面暂时关闭而消失。清理动作要求幂等,重复调用不会引入新的订阅。下一篇跨栏编辑会继续依赖同一个业务ID,因此这一篇最重要的收获不是搜索框的外观,而是筛选结果与业务身份能够独立演进。

手机工单搜索结果与当前详情界面

参考资料

Logo

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

更多推荐