前言

我在看一个整理结果页的小窗状态时,第一眼注意到的是按钮位置太靠后了。全屏状态下,这个页面看起来信息很全,标题、状态、摘要、来源、时间、标签、识别内容、处理建议、主按钮和次按钮都能放下。到了悬浮窗尺寸以后,这些内容仍然按全屏页面的顺序往下排,用户要先看完一堆说明,才能找到真正要点的那个按钮。

这个细节在 Pura X Max 上比较典型。展开态适合放更多信息,分屏和自由窗口会让页面变窄,悬浮窗则更像一个临时处理的小区域。Pura X Max 外屏是 5.4 英寸,内屏是 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。这个设备既有展开态的大屏场景,也有分屏和悬浮窗里的小窗口场景,如果页面一直按全屏状态设计,小窗里最先受影响的通常就是主操作。

我这次处理的是一个整理结果确认页。它在全屏下可以展示完整信息,但在悬浮窗里,用户大概率只是想确认当前记录、点一下处理、把这件事先放下。这个时候页面不需要带着所有字段一起进入小窗,先让用户知道当前处理对象、当前状态,以及下一步应该点哪里,实际使用里已经够了。

这次适配基于下面这个环境:

  • 设备形态:Pura X Max 阔折叠设备
  • 系统版本:HarmonyOS 6.1
  • 页面类型:整理结果页、提醒确认页、轻量处理页
  • 技术方向:窗口宽度判断、信息收缩、操作区保留、full / compact / minimal 三档状态

我给页面增加了一个 minimal 状态。它的目的很直接,小窗口里只留下当下要用的东西。标题、状态、主按钮留下;摘要、元信息、识别详情、辅助入口先收起来。窗口变宽以后,这些内容再逐步回到页面里。这样处理以后,小窗不会变成一个缩小后的长页面,用户也不用在里面找按钮。

一、小窗里先把任务放到前面

1.1 全屏内容搬进去以后会挡住按钮

很多页面在全屏下看起来内容并不多,因为屏幕有足够空间。标题放一行,摘要放两三行,下面再放来源、时间、标签,识别内容继续往下排,最后再放主按钮和次按钮。用户全屏使用时,从上往下看一遍内容,最后点按钮,这个顺序没有太大阻碍。

到了悬浮窗尺寸,这个顺序就会变成负担。窗口只有一小块区域,标题、摘要、来源、时间、标签、识别内容、处理建议都挤在一起,按钮自然就被挤到后面。用户本来只是想快速处理一个提醒,现在却要先在小窗里重新读一遍完整页面,这和悬浮窗里的实际动作并不匹配。

我在处理这类页面时,会先把任务拆得更具体一点。用户打开这个小窗时,最可能完成哪件事?如果只是确认一条记录、保存一个提醒、处理一个结果,页面里最应该留下的就不是所有信息,而是当前对象、当前状态和主操作。

比如这个整理结果页,悬浮窗里真正要保留的是这些内容:

  • 记录标题,让用户知道自己正在处理什么
  • 状态标签,让用户知道这条记录是否还在待处理
  • 主按钮,让用户可以直接完成当前动作
  • 少量反馈,比如已经操作了几次

摘要、来源、时间、标签、识别原文、处理建议这些内容仍然有价值,但它们不适合排在主操作前面。窗口恢复到更宽状态后,再把它们放回来,页面的任务顺序会更贴近用户在小窗口里的真实动作。

1.2 小窗口承担的是临时处理

悬浮窗里的操作一般不会持续很久。用户可能一边看其他内容,一边把这个应用放成小窗,只想顺手处理一个提醒;也可能在分屏里切出一个小窗口,确认一条识别结果是否要保存。这种使用方式和全屏阅读完全不同。

我会把悬浮窗看成一个临时处理入口。用户把页面缩成小窗时,大概率不是为了完整阅读一条记录,而是想确认当前事项能不能处理、要不要保存、是否需要稍后再看。这个时候如果还把全屏里的摘要、来源、识别内容、处理建议都按原顺序塞进去,主按钮会被压到很靠后的位置。页面看起来信息完整,但用户真正要做的动作反而被放到了后面。

