DarkroomFlow的五段暗房流程已经有顺序确认、倒计时暂停、重复请求拦截,以及计划与实际耗时双轨汇总。到了这一阶段,手机页上按顺序展示五张工序卡片还比较自然;但把它放到宽屏工作台时,继续把所有内容拉成一条长列表,会让操作员在上方选择工序,又向下滚动查看时间偏差,确认按钮和当前对象容易失去视觉关联。

上一节的账本需要原样保留:显影计划08:00、实际08:40,停止计划00:30、实际00:45,二者已经完成;定影计划05:00,目前处于就绪状态;水洗计划10:00、干燥计划20:00仍待前序完成。当前流程修订号为9,已完成2/5,已完成计划08:30、实际09:25、累计偏差+00:55,剩余计划35:00,预测总耗时44:25。本节的目标不是修改这些数字,而是在不同宽度下保持一份状态来源。

宽屏工作台的主从布局与耗时汇总

先决定哪个区域拥有操作权

宽屏版本把页面分成两部分:左侧是工序顺序列表,负责选择工序与观察整体进度;右侧是当前工序详情、双轨耗时卡与主操作区。960vp的内容区采用左右布局,420vp时则上下排列。阈值以容器可用宽度720vp划分,原因是两栏同时需要保证计时数字、完整工序名称以及按钮触控范围,而不是简单把整块手机UI放大。

主从布局的难点并不是Row和Column本身,而是两个区域会同时显示“当前工序”。如果左侧选中定影,右侧仍保留上一次的停止详情,用户容易把启动动作落到错误的对象上。这里约定页面父级持有selectedStepId,左右视图都从这一字段读取当前选择。完成确认仍需通过既有版本检查和顺序校验;点选工序仅更换详情焦点,绝不会改变流程状态。

需要分清“选中”与“就绪”:左栏可以选中任意工序查看历史和计划,但只有状态为ready的定影可以启动。显影和停止处于done,水洗和干燥处于waiting。宽屏上的详情面板不能因为某条记录被选中,就自动把它升级为可执行状态。此规则与之前的幂等令牌、修订号检查配合,避免宽屏增加快捷按钮后造成流程跳步。

让投影函数负责表格与详情

最稳妥的方案是保留上一节使用的TimingLine和summarize数据契约,然后在其上构造仅供展示的工作台投影。当前记录从固定的五步列表推导,不靠控件排列顺序反查。如果业务列表后来增加筛选条件,计划、实际与当前状态仍能按照稳定的id取回。

export interface FlowLine {
  id: string;
  name: string;
  plannedSeconds: number;
  actualSeconds: number | null;
  phase: 'done' | 'ready' | 'waiting';
}
export const FLOW_LINES: FlowLine[] = [
  { id: 'develop', name: '显影', plannedSeconds: 480,
    actualSeconds: 520, phase: 'done' },
  { id: 'stop', name: '停止', plannedSeconds: 30,
    actualSeconds: 45, phase: 'done' },
  { id: 'fix', name: '定影', plannedSeconds: 300,
    actualSeconds: null, phase: 'ready' },
  { id: 'wash', name: '水洗', plannedSeconds: 600,
    actualSeconds: null, phase: 'waiting' },
  { id: 'dry', name: '干燥', plannedSeconds: 1200,
    actualSeconds: null, phase: 'waiting' }
];
export function formatDuration(seconds: number): string {
  const m: number = Math.floor(seconds / 60);
  const s: number = seconds % 60;
  return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}

计划秒数保持整数,是为了避免在“08:40”之类的显示字符串上直接做运算。实际秒数为空意味着这道工序尚未完成结算,不能按0秒直接累计。五个稳定的id也同样用于选中状态、按钮权限和事件记录:即使以后将标签改成更贴合工作习惯的名称,业务关联也不需要依赖本地化文字。

