影展排期进入集中调整阶段以后,工作人员通常先处理冲突,再处理调整后的连锁影响。上一节已经可以把几场影片整体移动到不同厅位,列出符合准备时间、清场时间和跨午夜约束的候选方案。真正容易出问题的是“保存之后反悔”:批量调整刚发布,主办方又要求恢复原来的放映厅。若页面只是从历史数组取出一份旧排期覆盖当前数据,便会抹去已经发生过的修改,也可能覆盖另一名工作人员随后添加的场次。

我把撤销定义为一次新的业务变更,而不是时间倒流。原排期版本为12,批量调位写入版本13,撤销成功后应当得到版本14。这样才能解释谁调整、谁恢复、两次操作影响哪些场次以及恢复前是否重新检查了放映厅占用。下面仍使用连续绝对分钟,跨午夜时不重置时间坐标。

排期撤销与历史变更的工程关系

把一次批量调整保存为可追踪的事实

上一节的候选排序关注“能否移动”;这次首先要回答“移动前是什么”。每个场次除了影片、厅位、开始时间,还需要记录准备及清场缓冲。批量变更应保留调整前后两个状态,同时附带一个操作组标识。组标识属于真实业务数据,用于将三部影片的一次整体调位关联起来,而不是依赖页面组件记住最近点击了哪个按钮。

例如《星际之旅》原来安排在一号厅23:30,之后被调整到二号厅次日00:15;《深海奇遇》与第三部短片也可能在同一个操作组中同步移动。撤销时不能只还原选中的电影,否则其余场次的占用关系将失去一致性。还必须保留操作所依据的版本号:前端仍停留在版本12时,不能直接撤销已经被其他修改推进到版本15的排期。

要解决的问题是统一排期、变更和审计记录的数据形状,使后续检查不依赖界面显示文本。

export interface Screening {
  id: string;
  filmTitle: string;
  hallId: string;
  startMinute: number;
  durationMinute: number;
  prepareMinute: number;
  clearMinute: number;
}

export interface MovePair {
  before: Screening;
  after: Screening;
}

export interface AuditEvent {
  id: string;
  action: 'MOVE' | 'UNDO';
  groupId: string;
  parentEventId: string;
  baseRevision: number;
  resultRevision: number;
  occurredAt: number;
  changes: MovePair[];
}

export interface ScheduleDocument {
  revision: number;
  screenings: Screening[];
  events: AuditEvent[];
}

变更记录保存的不是一串自然语言,而是可以用于校验的字段。parentEventId在普通调位记录中为空串,撤销记录则指向被撤销的操作。后续若要做事件列表筛选,只需按action和groupId查找。保存之前必须复制场次对象,避免用户继续编辑页面时把历史记录中的对象一同改掉。事件时间使用时间戳,展示层再负责转换成本地日期。

恢复旧值之前重新计算全局占用

撤销的入口虽然来自历史记录,结果却必须服从当前排期规则。最直接的反例是:原一号厅的时段在批量调位以后被安排了另一场影片。此时恢复《星际之旅》会造成新冲突,即使版本检查通过也应拒绝提交。

沿用前两节的“准备—放映—清场”占用窗口,先在工作副本中同时恢复整个操作组,再检查所有场次两两之间的厅位占用。不能逐条恢复、逐条保存,因为第三条尚未恢复之前计算得到的合法状态,可能被后续变化推翻。半开区间[start,end)仍然是边界规则:前场清场结束恰好等于后场准备开始时,不算重叠。

要解决的问题是在不覆盖当前数据的前提下创建完整撤销候选,并显式计算失败原因。

function occupiedStart(item: Screening): number {
  return item.startMinute - item.prepareMinute;
}

function occupiedEnd(item: Screening): number {
  return item.startMinute + item.durationMinute + item.clearMinute;
}

function hasCollision(items: Screening[]): boolean {
  for (let i: number = 0; i < items.length; i++) {
    for (let j: number = i + 1; j < items.length; j++) {
      const a: Screening = items[i];
      const b: Screening = items[j];
      if (a.hallId === b.hallId &&
          occupiedStart(a) < occupiedEnd(b) &&
          occupiedStart(b) < occupiedEnd(a)) {
        return true;
      }
    }
  }
  return false;
}

这里没有把两部电影的片长直接相加,准备时间和清场时间被包含在各自占用窗口中。校验时不区分修改前后的出发厅位,而是比较工作副本的最终全貌。场次很多时可以先按厅位分组再按起点排序,将成对比较缩减为相邻比较;不过在数据量尚小的排期页面,直接算法更容易审核边界。

用反向操作生成新修订,不覆盖旧修订

具体撤销逻辑需要检查三件事:目标事件存在且属于已发布的调位;当前文档修订号等于目标事件的结果修订号;每个待恢复场次的当前位置仍与当时的after完全一致。第三项很重要:只检查全局版本仍不足以解释场次是否被另一个流程更新,尤其是在接入多入口操作以后。

本文采用较严格的整版撤销策略。只要目标事件之后出现任何正式修改,就先要求用户刷新变更历史并重新确认。这个策略会拒绝某些本可安全恢复的操作,却能避免用旧副本悄悄覆盖新工作。

要解决的问题是将“撤销一次调位”变成一条追加的审计事件。

function samePlace(a: Screening, b: Screening): boolean {
  return a.hallId === b.hallId &&
    a.startMinute === b.startMinute &&
    a.durationMinute === b.durationMinute &&
    a.prepareMinute === b.prepareMinute &&
    a.clearMinute === b.clearMinute;
}

