港口箱位核对的价值不止于把一条记录从“待核对”改成“已确认”。当一个班组交接给另一个班组,接手人员往往需要回答更具体的问题:箱体043是什么时候获得明确回执的,051为何还在待确认清单,063的修订冲突发生在提交前还是查询回执时。上一阶段已经为每条命令保留稳定箱位ID、操作键和最终或暂定回执,但列表只展现当前状态,还无法解释这些状态形成的过程。

例如,south-02-04对应箱体043,修订版3提交后获得APPLIED回执,确认修订版为4;south-03-01对应箱体051,命令已发出但连接中断,回执仍为UNKNOWN;north-01-08对应箱体063,修订版5产生CONFLICT,不能随着其他两条的处理而被隐去。新增的历史页必须让三条记录分别保留其时间顺序、操作意图与原因,而不是从最终状态倒推出并不存在的中间过程。

港口批量核对回执与异常时间线

历史事件和箱位现状采用两套模型

如果只把箱位表上的status字段写进历史,每次刷新都会丢失原有状态。相反,若把整份箱位对象复制到每条历史事件里,重量、箱型以及其他经常变化的业务字段又会重复存储。更合适的是维护不可变的审阅事件:事件描述发生了什么,箱位现状由最新有效业务记录给出。两者通过稳定箱位ID关联,查询时仍能区分事件发生时的修订版和当前修订版。

工程沿用model/BatchReceipt.ets与service/ReceiptReconciler.ets,增加model/ReviewHistoryEvent.ets、service/HistoryQueryService.ets和pages/ReviewHistoryPage.ets。历史事件由仓储业务接口返回,客户端负责验序、过滤和展示;本地保存的是查询条件与当前选中的事件ID,不将本地缓存当作仓储状态的最终来源。选择事件时只改变详情焦点,不改变箱位核对结果。

export type HistoryKind = 'COMMAND_CREATED' | 'RECEIPT_APPLIED' |
  'RECEIPT_UNKNOWN' | 'REVISION_CONFLICT' | 'RECEIPT_RECHECKED';

export interface ReviewHistoryEvent {
  eventId: string;
  slotId: string;
  containerNo: string;
  commandKey: string;
  sequence: number;
  kind: HistoryKind;
  expectedRevision: number;
  actualRevision?: number;
  occurredAt: number;
  reasonCode?: string;
}

export interface HistoryRepository {
  queryEvents(batchKey: string): Promise<ReviewHistoryEvent[]>;
}

eventId用于列表复用和重复事件识别,sequence用于同一批次的先后顺序,occurredAt负责给人员显示时间。这三个字段不能互相替代。服务端可能因延时使事件到达顺序与生成顺序不同,也可能有两条操作发生在同一毫秒。前端排序依照业务序号,再以事件ID作为稳定的第二排序条件;时间仅用于说明,不承担排序唯一性的责任。

按原因分类,而不是按颜色猜测

UNKNOWN表示尚未得到确切处理结果,并不等于提交失败;CONFLICT表示当前记录版本与提交条件不一致,也不自动意味着业务员填写错误。若把两者放进同一个“失败”筛选,值班人员很可能直接重发051命令,进而产生重复确认意图。历史页把“待核实回执”“修订冲突”“身份不一致”“已获得确认”定义为独立归因,业务判断与界面颜色分离。

归因函数可以在数据模型层保持纯计算,不依赖页面的生命周期。这样报表列表、详情页和交接页可以共享一套规则,不会因为显示组件不同而给出相反的异常说明。未知的原因码应保留原值并归入“其他待查”,不应被默认为成功。

export type HistoryCategory = 'CONFIRMED' | 'WAITING_ACK' |
  'REVISION' | 'IDENTITY' | 'OTHER';

export function categoryOf(event: ReviewHistoryEvent): HistoryCategory {
  if (event.kind === 'RECEIPT_APPLIED') {
    return 'CONFIRMED';
  }
  if (event.kind === 'RECEIPT_UNKNOWN') {
    return 'WAITING_ACK';
  }
  if (event.kind === 'REVISION_CONFLICT') {
    return 'REVISION';
  }
  if (event.reasonCode === 'SLOT_ID_MISMATCH') {
    return 'IDENTITY';
  }
  return 'OTHER';
}

