DarkroomFlow已经把五道胶片处理工序串成受顺序约束的流程,并且把计划用时和完成后的实际用时分开记账。第五篇解决了工作台分栏时的数据同源问题:显影计划八分钟、实际八分四十秒,停止计划三十秒、实际四十五秒,两个阶段已经完成;定影计划五分钟,当前被选中;水洗计划十分钟、干燥计划二十分钟仍在后面等待。完成部分的实际累计为09:25,比对应计划多00:55。

现在需要处理的是第三道工序进入运行状态后的显示。定影计划05:00,在计时达到04:00时,应给出接近计划结束的提示;达到05:00显示已到计划时长;继续运行到05:30则显示超出计划00:30。页面需要按真实经过的有效计时秒数更新提示,但不能仅因为时钟越过五分钟,就擅自把定影标记为完成、解锁水洗。这条边界决定了计时器和工序确认必须是两个独立入口。

定影工序的进度阈值与超时状态

区分工序状态和提示级别

上一节的流程摘要仍保持已完成2/5、修订号9。启动定影时,会通过第四篇工作台保留的启动入口进行一次顺序检查;启动动作与定影最终完成确认不是同一事件。计时中的秒数属于当前运行会话,已经完成的两道工序仍按其归档实际耗时结算。因此运行到05:30,完成数量依然是2/5,完整计划时长仍然是43:30。

这里把进度提示设计为四级:running表示尚未到达80%阈值,nearEnd表示已到04:00但未到05:00,plannedEnd表示刚达到05:00,overtime表示已超过05:00。等级完全由有效运行秒数和计划秒数决定,不在四个不同的按钮监听器里各写一段判断。对于五分钟计划,80%阈值就是240秒;330秒有效运行时,超时为30秒。

需要特别注意“已达到计划时长”和“工序已经完成”不能共用一个布尔变量。前者是数学比较,后者应由操作者确认并经过现有状态转移校验。若把计时归零当作自动完成,后台恢复后的时钟跳跃就可能替代业务操作,导致后续工序被提前打开。这里让阈值仅影响进度条颜色、文案和提示状态,流程阶段仍是running。

时间值只保留秒数,不依赖字符串计算

工序时长来自原有数据契约,显影480秒,停止30秒,定影300秒,水洗600秒,干燥1200秒。前两道完成实际分别是520秒和45秒,其余尚无最终实际记录。05:30这类文字适合展示,却不适合状态计算,因为字符串排序无法代替时长比较。业务层始终使用整数秒,视图层再统一格式化。

export type ProgressLevel = 'running' | 'nearEnd' | 'plannedEnd' | 'overtime';
export interface ThresholdView {
  elapsedSeconds: number;
  plannedSeconds: number;
  warningAt: number;
  overSeconds: number;
  progressLevel: ProgressLevel;
}
export function formatSeconds(seconds: number): string {
  const safe: number = Math.max(0, Math.floor(seconds));
  const mm: string = Math.floor(safe / 60).toString().padStart(2, '0');
  const ss: string = (safe % 60).toString().padStart(2, '0');
  return `${mm}:${ss}`;
}
export function classifyProgress(elapsed: number, planned: number): ThresholdView {
  const safePlan: number = Math.max(1, Math.floor(planned));
  const safeElapsed: number = Math.max(0, Math.floor(elapsed));
  const warningAt: number = Math.ceil(safePlan * 0.8);
  let level: ProgressLevel = 'running';
  if (safeElapsed > safePlan) level = 'overtime';
  else if (safeElapsed === safePlan) level = 'plannedEnd';
  else if (safeElapsed >= warningAt) level = 'nearEnd';
  return {
    elapsedSeconds: safeElapsed, plannedSeconds: safePlan,
    warningAt, overSeconds: Math.max(0, safeElapsed - safePlan),
    progressLevel: level
  };
}

这个函数可以独立覆盖几个关键秒数:239秒属于正常运行,240秒进入接近完成,299秒仍为接近完成,300秒表示达到计划,301秒起进入超时。把边界写得明确,能避免>=与>混用时出现一分钟提前变红的问题。计划值为零或负数应在配置录入阶段拒绝;函数里保留最小值保护,避免异常输入引发除零或非数字进度。

使用时间基准刷新,而不是累加定时器次数

