工单台里的“当前选择”很容易被三个东西搅乱:搜索框缩小了可见列表,手机打开了独立详情,宽屏又把详情放回同一页面。假设已选工单 id=2,随后用户筛选“待处理”,列表里只剩 1 与 3。这时详情应该显示 2、跳到 1,还是显示“选中项不在筛选结果中”?如果不先明确这个问题,开发时往往会在筛选回调、路由回退和断点变化里分别修改 selectedId,最后出现列表高亮与详情标题对不上的现象。

前两篇已固定 WorkOrder { id, title, status, description },并通过 @StorageLink('workOrderSelectedId') 保持应用进程内的选择状态。这次不改模型和存储键;真正增加的是一套筛选的可见性规则、选择的业务合法性规则,以及窄屏详情导航的边界。它们共同决定用户看到什么,却不应被同一个布尔值混在一起。

响应式工单台筛选与详情一致性技术封面

把可见列表和当前工单分开想

筛选是一个视图条件,不等于删除数据。orders 表示完整工单集合,visibleOrders 是基于状态和关键词得到的视图,selectedId 仍是当前业务选择。业务上有两条说得通的产品策略:一种是筛选后自动选中第一条可见工单;另一种是保留此前选择,但在右侧提示“当前工单不在筛选范围内”。这里采用第一种,因为工单台的主从详情用于处理当前列表中的一条记录,用户不应该在一个看不见的筛选结果里操作隐藏工单。将来若产品允许“全局固定详情”,只需替换选择校正规则,而不是拆掉整个布局。

规则可以写得非常明确:一,输入关键词或切换状态后,只要原选择仍在可见集合中就保留;二,如果不在,就选择筛选结果中的第一项;三,如果筛选结果为空,selectedId=-1,详情显示可解释的空态;四,断点发生变化时不重新选择;五,真正删除工单时先修改完整集合,再根据当前条件校正。筛选条件变化与设备宽度变化是两种不同事件,不能因为都引发重绘就使用同一段重置逻辑。

筛选集合与选择标识之间的状态流转

还有一个看似琐碎的细节:筛选字段应当固定语义。这里 filterStatus 使用“全部、待处理、处理中”三个业务值,不使用 UI 组件在数组中的索引;keyword 先 trim() 再匹配标题或描述。搜索时把中文和英文的大小写规则分别处理,英文使用 toLowerCase(),中文保持原字即可。稳定的 id 继续作为 ForEach key,不能因为排序或筛选而改用当前位置。否则视图重新排列时,原先的高亮、焦点或后续需要的缓存都可能被错误复用。

从单一选择入口出发写页面

工程继续使用 entry/src/main/ets/pages/Index.ets,无需第三方包,也不需要网络权限。下列示例保留第一、二篇的数据和栅格断点;示例工单量很少,因此直接用 ForEach,下一阶段才会讨论大列表虚拟化。窄屏的详情使用 Navigation 和 NavPathStack 提供导航容器,宽屏仍用 GridRow/GridCol 展示主从区。为避免“路由参数”和“当前选择”成为两套竞争状态,打开路由前先统一写入 selectedId,详情从同一个 selectedId 读取,不在路由页面再维护另一份工单对象。

interface WorkOrder {
  id: number;
  title: string;
  status: string;
  description: string;
}

@Entry
@Component
struct Index {
  @StorageLink('workOrderSelectedId') selectedId: number = 1;
  @State private orders: WorkOrder[] = [
    { id: 1, title: '排查登录超时', status: '待处理',
      description: '核对登录请求链路与超时提示。' },
    { id: 2, title: '修复列表筛选', status: '处理中',
      description: '核对筛选条件及数据刷新范围。' },
    { id: 3, title: '梳理异常日志', status: '待处理',
      description: '为异常事件补充可检索的分类字段。' }
  ];
  @State private keyword: string = '';
  @State private filterStatus: string = '全部';
  @State private compact: boolean = true;
  @State private breakpointName: string = '由栅格自动判断';
  private pathStack: NavPathStack = new NavPathStack();

  aboutToAppear(): void {
    this.reconcileSelection();
  }

  private visibleOrders(): WorkOrder[] {
    const word = this.keyword.trim().toLowerCase();
    return this.orders.filter((item: WorkOrder): boolean => {
      const statusOK = this.filterStatus === '全部' ||
        item.status === this.filterStatus;
      const textOK = word.length === 0 ||
        item.title.toLowerCase().includes(word) ||
        item.description.toLowerCase().includes(word);
      return statusOK && textOK;
    });
  }