export function sortHistory(items: ReviewHistoryEvent[]): ReviewHistoryEvent[] {
  return items.slice().sort((a: ReviewHistoryEvent, b: ReviewHistoryEvent) =>
    a.sequence !== b.sequence ? a.sequence - b.sequence :
    a.eventId.localeCompare(b.eventId));
}

这段规则没有把“发起命令”视为已完成,因为COMMAND_CREATED只是操作意图。对于箱体043,页面可以按序展示创建命令与收到确认回执两个节点;箱体051有创建命令和待确认节点;箱体063有冲突节点及版本差异入口。交接人员可以逐个展开,而不用根据三种颜色猜测仓储后台的处理行为。

检索先收紧业务范围,再建立显示列表

历史记录增长后,直接把全部事件放进一个列表再逐字搜索,会让页面出现两类问题:过滤时选中行突然消失,以及新查询的晚到结果覆盖当前批次。检索条件至少包括批次身份、箱位ID、箱体号、归因分类和关键字。批次身份传给后端限定基础范围,其余本地条件作用于已经取得的结果。事件列表的数据源应与用户选择状态分开,当前选择保存eventId,而不是数组下标。

export interface HistoryFilter {
  keyword: string;
  category: HistoryCategory | 'ALL';
  slotId: string;
}

export function visibleEvents(
  source: ReviewHistoryEvent[], filter: HistoryFilter
): ReviewHistoryEvent[] {
  const key = filter.keyword.trim().toLowerCase();
  return sortHistory(source.filter((event: ReviewHistoryEvent) => {
    const categoryMatched = filter.category === 'ALL' ||
      categoryOf(event) === filter.category;
    const slotMatched = filter.slotId.length === 0 ||
      event.slotId === filter.slotId;
    const text = `${event.containerNo} ${event.slotId} ${event.eventId}`.toLowerCase();
    return categoryMatched && slotMatched && (key.length === 0 || text.includes(key));
  }));
}

文本查询范围刻意限定为箱体号、箱位ID和事件ID,避免把尚未处理的详细异常备注随意投进通用搜索。检索字符串在页面上可保留,必要时复原筛选条件,但不应把完整操作键显示给普通列表。用户从“待确认”切换到“修订冲突”时,若已选的051不再可见,详情区可展示“当前筛选未包含此事件”,而不是改选第一条063并让人员误以为记录发生了变化。

历史检索与异常归因的开发环境及列表预览

查询竞态与页面重入需要共同处理

批次切换和筛选加载属于不同层面的异步工作。筛选是对已加载数组的纯计算,切换批次才需要重新发起后台请求。为了防止上一批次的慢响应覆盖新数据,页面每发起一次加载都增加查询代次,只有代次一致的结果才能进入状态变量。异常分支保留当前检索条件,同时在页面上提供重试入口。业务状态的变更仍由核对服务负责,历史页没有确认按钮。

class SnapshotHistoryRepository implements HistoryRepository {
  async queryEvents(batchKey: string): Promise<ReviewHistoryEvent[]> {
    if (batchKey !== 'batch-port-south') { return []; }
    return [
      { eventId: 'event-043-applied', slotId: 'south-02-04',
        containerNo: '043', commandKey: 'op-043', sequence: 101,
        kind: 'RECEIPT_APPLIED', expectedRevision: 3, actualRevision: 4,
        occurredAt: 1793207400000 },
      { eventId: 'event-051-unknown', slotId: 'south-03-01',
        containerNo: '051', commandKey: 'op-051', sequence: 102,
        kind: 'RECEIPT_UNKNOWN', expectedRevision: 1,
        occurredAt: 1793207520000 },
      { eventId: 'event-063-conflict', slotId: 'north-01-08',
        containerNo: '063', commandKey: 'op-063', sequence: 103,
        kind: 'REVISION_CONFLICT', expectedRevision: 5,
        occurredAt: 1793207760000 }
    ];
  }
}

function createHistoryRepository(): HistoryRepository {
  return new SnapshotHistoryRepository();
}