这里需要一张示意图把问题抽出来。真实截图有时会被具体样式干扰,示意图可以直接表达小窗口里内容太多、主操作被挤到下方这个现象。看这张图时,不用关注颜色和卡片细节,主要看内容块和按钮的位置关系。

这张图也可以作为后面代码判断的铺垫。后面的 minimal 状态,其实就是针对这张图里暴露出来的页面顺序做处理。我们不是在小窗口里压缩所有内容,而是把主操作提前,把辅助信息往后收。

二、信息分成三档

2.1 full 承载完整上下文

宽窗口下,我会保留完整信息。这个状态适合全屏、展开态或者较宽的自由窗口,页面可以展示标题、状态、摘要、元信息、识别内容、处理建议和多个操作入口。用户有足够空间看上下文,也能从辅助面板里看到更多补充信息。

在示例里,full 状态会显示主卡片和右侧辅助面板。主卡片负责当前记录,右侧面板放建议、关联入口和状态补充。这个状态适合用户停下来仔细处理一条记录,比如确认识别内容是否准确,或者判断要不要把这条记录保存成待办。

private readonly fullWidth: number = 840;

这里的 840vp 是示例里的门槛,不是固定标准。如果页面字段更多,或者右侧说明卡片更宽,这个值可以继续提高。我这里宁愿让页面晚一点进入 full,也不希望右侧面板刚出现就把主卡片挤窄。对整理结果页来说,主卡片仍然是主要区域,辅助面板只是补充。

这个判断也可以迁移到其他页面里。比如待办页、提醒页、识别结果页,只要右侧有辅助信息,进入 full 的阈值都要看主内容还能不能保住。小窗里最容易出问题的地方,不是信息少了,而是主任务被辅助信息挤到后面。

2.2 compact 保留摘要和主操作

窗口缩到中等宽度时,页面进入 compact。这个状态还可以保留摘要和次要按钮,但不再显示完整识别内容和右侧辅助面板。

示例里 compact 的门槛是:

private readonly compactWidth: number = 620;

这个范围适合中等宽度窗口。用户还能看到标题、状态和一小段摘要,也能点主按钮和次按钮。它不像 full 那样展示完整上下文,但也没有马上退到只剩一个按钮。

我不会把所有内容一次性隐藏。窗口从宽到窄时,信息可以逐步收起来。先收右侧辅助面板,再收完整识别内容,最后才进入 minimal。这种逐步收缩的方式更容易迁移到真实项目,因为你可以把每个字段放在哪个状态里,逐个判断清楚,而不是把小窗口状态单独做成另一套页面。

这里其实有一个很实际的开发经验。很多页面一开始做响应式布局时,会直接写两个版本,一个完整版本,一个小窗版本。短期看确实快,但字段一多,两个版本就会开始不一致。比如 full 里改了一个字段名,minimal 忘了同步;full 里加了一个状态,compact 里还没有。用状态控制同一张卡片的显示内容,会更容易维护。

2.3 minimal 只留下当前动作

当窗口继续缩小,页面进入 minimal。这个状态只保留标题、状态和主按钮,其他内容都先隐藏。

private getLayoutMode(): string {
  const width = this.getEffectiveWidth();

  if (width >= this.fullWidth) {
    return 'full';
  }

  if (width >= this.compactWidth) {
    return 'compact';
  }

  return 'minimal';
}

我没有给 minimal 再拆更多子状态。悬浮窗已经是一个很小的空间,如果再继续做很多细分,代码会变复杂,用户看到的变化也不一定有价值。更实际的处理方式,是给 minimal 一个明确边界。标题、状态、主操作留下,摘要、元信息、次操作、辅助面板收起。

这张三档状态图适合放在这里,因为它能把设计取舍一次性展示出来。full 不是默认状态,minimal 也不是低配状态,它们只是同一个页面在不同窗口宽度下承担不同任务。读者看完图,再看后面的代码判断,会更容易理解为什么要有 getLayoutMode() 这个函数。

在真实项目里,我会把这种状态拆分写成页面级规则,而不是分散到每个字段旁边。字段越来越多以后,只有先确定 full、compact、minimal 各自承担什么任务,后面加字段才不会破坏小窗口里的主操作。

三、显示规则要落到组件里

3.1 同一张卡片逐步收起内容

真正落到代码里,页面需要根据状态决定哪些内容显示。这个示例里,我没有把 fullcompactminimal 写成三套完全不同的页面,而是让同一张主卡片根据当前状态收缩内容。