  private currentOrder(): WorkOrder | undefined {
    return this.visibleOrders().find((item: WorkOrder): boolean =>
      item.id === this.selectedId);
  }

  private reconcileSelection(): void {
    const visible = this.visibleOrders();
    if (!visible.some((item: WorkOrder): boolean =>
      item.id === this.selectedId)) {
      this.selectedId = visible.length > 0 ? visible[0].id : -1;
    }
    // 已打开的窄屏详情若失去选中对象,关闭这一层路由。
    if (this.selectedId === -1) {
      this.pathStack.clear();
    }
  }

  private choose(item: WorkOrder): void {
    this.selectedId = item.id;
    if (this.compact) {
      this.pathStack.pushPath({ name: 'WorkOrderDetail' });
    }
  }

  private removeSelected(): void {
    const targetId: number = this.selectedId;
    this.orders = this.orders.filter((item: WorkOrder): boolean =>
      item.id !== targetId);
    this.reconcileSelection();
  }

  @Builder
  private orderList() {
    Column({ space: 10 }) {
      Text('工单列表').fontSize(22).fontWeight(FontWeight.Bold)
      TextInput({ placeholder: '搜索标题或描述', text: this.keyword })
        .onChange((value: string) => {
          this.keyword = value;
          this.reconcileSelection();
        })
      Row({ space: 8 }) {
        ForEach(['全部', '待处理', '处理中'], (status: string) => {
          Button(status)
            .backgroundColor(this.filterStatus === status ? '#2563EB' : '#94A3B8')
            .onClick(() => {
              this.filterStatus = status;
              this.reconcileSelection();
            })
        }, (status: string) => status)
      }
      if (this.visibleOrders().length === 0) {
        Text('此条件下没有工单,可以清空搜索词或切换状态')
          .fontColor('#64748B')
      } else {
        List({ space: 8 }) {
          ForEach(this.visibleOrders(), (item: WorkOrder) => {
            ListItem() {
              Column({ space: 5 }) {
                Text(item.title).fontSize(16)
                Text(item.status).fontColor('#64748B')
              }
              .width('100%').padding(12)
              .backgroundColor(item.id === this.selectedId ? '#DBEAFE' : '#F1F5F9')
              .borderRadius(10)
              .onClick(() => { this.choose(item); })
            }
          }, (item: WorkOrder) => item.id.toString())
        }
        .height(288)
      }
    }
    .alignItems(HorizontalAlign.Start)
    .width('100%').padding(14)
    .backgroundColor(Color.White).borderRadius(14)
  }

  @Builder
  private orderDetail() {
    Column({ space: 12 }) {
      Text('工单详情').fontSize(22).fontWeight(FontWeight.Bold)
      if (this.currentOrder()) {
        Text(this.currentOrder()!.title).fontSize(20)
        Text(this.currentOrder()!.status).fontColor('#2563EB')
        Text(this.currentOrder()!.description)
        Button('移除当前工单').onClick(() => { this.removeSelected(); })
      } else {
        Text('当前筛选条件下没有可查看的工单')
          .fontColor('#B45309')
      }
    }
    .width('100%').height(360).padding(20)
    .alignItems(HorizontalAlign.Start)
    .backgroundColor(Color.White).borderRadius(14)
  }

  @Builder
  private destination(name: string) {
    if (name === 'WorkOrderDetail') {
      NavDestination() {
        this.orderDetail()
      }
      .title('当前工单')
    }
  }

  build() {
    Navigation(this.pathStack) {
      Scroll() {
        Column({ space: 12 }) {
          Text('团队工单台').fontSize(26).fontWeight(FontWeight.Bold)
          Text('布局:' + this.breakpointName).fontSize(12)
          GridRow({
            columns: { sm: 4, md: 8, lg: 12 },
            gutter: { x: 12, y: 12 }
          }) {
            GridCol({ span: { sm: 4, md: 3, lg: 4 } }) {
              this.orderList()
            }
            if (!this.compact) {
              GridCol({ span: { sm: 4, md: 5, lg: 8 } }) {
                this.orderDetail()
              }
            }
          }
          .onBreakpointChange((value: string) => {
            this.breakpointName = value;
            this.compact = value === 'sm';
          })
          .width('100%')
        }
        .padding(16).width('100%')
      }
      .height('100%')
    }
    .navDestination(this.destination)
    .mode(NavigationMode.Stack)
    .width('100%').height('100%')
  }
}