每一次更新账本都应该来自一个明确的业务事件。比如定影完成确认通过后,系统才会生成实际耗时并推进下一步;单纯扩大工作区、收起右侧面板或切换屏幕方向没有资格改写账本。把UI投影与业务事件切开,可以让跨布局切换保持可预期性,也更方便排查错误来源。

用同一份账本生成总览指标

工作台的摘要包含完整计划、已完成计划、已完成实际、剩余计划、累计偏差和预测总时长。已完成部分只按phase=done且存在合法实际秒数的条目求和。当前样本中510秒计划对565秒实际,偏差55秒;后面三道工序剩余2100秒计划,预测总时长2665秒。左侧列表只负责展示每条记录,右侧汇总函数负责这些全局数字,避免在多处写一套独立计算公式。

export interface FlowSummary {
  completed: number;
  totalPlan: number;
  donePlan: number;
  doneActual: number;
  remainingPlan: number;
  delta: number;
  forecast: number;
}
export function calculateSummary(lines: FlowLine[]): FlowSummary {
  let totalPlan: number = 0;
  let donePlan: number = 0;
  let doneActual: number = 0;
  let completed: number = 0;
  for (const line of lines) {
    totalPlan += line.plannedSeconds;
    if (line.phase === 'done' && line.actualSeconds !== null) {
      completed += 1;
      donePlan += line.plannedSeconds;
      doneActual += line.actualSeconds;
    }
  }
  const remainingPlan: number = totalPlan - donePlan;
  return {
    completed, totalPlan, donePlan, doneActual, remainingPlan,
    delta: doneActual - donePlan,
    forecast: doneActual + remainingPlan
  };
}
export function canStart(lines: FlowLine[], targetId: string): boolean {
  const target: FlowLine | undefined = lines.find(
    (line: FlowLine) => line.id === targetId
  );
  return target !== undefined && target.phase === 'ready';
}

这段纯逻辑还让测试样本更容易覆盖异常:遇到完成状态和实际字段不一致,应先进入数据修复路径,而非在界面里勉强显示结果;实际秒数不得为负,计划秒数应大于零;同一工序id出现两次也应该被识别出来。合法性检查可以放到输入层,不让脏数据穿透到展示层后再由各个Text组件分别兜底。

视觉上也需要区分“完成耗时”和“预测耗时”。09:25是已确认的两步实际累计;44:25是在其余三步继续采用计划值的前提下推导出来的预测值。UI将二者排列在不同的指标卡片,避免颜色或大小暗示它们是同一种统计口径。最终五步全部完成时,预测才可以改为完整实际总结。

自适应布局与选择状态分离

HarmonyOS 7对应的API 26提供ContainerReader,适合按当前内容容器宽度切换主从布局。这里在父组件存放尺寸、选中工序以及账本。左右构建函数读取同一@State selectedStepId,使用onClick只更改这一焦点。因为工序详情与权限都通过稳定id派生,所以即使分栏改变导致子组件树重新创建,也不会把选中定影变成选中停止。

import { ContainerReader, Size } from '@kit.ArkUI';

@Entry
@Component
struct WorkbenchPage {
  @State private contentSize: Size = { width: 0, height: 0 };
  @State private selectedStepId: string = 'fix';
  @State private revision: number = 9;
  @State private currentPhase: 'ready' | 'running' = 'ready';
  @State private acceptedTokens: string[] = [];

  private startSelected(): void {
    const token: string = 'start-fix-01';
    if (this.selectedStepId !== 'fix' || this.currentPhase !== 'ready' ||
        this.revision !== 9 || this.acceptedTokens.includes(token)) {
      return;
    }
    this.acceptedTokens = [...this.acceptedTokens, token];
    this.currentPhase = 'running';
    this.revision = 10;
  }

  @Builder
  private stepsPane() {
    Column({ space: 10 }) {
      ForEach(FLOW_LINES, (line: FlowLine) => {
        Row() {
          Text(`${line.name} ${formatDuration(line.plannedSeconds)}`)
          Text(line.phase === 'done' ? '已完成' :
            line.phase === 'ready' ? '就绪' : '等待')
        }
        .padding(12).width('100%')
        .backgroundColor(this.selectedStepId === line.id ? '#D9EDFF' : '#FFFFFF')
        .onClick(() => { this.selectedStepId = line.id; })
      }, (line: FlowLine) => line.id)
    }
  }