摘要只在非 minimal 状态下显示:

if (!this.isMinimal()) {
  Text('识别到物业费缴纳截止日期、金额明细和办理地点,建议保存为待办提醒。')
}

完整识别内容和元信息只在 full 状态下显示:

if (this.isFull()) {
  // 元信息
  // 识别内容
  // 右侧辅助面板
}

这个写法的好处是状态关系比较集中。full 展示完整信息,compact 保留摘要和主操作,minimal 只保留处理入口。后续要改某个状态的显示内容,可以直接回到这些判断里调整。

真实项目里也适合按这个思路拆。字段本身不需要变,变化的是每个窗口状态下展示哪些字段。这样不会为了悬浮窗单独维护另一套数据结构,也不会让业务字段在多个页面里重复一遍。

我会把这个规则理解成“同一件事在不同窗口里显示不同层级”。用户处理的是同一条记录,状态也还是同一个状态,只是窗口变小以后,页面先把完成任务必须的信息放出来。这样写出来的代码,后面遇到分屏、自由窗口、悬浮窗时更容易统一。

3.2 主按钮要尽量提前出现

悬浮窗里最容易出问题的地方是主按钮。全屏里按钮放在内容下面没有太大问题,因为用户有足够空间从上往下读。小窗口里如果仍然把按钮排在一堆说明后面,用户就要先滚动再操作。

示例里,minimal 状态下按钮文案也会缩短:

Button(this.isMinimal() ? '处理' : '保存为待办提醒')

完整窗口里按钮可以写得完整一些,让用户知道动作含义;小窗口里按钮文字要短,卡片高度也要控制,不然主操作仍然会被压到下方。

这里的取舍很现实。悬浮窗里宁愿少放一点说明文字,也要让按钮直接出现在用户能看到的位置。完整说明可以等窗口变大后再展示,主动作不能一直藏在下面。下面这张图可以用来解释信息如何从完整卡片收缩到 minimal 卡片,读者看图时重点关注保留项,而不是关注视觉样式。

这张图也能帮助后面做项目迁移。你在真实项目里可以把每个页面的字段按这张图重新分层,标题、状态、主按钮放在最小集合里,摘要和元信息放在中间集合里,完整说明和辅助入口放在宽窗口里。这样写出来的页面,不会因为进入小窗就失去主任务。

四、直观感受运行结果

4.1 开启悬浮窗

页面能不能进入悬浮窗,不能只靠 ArkUI 里的布局代码。布局代码解决的是窗口变小以后页面怎么显示,工程配置解决的是应用是否允许进入分屏、悬浮窗这类窗口模式。这个地方如果没有提前配好,后面写再多 fullcompactminimal 判断,也只能在预览器里模拟宽度变化,没法真正验证小窗场景。

我一般会先打开 entry/src/main/module.json5,在 abilities 里的 EntryAbility 配置中确认 supportWindowMode。这个字段用来声明当前 UIAbility 支持哪些窗口模式,常用的三个值分别是 fullscreensplitfloating。其中 floating 对应自由悬浮窗口,split 对应分屏窗口,fullscreen 对应全屏窗口。这个字段应配置在 module.json5abilities 节点下。

可以参考下面这种写法,保留你项目里原有的 namesrcEntryiconlabel 等配置,只补充或检查 supportWindowMode 这一项:

{
  "module": {
    "abilities": [
      {
        "name": "EntryAbility",
        "srcEntry": "./ets/entryability/EntryAbility.ets",
        "description": "$string:EntryAbility_desc",
        "icon": "$media:layered_image",
        "label": "$string:EntryAbility_label",
        "startWindowIcon": "$media:startIcon",
        "startWindowBackground": "$color:start_window_background",
        "exported": true,
        "supportWindowMode": [
          "fullscreen",
          "split",
          "floating"
        ]
      }
    ]
  }
}

如果你的工程已经有 supportWindowMode,先不要整段替换,只检查里面有没有 floating。有些项目一开始只配了 fullscreen,页面全屏运行没有问题,但进入悬浮窗或分屏时就缺少入口。这里把 splitfloating 一起打开,后面测试分屏降级和悬浮窗极简界面时会方便一些。

