HarmonyOS 7 活动列表与入场凭证进度关联:连载02(共08篇)【共创稿事节】

活动排队页面通常只是用户进入服务后的一个局部入口。对于已经预约多个展览或体验项目的人来说,更常见的操作是先打开活动列表,确认要参加哪场活动,再查看对应凭证与入场进度。如果把所有字段放进单个“活动对象”,业务很快会遇到难题:同一场活动可以有多张凭证,同一张凭证只对应某一次入场,而排队序号又可能持续变化。

上一阶段已经定义了 QueueSnapshot 和排队状态归约规则。这一阶段不改写状态机,而是补充活动目录、入场凭证和关联后的展示模型。HarmonyOS 7 的闪控窗和闪控球以后都可以读取同一份结果:主页面显示完整活动列表,紧凑窗口则只抽取用户当前关注的凭证摘要。

活动目录、凭证与排队进度的关联结构

一、先把三类数据拆清楚

活动目录描述“活动是什么”,通常包括活动 ID、名称、场馆、举办时间和名额;入场凭证描述“谁拥有哪次入场资格”,包含凭证 ID、活动 ID 和可展示的脱敏编号;排队快照则描述“这张凭证当前排到哪里”。三类数据生命周期不同,更新频率也不同,适合分别存储与读取。

数据实体核心关联键更新特点推荐展示区域
活动 EventInfoeventId活动调整时更新列表卡片、详情页
凭证 EntryPassticketId 与 eventId预约、改签或作废时更新凭证卡片
进度 QueueSnapshotticketId排队期间频繁更新进度摘要、状态入口

不能只使用活动 ID 去匹配进度,因为同一活动可能允许家庭预约、同行人预约或者补领凭证。也不能拿列表数组下标绑定入场状态:只要用户切换排序、筛选或重新加载活动,下标就可能变动。推荐使用 eventId 关联活动与凭证,使用 ticketId 精确关联凭证与队列。

对于尚未预约的活动,显示“了解活动”即可;已拥有凭证但还没有排队信息时,应显示“凭证已领取,等待进度更新”。信息缺失与“排队中”是完全不同的业务状态,不能用默认数字 0 将两种情况混淆。

二、定义活动与凭证实体

在既有工程中新建 model/EventCatalog.ets。这里仅存放页面需要的字段;票务核验涉及的原始密钥、签名内容等应由安全存储与服务端流程单独管理,普通列表只接触脱敏摘要。示例中为两个活动建立资料,并给其中两张凭证指定对应事件。

export interface EventInfo {
  eventId: string;
  title: string;
  venue: string;
  startText: string;
  category: string;
}

export interface EntryPass {
  ticketId: string;
  eventId: string;
  maskedCode: string;
  entryPeriod: string;
}

export function loadEvents(): EventInfo[] {
  return [
    { eventId: 'art-night', title: '城市艺术夜展',
      venue: '城市文化中心·展厅A', startText: '10月31日 19:30',
      category: '艺术展览' },
    { eventId: 'science-day', title: '科学互动体验日',
      venue: '城市科学馆', startText: '11月2日 10:00',
      category: '互动体验' },
    { eventId: 'reading-walk', title: '夜读漫游计划',
      venue: '城市图书馆', startText: '11月5日 18:30',
      category: '阅读活动' }
  ];
}

export function loadPasses(): EntryPass[] {
  return [
    { ticketId: 'entry-7264', eventId: 'art-night',
      maskedCode: '**** 7264', entryPeriod: '19:00—20:00' },
    { ticketId: 'entry-8321', eventId: 'science-day',
      maskedCode: '**** 8321', entryPeriod: '10:00—11:00' }
  ];
}

这里的时间使用面向页面的文本字段,便于先完成卡片布局。接入后端接口时,实际存储层更适合使用带明确时区的时间戳与活动时区字段,展示时再根据时区格式化,防止跨地区使用时出现日期偏移。凭证编号采用掩码文本,列表页面不应持有完整核验码。

一张活动卡片至少要稳定表达三件事:活动名称与地点、是否拥有凭证、当前排队阶段。只用一个彩色标签代替所有信息,用户难以判断“未预约”和“进度尚未更新”的区别。因此应构造专用的页面投影模型,把关联过程与实际组件解耦。

三、聚合模型负责检查关联关系