如果每次刷新都简单执行elapsedSeconds += 1,当页面忙碌、系统挂起回调或用户离开页面时,刷新次数就可能小于实际经过的时长。定时器应该承担“提示界面重新读取”的职责,当前有效秒数则由起始时间、暂停前累计秒数和运行状态计算。页面切到后台时按既定产品规则暂停,先结算这一段有效时间,再释放周期刷新资源;返回页面后按暂停后的秒数显示,用户明确继续才建立新的运行起点。

计时用当前毫秒时间作为段起点,在页面需要刷新的时候重新计算差值。这里的时间使用系统时钟,若产品需要抵抗手动调时和跨设备时钟变化,应另引入受控的单调计时源,并通过业务事件记录重建运行片段。无论采用哪个时钟,都不能把单次回调触发当作真实过了一秒的依据。当前例子从开始运行累计330秒,显示05:30;进入后台后冻结有效累计,不继续推进流程。

export class ActiveStepClock {
  private accumulatedMs: number = 0;
  private startedAtMs: number = 0;
  private running: boolean = false;
  constructor(initialMs: number = 0) {
    this.accumulatedMs = Math.max(0, initialMs);
  }
  start(nowMs: number): void {
    if (this.running) return;
    this.startedAtMs = nowMs;
    this.running = true;
  }
  pause(nowMs: number): void {
    if (!this.running) return;
    this.accumulatedMs += Math.max(0, nowMs - this.startedAtMs);
    this.running = false;
  }
  seconds(nowMs: number): number {
    const currentMs: number = this.running
      ? Math.max(0, nowMs - this.startedAtMs) : 0;
    return Math.floor((this.accumulatedMs + currentMs) / 1000);
  }
  isRunning(): boolean { return this.running; }
  reset(): void {
    this.accumulatedMs = 0;
    this.startedAtMs = 0;
    this.running = false;
  }
}

这段计时器把“暂停累计”与“当前运行片段”分开,连续点击开始不会重新设置起点,连续暂停也不会多次累加。异常时间差值按零处理,避免设备时钟回退直接产生负的耗时。若准备把数据写入持久存储,至少需要保存工序稳定ID、已经累计的有效毫秒数、运行片段状态和操作修订号;恢复时不能只读取页面上的Text内容。

预测耗时要纳入正在运行的超时部分

第四、五篇的预测总耗时,是已完成实际09:25加上剩余三个工序的计划35:00,得到44:25。当前定影仍未确认完成,但它已比计划的05:00多运行00:30。为了让工作台显示一个不断更新的预测值,可以先计算已经完成工序的偏差+00:55,再叠加当前进行工序的超计划部分+00:30,得到临时综合偏差+01:25,预测全流程耗时变成44:55。

这里需要分开三个数字:已完成部分的偏差仍是+00:55;定影当前超时是+00:30;包含进行中超时的预测偏差是+01:25。不要把05:30当作定影已完成实际值写入最终账本,否则用户暂停或继续运行后,已完成数量与实际统计会发生冲突。预测只是按当前时点与其余工序计划推导;工序确认后才将完整的定影实际时长写入双轨结算记录。