我还会顺手检查 deviceTypes。如果这个页面本来就要覆盖 Pura X Max、平板、2in1 这类设备,模块里不要只保留 phone。常见写法会包含:

"deviceTypes": [
  "phone",
  "tablet",
  "2in1"
]

配置完成以后,再回到页面里处理 onAreaChange、窗口宽度判断和 minimal 状态。也就是说,module.json5 负责让应用具备进入悬浮窗的条件,页面代码负责在窗口真的变小以后,把标题、状态和主按钮保留下来。这样调试时就不会把工程配置问题误判成 ArkUI 布局问题。

4.2 查看运行结果

我这里提供了“完整”“紧凑”“极简”三个演示按钮,方便在同一台模拟器里观察窗口变窄后的变化。真实项目里不需要这些按钮,页面会根据实际窗口宽度自动切换。

我建议先分别看三种状态,因为悬浮窗适配真正要表达的是信息如何逐步收缩。完整模式里上下文更多,紧凑模式里开始减少辅助内容,极简模式里只保留当前任务所需的信息。

完整模式下,页面显示主卡片和右侧辅助面板,摘要、元信息、识别内容和多个操作入口都会出现。这个状态适合全屏或较宽窗口,用户可以看到完整上下文。

紧凑模式下,右侧辅助面板和完整识别内容被收起,主卡片仍然保留摘要、主按钮和少量次要操作。这个状态适合中等宽度窗口,它还允许用户了解大致内容,但不会把完整识别信息全部展示出来。

极简模式下,页面只剩一个小卡片。标题、状态和主按钮保留,摘要、元信息、次按钮、辅助面板都被隐藏。这个状态更接近悬浮窗里的临时处理界面,用户看到标题以后就可以直接点主按钮,不需要在小窗口里找入口。

五、如何实际放在自己项目中

5.1 演示宽度要删掉

代码里使用了 previewWidth 和顶部演示按钮,方便在同一个模拟器里观察 fullcompactminimal 三种状态。真实项目里不需要这些按钮,页面应该直接读取真实窗口宽度。

示例里的写法是:

private getEffectiveWidth(): number {
  if (this.previewWidth > 0) {
    return this.previewWidth;
  }

  return this.pageWidth;
}

迁回真实项目时,可以改成:

private getEffectiveWidth(): number {
  return this.pageWidth;
}

页面宽度仍然可以通过 onAreaChange 写入 pageWidth。这里记录的是页面区域宽度,不是设备型号,也不是设备方向。对悬浮窗来说,这个差别很重要,因为同一台设备上,窗口可以从完整展开态变成一个很小的浮动窗口。

5.2 minimal 不适合承载表单

minimal 状态适合快速处理,不适合复杂编辑。

像提醒确认、待办处理、计时暂停、整理结果保存,这些动作都可以用 minimal 承接。用户知道当前对象是什么,也能点主按钮完成处理。

如果页面是长表单、复杂编辑、图片裁剪、长文阅读,我不会强行塞进 minimal 状态。小窗口里可以保留标题和一个“打开完整页面”的入口,让用户进入完整页面继续处理。悬浮窗只是临时入口,不适合承担所有业务流程。

这个边界在真实项目里要提前想清楚。不是每个页面都应该有完整的 minimal 交互,有些页面在悬浮窗里只适合展示状态,有些页面可以提供一个快捷按钮,有些页面则应该提示用户回到完整窗口。适配不是把所有能力压缩到小窗里,而是判断小窗到底能承担哪一步。

5.3 辅助信息要有退路

摘要、来源、时间、标签、识别原文这些内容被隐藏后,不能就此消失。窗口变宽时,它们要重新出现;用户点击查看详情时,也应该能进入完整页面。

所以 minimal 不是删除信息,而是在小窗口里暂时收起。真实项目里,我会把字段分层写清楚:哪些字段在 minimal 显示,哪些字段在 compact 显示,哪些字段只在 full 或详情页里显示。这样后续加字段时,不会每次都把小窗口重新撑满。

我还会把这类规则尽量放到同一个页面配置里,而不是散落在每个组件的 if 分支里。字段越来越多以后,如果没有统一规则,最容易出现的情况就是有人在卡片里顺手加一个字段,小窗口高度又被撑起来,主按钮又被压到后面。

总结