在 logic/EventProjection.ets 中,把 EventInfo、EntryPass 与 QueueSnapshot 转成适合渲染的扁平结构。归约器已经负责拒绝同一凭证的旧版本;聚合层只读取被接受的当前快照,不负责修改排队阶段。

import { EventInfo, EntryPass } from '../model/EventCatalog';
import { QueueSnapshot, phaseLabel } from '../model/QueueModel';

export interface EventCardModel {
  eventId: string;
  title: string;
  venue: string;
  startText: string;
  ticketId: string;
  maskedCode: string;
  progressText: string;
  positionText: string;
  hasPass: boolean;
}

export function buildEventCards(events: EventInfo[],
  passes: EntryPass[], queues: QueueSnapshot[]): EventCardModel[] {
  let cards: EventCardModel[] = [];
  for (let i: number = 0; i < events.length; i++) {
    const event: EventInfo = events[i];
    const pass: EntryPass | undefined = passes.find(
      (value: EntryPass): boolean => value.eventId === event.eventId);
    const queue: QueueSnapshot | undefined = pass ? queues.find(
      (value: QueueSnapshot): boolean =>
        value.ticketId === pass.ticketId && value.eventId === event.eventId
    ) : undefined;
    cards.push({
      eventId: event.eventId, title: event.title,
      venue: event.venue, startText: event.startText,
      ticketId: pass ? pass.ticketId : '',
      maskedCode: pass ? pass.maskedCode : '',
      progressText: !pass ? '尚未预约' :
        (queue ? phaseLabel(queue.phase) : '等待进度更新'),
      positionText: queue && queue.position > 0 ?
        `序号 ${queue.position}` : '',
      hasPass: pass !== undefined
    });
  }
  return cards;
}

该函数在生成页面结果时校验两次关联:先用活动 ID 找到凭证,再用凭证 ID 与活动 ID 同时查找排队快照。双条件校验可以减少服务端数据串票、旧本地缓存未清理等情况对显示结果的影响。若一个活动允许多凭证,聚合层应将 EntryPass[] 作为列表,而不能用 find() 选第一张;当前代码对应每场活动一张个人凭证的场景。

这个投影模型既可以供应活动列表,也可以筛选出 hasPass 为 true 的内容形成“我的预约”栏目。以后加入轻量窗口时,只需依据 ticketId 从同一批模型中选取关注项,不必重新构造另一套排队数据库。

四、列表和凭证区域分开渲染

下面继续使用上一阶段的 model/QueueModel.ets,新建页面读取本地示例数据。为突出数据拼接关系,页面先构建两条排队快照:艺术夜展处于排队阶段,科学互动活动则仍处于预约阶段。列表用 ForEach 和稳定的 eventId 作为键,选择活动后更新凭证详情。

pages/Index.ets:

import { EntryPhase, QueueSnapshot } from '../model/QueueModel';
import { loadEvents, loadPasses } from '../model/EventCatalog';
import { EventCardModel, buildEventCards } from '../logic/EventProjection';

function currentQueues(): QueueSnapshot[] {
  return [
    { ticketId: 'entry-7264', eventId: 'art-night',
      phase: EntryPhase.WAITING, position: 28,
      waitMinutes: 12, seq: 2, updatedAt: 1793475060000 },
    { ticketId: 'entry-8321', eventId: 'science-day',
      phase: EntryPhase.RESERVED, position: 0,
      waitMinutes: 0, seq: 1, updatedAt: 1793475000000 }
  ];
}

@Entry
@Component
struct Index {
  @State cards: EventCardModel[] = buildEventCards(
    loadEvents(), loadPasses(), currentQueues());
  @State selectedEventId: string = 'art-night';

  private selectedCard(): EventCardModel | undefined {
    return this.cards.find((card: EventCardModel): boolean =>
      card.eventId === this.selectedEventId);
  }

  private selectedCode(): string {
    const card: EventCardModel | undefined = this.selectedCard();
    return card ? (card.maskedCode || '尚未领取') : '尚未领取';
  }

  private selectedProgress(): string {
    const card: EventCardModel | undefined = this.selectedCard();
    return card ? card.progressText : '请选择活动';
  }