几个边界要讲清楚。visibleOrders() 是基于状态得出的纯计算结果,不修改原数组;reconcileSelection() 只能在数据集或筛选条件真正变化时调用,不能塞进 build() 或断点回调里反复修改状态。NavPathStack 不存业务数据,窄屏点击 choose() 时先选中,再打开详情;详情直接调用 currentOrder()。因此不论从列表点击还是从宽屏直接查看,同一条工单都不会因为进入另一个容器就变成另一份数据。

列表筛选、共享选择与详情导航的代码关系图

别让导航返回误以为是“重选工单”

Navigation 管理的是视图路径,不是工单所有权。手机里从列表进入详情,返回通常意味着退出详情视图;这一步不应该把 selectedId 清零。宽屏主从同时可见时也没必要每点击一次工单就往导航栈追加一页。示例的 compact 来源于 GridRow 断点回调,因此窄屏走 pushPath,宽屏只更新 selectedId。本例采用保守初始值 compact=true,首次渲染先给窄屏单列的安全布局,再由 onBreakpointChange 调整为宽屏主从。正式接入仍需验收首次断点回调是否按照预期触发;如果要求首次布局就精准匹配容器尺寸,可以配合窗口尺寸能力建立初始布局策略,不要把所有设备硬判为手机。

另一个较难碰到的边界是“详情打开期间条件改变”。本示例的搜索操作位于列表主页面,通常详情已经覆盖列表,不会在同一时刻修改条件;如果未来增加全局筛选入口或外部推送刷新,就要在状态协调层显式决定是否关闭路由、提示当前工单离开结果集,还是自动切换到新的可见项。示例只在无结果时 clear(),对筛选后切换到另一条可见记录会让当前详情显示新选择。这是一种明确的“跟随当前可见集合”策略,不是路由回调的默认承诺。

路由重建与进程重启同样不能混淆。@StorageLink 链接的是进程内应用状态;AppStorage 在页面重建时能保持选择键,但并不等价于持久化数据库或账号级同步。路由参数若需要用于分享外部链接或跨进程恢复,应携带业务 id,并重新查询合法性、权限和当前筛选条件,不能仅将对象实例塞进参数后长期依赖它。本文选择在页面内统一状态,而不是凭空承诺任意场景下的深链接恢复。

过滤前后与窄宽布局的选中态对照

按事件顺序回归,比只看截图可靠

建议先固定宽窗口:选择 id=2,把状态切到“待处理”,预期转为 id=1,详情展示登录超时;清空状态筛选后,应保留 id=1,不应自动跳回 2。再输入一个不存在的关键词,预期是空列表和 -1 空态;清除关键词后应恢复第一条可见工单。随后仅改变窗口宽度,重复观察 selectedId,确认断点回调没有隐式重选。最后在窄窗口点击列表并通过系统返回,检查仍保留同一选中项;删除它后检查回退是否符合规则。

排查顺序也应固定:先打印当前 keyword/filterStatus 和可见 id 集合,再看 selectedId,最后才看路由栈。如果可见集合错误,问题通常在筛选函数;如果集合正确但选中错误,检查谁调用了 reconcileSelection();如果二者都对但详情不一致,才去找是否存在额外状态副本、路由组件的重建位置或断点初值差异。不要一上来就把页面改成两个完全独立的手机、平板实现,那往往是把同一个问题复制两份。

从可维护性的角度,筛选逻辑最好保留为无副作用的纯函数,便于后续将数据源换成数据库、远程分页或从 ForEach 升级 LazyForEach。此时必须重新定义“可见集合”的范围——只在本地已加载的十条记录里查找与在服务端全量工单里查找,二者不应混为一谈。尤其是翻页后选中项不在当前页时,不能把“没加载到”简单当成“已删除”,这是下一阶段大列表设计绕不开的边界。

这一阶段得到的不是更多按钮,而是一条可审查的数据流:完整数据 → 筛选可见集合 → 选择合法性校验 → 主从详情或导航视图。屏幕形态只决定呈现方式,不决定工单身份。下一篇继续沿用这套 WorkOrder 和选择键,把大数据量列表的 LazyForEach 数据源、滚动位置及筛选后的增量更新放在同一张设计图里讨论。

参考资料:华为开发者:Navigation 跨包路由与 NavPathStack 使用、GridCol API、ArkUI 状态观察 @Watch。

Logo

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

更多推荐