悬浮窗里的页面不适合复刻全屏详情页。窗口缩到小尺寸以后,用户更可能是在临时处理一件事,而不是完整阅读所有信息。标题、状态和主按钮要留在最前面,摘要、元信息、识别内容和次级入口可以随着窗口变宽再出现。

我处理这类页面时,会把 fullcompactminimal 当成三个不同的信息层级。full 承载完整上下文,compact 保留摘要和主操作,minimal 只留下完成当前动作所需的内容。这样写不会把悬浮窗变成缩小版详情页,也不会让用户在小窗口里到处找按钮。

附:完整代码

interface InfoItem {
  id: number;
  label: string;
  value: string;
}

@Entry
@Component
struct Index {
  // 页面真实宽度,由 onAreaChange 写入
  @State private pageWidth: number = 0;

  // 演示宽度,只用于在同一个模拟器里切换 full / compact / minimal 三种状态
  @State private previewWidth: number = 0;

  // 模拟操作次数,用来观察 minimal 状态下主操作是否正常保留
  @State private doneCount: number = 0;

  // compactWidth 以下进入 minimal,fullWidth 以上显示完整内容和辅助面板
  private readonly compactWidth: number = 620;
  private readonly fullWidth: number = 840;

  private readonly infoItems: InfoItem[] = [
    {
      id: 1,
      label: '来源',
      value: '拍照整理'
    },
    {
      id: 2,
      label: '时间',
      value: '09:20'
    },
    {
      id: 3,
      label: '类型',
      value: '通知'
    },
    {
      id: 4,
      label: '优先级',
      value: '高'
    }
  ];

  // Demo 中优先使用演示宽度,真实项目里可以直接返回 pageWidth
  private getEffectiveWidth(): number {
    if (this.previewWidth > 0) {
      return this.previewWidth;
    }

    return this.pageWidth;
  }

  // 把三档状态集中在一个函数里,方便后续统一调整阈值
  private getLayoutMode(): string {
    const width = this.getEffectiveWidth();

    if (width >= this.fullWidth) {
      return 'full';
    }

    if (width >= this.compactWidth) {
      return 'compact';
    }

    return 'minimal';
  }

  private isFull(): boolean {
    return this.getLayoutMode() === 'full';
  }

  private isCompact(): boolean {
    return this.getLayoutMode() === 'compact';
  }

  private isMinimal(): boolean {
    return this.getLayoutMode() === 'minimal';
  }

  private getContentWidth(): Length {
    if (this.previewWidth > 0) {
      return this.previewWidth;
    }

    return '100%';
  }

  private getPagePadding(): number {
    if (this.isFull()) {
      return 24;
    }

    if (this.isCompact()) {
      return 16;
    }

    return 10;
  }

  private getTitleSize(): number {
    if (this.isFull()) {
      return 28;
    }

    if (this.isCompact()) {
      return 23;
    }

    return 18;
  }

  private getCardRadius(): number {
    if (this.isMinimal()) {
      return 16;
    }

    return 22;
  }

  private getModeText(): string {
    if (this.isFull()) {
      return 'full · 完整模式';
    }

    if (this.isCompact()) {
      return 'compact · 紧凑模式';
    }

    return 'minimal · 最小可用';
  }

  private getModeDesc(): string {
    if (this.isFull()) {
      return '完整窗口下展示摘要、元信息、识别内容和辅助面板。';
    }

    if (this.isCompact()) {
      return '中等窗口下保留摘要和主操作,收起完整识别内容。';
    }

    return '小窗口下只保留标题、状态和主按钮。';
  }

  private setPreview(width: number) {
    this.previewWidth = width;
  }

  private markDone() {
    this.doneCount += 1;
  }

  @Builder
  private PreviewButton(text: string, width: number) {
    Text(text)
      .fontSize(12)
      .fontColor(this.previewWidth === width ? '#FFFFFF' : '#2F8F83')
      .textAlign(TextAlign.Center)
      .padding({ left: 10, right: 10, top: 7, bottom: 7 })
      .backgroundColor(this.previewWidth === width ? '#2F8F83' : '#E6F4F1')
      .borderRadius(999)
      .onClick(() => {
        this.setPreview(width);
      })
  }