export interface LiveForecast {
  doneDeltaSeconds: number;
  runningOverSeconds: number;
  projectedDeltaSeconds: number;
  predictedTotalSeconds: number;
}
export function liveForecast(elapsedFix: number): LiveForecast {
  const allPlanned: number = 480 + 30 + 300 + 600 + 1200;
  const donePlanned: number = 480 + 30;
  const doneActual: number = 520 + 45;
  const doneDelta: number = doneActual - donePlanned;
  const currentOver: number = Math.max(0, elapsedFix - 300);
  return {
    doneDeltaSeconds: doneDelta,
    runningOverSeconds: currentOver,
    projectedDeltaSeconds: doneDelta + currentOver,
    predictedTotalSeconds: allPlanned + doneDelta + currentOver
  };
}
@Entry
@Component
struct FixTimerPage {
  @State private elapsedSeconds: number = 330;
  private clock: ActiveStepClock = new ActiveStepClock(330000);
  private refreshId: number = -1;
  private continueStep(): void { this.clock.start(Date.now()); }
  private pauseStep(): void { this.clock.pause(Date.now()); }
  private startRefresh(): void {
    if (this.refreshId >= 0) return;
    this.refreshId = setInterval(() => {
      this.elapsedSeconds = this.clock.seconds(Date.now());
    }, 1000);
  }
  private stopRefresh(): void {
    if (this.refreshId >= 0) clearInterval(this.refreshId);
    this.refreshId = -1;
  }
  aboutToAppear(): void { this.startRefresh(); }
  onPageShow(): void { this.startRefresh(); }
  onPageHide(): void {
    this.clock.pause(Date.now());
    this.stopRefresh();
  }
  aboutToDisappear(): void {
    this.clock.pause(Date.now());
    this.stopRefresh();
  }
  build() {
    Column({ space: 10 }) {
      Text('暗房工序 · 定影计时')
      Text(formatSeconds(this.elapsedSeconds))
      Text(`计划 05:00 · 阈值 04:00`)
      Text(`超出计划 ${formatSeconds(classifyProgress(
        this.elapsedSeconds, 300).overSeconds)}`)
      Text(`预测总耗时 ${formatSeconds(
        liveForecast(this.elapsedSeconds).predictedTotalSeconds)}`)
      Row({ space: 12 }) {
        Button('继续计时').onClick(() => this.continueStep())
        Button('暂停计时').onClick(() => this.pauseStep())
      }
    }.padding(16)
  }
}

页面片段展示的是刷新与投影关系。集成到既有应用时,计时器应由当前工序会话统一持有;页面出现时先从持久化状态恢复累计值,用户明确开始或继续后再调用clock.start(Date.now()),不能由页面创建动作自动开始计时。上方330和计时器初始的330000毫秒来自同一份恢复记录,确保第一个刷新周期不会把05:30清零。点击继续计时后,才用当前时间建立新的活动片段;页面切走时先暂停并释放刷新资源。对于采用Navigation缓存页面的工程,还需要结合目标页面的可见生命周期停止定时器,避免隐藏页面继续消耗资源。

开发环境中的阈值分类与时钟刷新

将阈值提示放在业务确认之前

界面上可以用进度条和状态字区分三个阶段:达到04:00时提醒接近计划,05:00时提示已到计划时长,05:30时用橙色突出超时00:30。这里的进度条不适合简单无限增长,可以将主进度限制到100%,超过计划的部分放到独立超时区。这样超时十分钟时不会把布局拉出容器,也不会让后面的水洗卡片跟随产生不必要的位移。

还要留意重复确认与修订号。第三篇建立的令牌检查仍适用:启动定影会形成一条独立会话,完成确认要绑定该会话与当下修订号。页面刷新每秒都可能发生,但这种刷新并非一次业务事件,不应每秒增加流程修订号。只有真正接受的开始、暂停或完成操作才由流程服务判断是否推进版本。第五篇工作台展示的完成账本仍停留在修订9,进行中会话的状态单独管理,直到定影被正式确认结束才合并进完成统计。

异常情况下,比如操作员在超时后暂时切换到别的页面,系统先暂停有效时钟并保留05:30和警告级别;回到页面后,提示继续计时或结束确认。若实际秒数来自持久化记录,需先检验是否为非负整数,并核对所属工序ID为fix,避免把前一道停止工序的剩余秒数恢复到定影卡片。刷新间隔采用一秒只影响视觉流畅度,业务判断基于秒数时不会因为少一次刷新而改变逻辑结果。

定影工序超时界面与流程预测

状态机与计时器不应争夺决定权

到这一节为止,DarkroomFlow已经把“工序是否完成”和“工序进行到什么时间”拆成两套职责。计时器负责测量有效持续时间,阈值函数负责把数字解释为提示等级,双轨汇总负责把已确认的实际值与计划值比较,状态机则负责校验当前工序能否完成并解锁后续步骤。四层职责各有唯一入口,切换手机与平板布局时,也能继续读取同一条工序记录。

后续如果加入历史恢复与最终归档,只需要在当前会话和完成记录之间定义清楚的提交界线,而不必重写计时或阈值算法。对实际工程来说,这比给进度条叠加更多动画更关键:业务转移有明确条件,计时提示可重复计算,异常回到页面时也不会把尚在运行的定影误记为已完成。

相关技术参考:华为开发者:ArkTS定时器、华为开发者:ArkTS页面生命周期、华为开发者:@Watch状态变化通知。

Logo

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

更多推荐