  @Builder
  private detailPane() {
    Column({ space: 12 }) {
      Text(`当前选择 ${this.selectedStepId}`)
      Text(`修订号 ${this.revision}`)
      Text('已完成2/5 · 实际09:25 · 偏差+00:55')
      Button(this.currentPhase === 'ready' ? '开始定影' : '定影计时中')
        .enabled(this.selectedStepId === 'fix' && this.currentPhase === 'ready' &&
          canStart(FLOW_LINES, 'fix'))
        .onClick(() => this.startSelected())
    }.padding(16).backgroundColor('#EFF6FF')
  }

  build() {
    ContainerReader({ size: this.contentSize!! }) {
      if (this.contentSize.width >= 720) {
        Row({ space: 16 }) {
          Column() { this.stepsPane() }.layoutWeight(2)
          Column() { this.detailPane() }.layoutWeight(3)
        }
      } else {
        Column({ space: 16 }) {
          this.stepsPane()
          this.detailPane()
        }
      }
    }
    .breakpointConfig({ width: [720] })
    .width('100%').height('100%')
  }
}

按钮的交互包含当前工序、修订号和令牌的检查;接受一次启动后,将页面级阶段变为运行中并推进修订号,重复点击因阶段变化而失去操作入口。这里的页面状态转移还应与既有流程控制服务的确认入口保持一致,持久层按相同令牌和预期修订号处理,避免宽屏新增快捷操作后绕开第三节建立的重复保护。正在计时的定影仍属于尚未完成的工序,不应因为启动动作立刻写入实际耗时。

白色开发环境中的主从工序工作台

从420vp到960vp的状态校对

选择定影后,在420vp内容区先展示工序列表,再展示定影的详情和“开始定影”按钮。将容器扩展至960vp,页面变为左列表右详情,定影仍处于选中状态,修订号仍为9,摘要卡仍应写着已完成2/5、09:25和+00:55。再次缩回420vp,原来的统计口径不能重新计算出别的结果,更不能因为尺寸变化触发“开始”事件。

如果操作员在右栏停留时查看已完成的停止工序,左侧可以高亮停止,但右侧启动按钮要随之禁用,因为停止已是done。如果用户再选择定影,按钮重新可用。这说明“当前选中”与“当前可执行”完全是两类状态。可以为键盘和触控提供同样的选择路径,语义与顺序保持一致,避免同一工序在不同设备上行为分叉。

此外,分栏宽度如果被其它页面元素占用,内容区可能比窗口宽度小得多。容器断点针对实际可用空间做判断,配合滚动或自适应文本换行,比强行设置固定960vp窗口尺寸更可靠。对于主面板较窄的情况,时间汇总卡可以转为纵向排列,但数字仍然从summarize的同一份结果读取。

单列界面的工序与双轨耗时卡

让主从结构维持清楚的职责

这一节的工程变化集中在展示层:左侧“查找与选择”,右侧“读取详情与提交入口”,父级“维护焦点”,流程服务“确认合法事件”。从一列变成两列没有产生第二套工序账本,也没有让倒计时与汇总成为两个互相竞争的数据来源。这样一来,维护人员可以针对实际耗时计算单独核对,也可以针对布局在阈值附近的行为单独处理。

下一步加入历史记录恢复时,依旧能使用当前的稳定工序id、修订号以及原有的完成确认语义。用户从手机查看、再打开平板工作台,不应看到不同的总耗时。布局适配的价值,就体现在屏幕形态改变时,工程数据和操作约束始终保持同一套定义。

相关技术参考:华为开发者:ContainerReader容器断点、华为开发者:ArkUI布局概述、华为开发者:@Watch状态通知。

Logo

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

更多推荐