  @Builder
  private StatusPill(text: string) {
    Text(text)
      .fontSize(12)
      .fontColor('#B25E00')
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor('#FFF4E5')
      .borderRadius(999)
  }

  @Builder
  private HeaderPanel() {
    Column({ space: this.isMinimal() ? 6 : 10 }) {
      Row() {
        Column({ space: 4 }) {
          Text('悬浮窗下保留最小可用界面')
            .fontSize(this.getTitleSize())
            .fontWeight(FontWeight.Bold)
            .fontColor('#111827')
            .maxLines(this.isMinimal() ? 1 : 2)
            .textOverflow({ overflow: TextOverflow.Ellipsis })

          Text(this.getModeText())
            .fontSize(13)
            .fontColor('#2F8F83')
        }
        .layoutWeight(1)

        if (!this.isMinimal()) {
          Text('窗口 ' + Math.round(this.pageWidth).toString() + 'vp')
            .fontSize(12)
            .fontColor('#374151')
            .padding({ left: 10, right: 10, top: 6, bottom: 6 })
            .backgroundColor('#FFFFFF')
            .borderRadius(999)
        }
      }
      .width('100%')

      if (!this.isMinimal()) {
        Text('演示宽度:' + Math.round(this.getEffectiveWidth()).toString() + 'vp。' + this.getModeDesc())
          .fontSize(14)
          .fontColor('#6B7280')
          .lineHeight(21)
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row({ space: 8 }) {
          this.PreviewButton('自动', 0)
          this.PreviewButton('完整', 920)
          this.PreviewButton('紧凑', 680)
          this.PreviewButton('极简', 360)
        }
        .width('100%')
      } else {
        Row({ space: 6 }) {
          this.PreviewButton('自动', 0)
          this.PreviewButton('完整', 920)
          this.PreviewButton('极简', 360)
        }
        .width('100%')
      }
    }
    .width('100%')
  }

  @Builder
  private MetaPill(text: string) {
    Text(text)
      .fontSize(12)
      .fontColor('#4B5563')
      .padding({ left: 8, right: 8, top: 4, bottom: 4 })
      .backgroundColor('#F3F4F6')
      .borderRadius(999)
  }