@Entry
@Component
struct ReviewHistoryPage {
  @State private rows: ReviewHistoryEvent[] = [];
  @State private keyword: string = '';
  @State private loading: boolean = false;
  @State private message: string = '';
  private generation: number = 0;
  private repository: HistoryRepository = createHistoryRepository();
  private batchKey: string = 'batch-port-south';

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

  private async refresh(): Promise<void> {
    const current = ++this.generation;
    this.loading = true;
    try {
      const data = await this.repository.queryEvents(this.batchKey);
      if (current !== this.generation) { return; }
      this.rows = sortHistory(data);
      this.message = '';
    } catch (error) {
      if (current === this.generation) {
        this.message = '历史记录读取受阻,可重新获取';
      }
    } finally {
      if (current === this.generation) { this.loading = false; }
    }
  }

  build() {
    Column({ space: 12 }) {
      Text('港口箱位 · 审阅历史').fontSize(22)
      TextInput({ placeholder: '搜索箱体、箱位或事件', text: this.keyword })
        .onChange((value: string) => { this.keyword = value; })
      if (this.message.length > 0) { Text(this.message).fontColor(Color.Orange) }
      List() {
        ForEach(this.rows.filter((item: ReviewHistoryEvent) =>
          `${item.containerNo} ${item.slotId}`.includes(this.keyword)),
        (item: ReviewHistoryEvent) => {
          ListItem() {
            Column() {
              Text(`${item.containerNo} · ${item.slotId}`)
              Text(`${item.kind} · 修订 ${item.expectedRevision}`)
            }.padding(12)
          }
        }, (item: ReviewHistoryEvent) => item.eventId)
      }.layoutWeight(1)
      Button(this.loading ? '获取中' : '刷新历史')
        .enabled(!this.loading).onClick(() => this.refresh())
    }.padding(16).height('100%')
  }
}

这里的createHistoryRepository()为应用自己的数据适配工厂,负责把仓储接口结果转成ReviewHistoryEvent,不属于系统接口。实际工程可将此适配器通过组件参数传入页面,便于单独核查空结果、乱序、超时和异常回执。页面示例使用较小数据集直接过滤,大规模历史则应在后台分页,保留游标及筛选快照,不让所有批次长驻内存。

本地恢复只保存界面线索

列表筛选条件和最近选中的事件可以通过Preferences存为小型JSON,减少页面重建后的重复操作。仓储事件和回执结论依然来自业务数据源,恢复时先检查选中事件是否仍在当前批次,若不存在就清空详情焦点,但不要改动该事件在服务器上的状态。Preferences对单进程小型键值配置很合适,操作时需等待写入与flush()完成;不能把它当成跨进程事务数据库或远端命令回执的替代品。

import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';

export interface HistoryViewHint {
  batchKey: string;
  category: string;
  selectedEventId: string;
}

export async function saveHistoryView(
  context: common.UIAbilityContext, hint: HistoryViewHint
): Promise<void> {
  const store = await preferences.getPreferences(context, { name: 'port_history_view' });
  await store.put('history_filter', JSON.stringify(hint));
  await store.flush();
}

调试时可用固定的043、051、063三条记录构造状态表,对比“操作已创建”“回执已确认”“回执待查”“修订冲突”各自落在哪个类别。随后调换事件输入顺序,观察同一序号规则是否得到一致的页面排列;快速切换批次,检查晚到请求是否被丢弃;清除搜索词,检查选中事件ID是否仍指向原事件;让持久化写入失败,检查界面提示与筛选状态是否分离。HiLog记录批次短标识、事件数量、查询代次和分类计数即可,不记录完整操作令牌。

回执历史、异常归因与待确认记录的手机界面

审阅历史真正要回答的是“为什么此刻看到这个状态”。箱位身份、命令意图、回执事件与最终箱位现状形成独立但可关联的四层信息,才不会因刷新界面或者网络中断丢失责任链。至此,港口箱位连载已经完成单条定位、跨页多选、批量核对与回执回溯的主要流程;后续可以进一步处理多人交接的待办归属和清单导出,而不再重新定义最基本的审阅身份。

参考资料:ArkUI列表创建、TextInput输入与光标处理、Preferences键值存储接口、ArkTS语言概述。

Logo

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

更多推荐