export function makeUndo(
  doc: ScheduleDocument,
  event: AuditEvent,
  newId: string
): ScheduleDocument {
  if (event.action !== 'MOVE' ||
      doc.revision !== event.resultRevision) {
    throw new Error('排期版本已变化,请刷新后重试');
  }
  if (doc.events.some((e: AuditEvent) =>
    e.action === 'UNDO' && e.parentEventId === event.id)) {
    throw new Error('该变更已存在撤销记录');
  }
  for (const pair of event.changes) {
    const sameId: Screening[] = doc.screenings.filter(
      (item: Screening) => item.id === pair.after.id);
    if (sameId.length !== 1) {
      throw new Error('待恢复场次缺失或出现重复ID');
    }
  }
  const next: Screening[] = doc.screenings.map((item: Screening) => {
    const pair: MovePair | undefined =
      event.changes.find((p: MovePair) => p.after.id === item.id);
    if (!pair) {
      return { ...item };
    }
    if (!samePlace(item, pair.after)) {
      throw new Error('场次已被后续操作修改');
    }
    return { ...pair.before };
  });
  if (hasCollision(next)) {
    throw new Error('恢复后的放映厅占用发生冲突');
  }
  const inverse: MovePair[] = event.changes.map((p: MovePair) => ({
    before: { ...p.after }, after: { ...p.before }
  }));
  const audit: AuditEvent = {
    id: newId, action: 'UNDO', groupId: event.groupId,
    parentEventId: event.id, baseRevision: doc.revision,
    resultRevision: doc.revision + 1, occurredAt: Date.now(),
    changes: inverse
  };
  return {
    revision: audit.resultRevision,
    screenings: next,
    events: [...doc.events, audit]
  };
}

操作组里的场次都通过唯一ID计数复核,缺失或重复都会被拒绝。业务服务还会检查是否已经存在指向同一调位事件的撤销记录,防止历史按钮误触生成第二条反向补偿。页面按钮可以根据这些条件调整状态,却不能替代提交前的业务检查;真正写入时仍应重算版本与占用关系。

持久化与页面状态分开提交

状态更新的顺序决定了撤销失败时用户会看到什么。若先刷新页面,再写入本地存储,写入失败后页面会显示尚未保存的版本14。更稳妥的顺序是:取当前文档、生成候选、进行版本与冲突复核、持久化候选、持久化成功后更新@State。一旦任何步骤抛错,页面保持原状态并给出具体原因。

这里使用Preferences保存一个较小的排期文档,整份文档以JSON字符串存入同一个键值,避免排期和事件分散在多个键中。getPreferencesSync从API 10起提供,put和flush有Promise形式。该方案用于单进程、小体量排期编辑。对于多人同时改表或事件数持续增长的管理后台,更适合将修订号条件更新与事件追加纳入关系数据库事务。

要解决的问题是让异常处理与页面生命周期对齐,避免异步写入还没结束就宣称撤销完成。

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

export class ScheduleStore {
  private pref: preferences.Preferences;

  constructor(context: Context) {
    this.pref = preferences.getPreferencesSync(
      context, { name: 'film_schedule' });
  }

  load(): ScheduleDocument {
    const raw: string = this.pref.getSync('document', '') as string;
    if (raw.length === 0) {
      return { revision: 0, screenings: [], events: [] };
    }
    return JSON.parse(raw) as ScheduleDocument;
  }

  async save(doc: ScheduleDocument): Promise<void> {
    await this.pref.put('document', JSON.stringify(doc));
    await this.pref.flush();
  }
}

页面可以在aboutToAppear读取文档,在撤销按钮的点击事件中启动异步保存,完成后一次性替换界面绑定的数据。等待阶段将按钮设为不可重复触发,避免连续点击生成两个具有相同修订号的事件。aboutToDisappear不应承担关键数据写入:页面已经进入销毁阶段时再启动保存,不如在用户确认那一刻明确等待结果。

另外,Preferences不是跨进程的条件提交数据库。同一文件被多个写入者竞争时,应用层读出版本再写回的间隔仍可能存在交错。当前实现应通过页面协调器保持同一时刻只有一个写入流程;后续接入多端协作时,再将版本比较和写操作移至支持事务的存储层。

开发环境中的撤销服务与审计页面结构

调试应该关注不变量,而不是按钮有没有响应

排查时可以固定三组场次:版本12的一号厅23:30;版本13的二号厅次日00:15;版本14应恢复到一号厅23:30。随后引入另一场占用一号厅原时段的影片,撤销应返回冲突而不产生版本14。再将当前修订号人为设成14却仍尝试撤销版本13,入口应提示需要重新载入。

日志建议记录groupId、baseRevision、resultRevision、被影响的场次数以及冲突厅位,而不是输出完整的业务对象或其他不必要字段。时间坐标也要一起核对:跨午夜的次日00:15必须仍为连续分钟,不能在审计重放时被转换成独立的第十五分钟。

批量调位版本与撤销入口的界面布局

如果希望保留更长的撤销链,还应定义策略:撤销记录本身是否允许再次撤销、同一调位操作是否只能撤销一次,以及在用户后续重新排片之后如何处理旧的操作组。本文选择“只允许撤销当前最新调位”,判断简单、错误提示明确,适合作为影展排期工具的首个审计版本。

排期工具发展到这一节,历史列表终于不只是让人回忆操作过程,而是成为带有版本约束、占用复核和事件关联的可解释记录。下一节可以围绕排期发布后的观众通知制作差异清单,避免后台调位和对外展示各自维护一份难以对账的时间表。

华为开发者资料:ArkTS语言基础、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-state@State状态管理、Preferences接口、页面生命周期。

Logo

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

更多推荐