  @Builder
  private InfoRow(item: InfoItem) {
    Row() {
      Text(item.label)
        .fontSize(13)
        .fontColor('#9CA3AF')

      Blank()

      Text(item.value)
        .fontSize(13)
        .fontColor('#374151')
        .fontWeight(FontWeight.Medium)
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .width('100%')
    .padding(12)
    .backgroundColor('#F9FAFB')
    .borderRadius(14)
  }

  @Builder
  private MainCard() {
    Column({ space: this.isMinimal() ? 10 : 14 }) {
      Row({ space: 8 }) {
        this.StatusPill('待处理')

        if (!this.isMinimal()) {
          this.MetaPill('通知')
        }

        Blank()

        if (this.doneCount > 0) {
          Text('已操作 ' + this.doneCount.toString() + ' 次')
            .fontSize(12)
            .fontColor('#2F8F83')
        }
      }
      .width('100%')

      Text('社区物业缴费提醒')
        .fontSize(this.isMinimal() ? 19 : 24)
        .fontWeight(FontWeight.Bold)
        .fontColor('#111827')
        .lineHeight(this.isMinimal() ? 25 : 31)
        .maxLines(this.isMinimal() ? 2 : 3)
        .textOverflow({ overflow: TextOverflow.Ellipsis })

      // 摘要从 compact 开始显示,minimal 状态下先让主按钮出现在用户能看到的位置
      if (!this.isMinimal()) {
        Text('识别到物业费缴纳截止日期、金额明细和办理地点,建议保存为待办提醒。')
          .fontSize(15)
          .fontColor('#4B5563')
          .lineHeight(23)
          .maxLines(this.isCompact() ? 2 : 3)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }

      // 完整识别内容只在 full 状态出现,避免小窗口承载过多阅读内容
      if (this.isFull()) {
        Column({ space: 8 }) {
          ForEach(this.infoItems, (item: InfoItem) => {
            this.InfoRow(item)
          }, (item: InfoItem) => item.id.toString())
        }
        .width('100%')

        Column({ space: 8 }) {
          Text('识别内容')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor('#111827')

          Text('本期物业服务费缴纳截止日期为 2026 年 5 月 28 日。请在截止日期前完成缴费,避免影响后续服务办理。')
            .fontSize(14)
            .fontColor('#6B7280')
            .lineHeight(22)
        }
        .width('100%')
        .padding(14)
        .backgroundColor('#F9FAFB')
        .borderRadius(16)
      }

      Button(this.isMinimal() ? '处理' : '保存为待办提醒')
        .fontSize(this.isMinimal() ? 14 : 15)
        .fontColor('#FFFFFF')
        .height(this.isMinimal() ? 38 : 44)
        .width('100%')
        .backgroundColor('#2F8F83')
        .borderRadius(this.isMinimal() ? 19 : 22)
        .onClick(() => {
          this.markDone();
        })

      if (!this.isMinimal()) {
        Row({ space: 10 }) {
          Button('稍后处理')
            .fontSize(14)
            .fontColor('#2F8F83')
            .height(40)
            .layoutWeight(1)
            .backgroundColor('#E6F4F1')
            .borderRadius(20)

          Button('查看详情')
            .fontSize(14)
            .fontColor('#4B5563')
            .height(40)
            .layoutWeight(1)
            .backgroundColor('#F3F4F6')
            .borderRadius(20)
        }
        .width('100%')
      }
    }
    .width('100%')
    .padding(this.isMinimal() ? 12 : 18)
    .backgroundColor('#FFFFFF')
    .borderRadius(this.getCardRadius())
    .shadow({
      radius: this.isMinimal() ? 8 : 12,
      color: '#12000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  private FullSidePanel() {
    Column({ space: 12 }) {
      Text('辅助信息')
        .fontSize(17)
        .fontWeight(FontWeight.Bold)
        .fontColor('#111827')

      Text('完整窗口下保留辅助入口。进入小窗口后,这些内容会收起,把空间留给当前记录和主按钮。')
        .fontSize(14)
        .fontColor('#6B7280')
        .lineHeight(22)

      Column({ space: 8 }) {
        this.InfoRow({ id: 10, label: '建议', value: '截止前一天提醒' })
        this.InfoRow({ id: 11, label: '关联', value: '日程 / 待办' })
        this.InfoRow({ id: 12, label: '状态', value: '等待确认' })
      }
      .width('100%')

      Blank()

      Button('打开完整详情')
        .fontSize(14)
        .fontColor('#2F8F83')
        .height(40)
        .width('100%')
        .backgroundColor('#E6F4F1')
        .borderRadius(20)
    }
    .width('100%')
    .height('100%')
    .padding(18)
    .backgroundColor('#FFFFFF')
    .borderRadius(24)
    .shadow({
      radius: 12,
      color: '#12000000',
      offsetX: 0,
      offsetY: 4
    })
  }

  @Builder
  private MainContent() {
    if (this.isFull()) {
      Row({ space: 16 }) {
        Column() {
          this.MainCard()
        }
        .layoutWeight(1)

        Column() {
          this.FullSidePanel()
        }
        .width(280)
      }
      .width('100%')
    } else {
      Column({ space: 12 }) {
        this.MainCard()

        if (this.isCompact()) {
          Text('当前窗口保留摘要和主操作,完整识别内容和辅助面板暂时收起。')
            .fontSize(13)
            .fontColor('#6B7280')
            .lineHeight(20)
            .padding({ left: 4, right: 4 })
        }
      }
      .width('100%')
    }
  }

  build() {
    Column() {
      Column({ space: this.isMinimal() ? 10 : 16 }) {
        this.HeaderPanel()
        this.MainContent()
      }
      .width(this.getContentWidth())
      .height('100%')
      .padding({
        left: this.getPagePadding(),
        right: this.getPagePadding(),
        top: this.isMinimal() ? 10 : 18,
        bottom: this.isMinimal() ? 10 : 16
      })
    }
    .width('100%')
    .height('100%')
    .alignItems(HorizontalAlign.Center)
    .justifyContent(this.isMinimal() ? FlexAlign.Center : FlexAlign.Start)
    .backgroundColor('#F6F7F9')
    .onAreaChange((_: Area, newValue: Area) => {
      const width = Number(newValue.width);
      if (!Number.isNaN(width) && width > 0) {
        this.pageWidth = width;
      }
    })
  }
}
Logo

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

更多推荐