  build() {
    Column({ space: 14 }) {
      Text('活动与入场凭证').fontSize(26).fontWeight(FontWeight.Bold)
      List({ space: 10 }) {
        ForEach(this.cards, (item: EventCardModel) => {
          ListItem() {
            Column({ space: 5 }) {
              Text(item.title).fontSize(20).fontWeight(FontWeight.Medium)
              Text(`${item.startText} · ${item.venue}`).fontSize(13)
              Text(item.progressText).fontColor('#146CC9')
              Text(item.positionText).fontSize(13)
            }
            .width('100%').padding(14).backgroundColor('#F1F6FC')
            .borderRadius(14)
            .onClick(() => { this.selectedEventId = item.eventId; })
          }
        }, (item: EventCardModel): string => item.eventId)
      }.layoutWeight(1)
      Text(`凭证:${this.selectedCode()}`).fontSize(18)
      Text(this.selectedProgress()).fontSize(16)
    }.height('100%').width('100%').padding(18)
  }
}

示例使用的 List、ListItem 和 ForEach 都是 ArkUI 常规列表构建方式。列表卡片选中变化与排队快照刷新是两条不同的数据流:选中变化只替换 selectedEventId;新的进度到达时,应先交给上一阶段的归约器,再重新执行 buildEventCards() 并整体替换 cards。这样用户正在查看的活动不会因为列表重新计算而跳到另一张凭证。

活动目录与凭证数据的ArkUI开发界面

五、列表刷新要处理版本与缺失数据

一个容易忽略的时序是:页面先收到活动目录,再收到凭证清单,最后才拿到排队快照。此时卡片应该依次从“尚未预约”过渡到“等待进度更新”,再显示“排队中”。不要为了让页面尽快出现数字而把上一场活动的队列位置放进当前卡片,也不要把刚刚刷新的列表直接视作入场资格确认。

如果服务端返回了相同凭证的两条进度,应按 seq 依次经由 reduceQueue() 处理;如果快照的 eventId 与凭证归属不一致,聚合层就应拒绝关联并保留安全的空状态。活动已下架但历史凭证仍存在时,可以在“我的预约”中保留历史入口,而不是把它塞进正在售票的活动列表中。这里需要区分目录状态与凭证历史状态,避免一个查询接口的结果直接决定所有卡片是否存在。

使用列表缓存时,应区分可缓存的公开活动数据和敏感凭证信息。活动标题、场馆与公开时间适合本地短期缓存;完整核验凭据则需要按安全要求存储和访问。页面截图、普通日志、无障碍标签都使用脱敏凭证编号即可。二维码若用于现场核验,应由明确的可信出票与核验流程生成,不能把随意拼接的字符串当成可入场凭证。

六、通过几个高频情形检查页面逻辑

场景页面应呈现的结果关键判断
活动有目录,用户无凭证尚未预约pass 为空
有凭证但进度源尚无快照等待进度更新queue 为空
收到凭证匹配的等待状态排队中、当前序号ticketId 与 eventId 同时匹配
列表顺序调整仍显示原来选中的活动使用稳定 eventId
旧进度晚于新进度到达不回退到旧序号先由归约器比较 seq
凭证已失效展示明确终止状态复用 phaseLabel

调试可分别输入三组示例资料:只有活动、活动加凭证、活动加凭证和队列快照。检查 buildEventCards() 的输出数量、关联键、脱敏编号和展示文案,再检查页面选择动作。如果卡片显示错误,先确认是数据拼接错误还是 UI 读取了旧 cards,避免直接修改渲染代码掩盖关联问题。

活动列表与入场凭证的手机页面效果

七、从目录走向轻量化入场服务

活动列表、入场凭证与排队进度分离后,应用已经具备清晰的页面数据结构。完整主页面可以展示多场活动与票据;关注某张凭证时,可以抽取活动标题、阶段、序号和预计等待时间形成紧凑数据投影。后续窗口形态再变化,也不会改变活动 ID、凭证 ID 与排队版本之间的关系。

真正需要持续维护的是数据一致性:列表排序不改变身份,消息乱序不回退状态,凭证缺失不冒用其他活动的进度,用户切换入口也不重新计算排队资格。将这些约束落实在数据层,界面的多形态交互才有稳固基础。

参考资料:HarmonyOS 7 新能力一览、ArkUI List 列表开发、ArkUI 状态变更监听。

Logo

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

更多推荐