HarmonyOS影展排片连载04:批量调位撤销与变更轨迹审计【共创稿事节】
影展排期进入集中调整阶段以后,工作人员通常先处理冲突,再处理调整后的连锁影响。上一节已经可以把几场影片整体移动到不同厅位,列出符合准备时间、清场时间和跨午夜约束的候选方案。真正容易出问题的是“保存之后反悔”:批量调整刚发布,主办方又要求恢复原来的放映厅。若页面只是从历史数组取出一份旧排期覆盖当前数据,便会抹去已经发生过的修改,也可能覆盖另一名工作人员随后添加的场次。
我把撤销定义为一次新的业务变更,而不是时间倒流。原排期版本为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接口、页面生命周期。
更多推荐


所有评论(0)