前言
我在看一个整理结果页的小窗状态时,第一眼注意到的是按钮位置太靠后了。全屏状态下,这个页面看起来信息很全,标题、状态、摘要、来源、时间、标签、识别内容、处理建议、主按钮和次按钮都能放下。到了悬浮窗尺寸以后,这些内容仍然按全屏页面的顺序往下排,用户要先看完一堆说明,才能找到真正要点的那个按钮。
这个细节在 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 同一张卡片逐步收起内容
真正落到代码里,页面需要根据状态决定哪些内容显示。这个示例里,我没有把full、compact、minimal写成三套完全不同的页面,而是让同一张主卡片根据当前状态收缩内容。
摘要只在非minimal状态下显示:
if (!this.isMinimal()) { Text('识别到物业费缴纳截止日期、金额明细和办理地点,建议保存为待办提醒。') }完整识别内容和元信息只在full状态下显示:
if (this.isFull()) { // 元信息 // 识别内容 // 右侧辅助面板 }这个写法的好处是状态关系比较集中。full展示完整信息,compact保留摘要和主操作,minimal只保留处理入口。后续要改某个状态的显示内容,可以直接回到这些判断里调整。
真实项目里也适合按这个思路拆。字段本身不需要变,变化的是每个窗口状态下展示哪些字段。这样不会为了悬浮窗单独维护另一套数据结构,也不会让业务字段在多个页面里重复一遍。
我会把这个规则理解成“同一件事在不同窗口里显示不同层级”。用户处理的是同一条记录,状态也还是同一个状态,只是窗口变小以后,页面先把完成任务必须的信息放出来。这样写出来的代码,后面遇到分屏、自由窗口、悬浮窗时更容易统一。
3.2 主按钮要尽量提前出现
悬浮窗里最容易出问题的地方是主按钮。全屏里按钮放在内容下面没有太大问题,因为用户有足够空间从上往下读。小窗口里如果仍然把按钮排在一堆说明后面,用户就要先滚动再操作。
示例里,minimal状态下按钮文案也会缩短:
Button(this.isMinimal() ? '处理' : '保存为待办提醒')完整窗口里按钮可以写得完整一些,让用户知道动作含义;小窗口里按钮文字要短,卡片高度也要控制,不然主操作仍然会被压到下方。
这里的取舍很现实。悬浮窗里宁愿少放一点说明文字,也要让按钮直接出现在用户能看到的位置。完整说明可以等窗口变大后再展示,主动作不能一直藏在下面。下面这张图可以用来解释信息如何从完整卡片收缩到minimal卡片,读者看图时重点关注保留项,而不是关注视觉样式。
这张图也能帮助后面做项目迁移。你在真实项目里可以把每个页面的字段按这张图重新分层,标题、状态、主按钮放在最小集合里,摘要和元信息放在中间集合里,完整说明和辅助入口放在宽窗口里。这样写出来的页面,不会因为进入小窗就失去主任务。
四、直观感受运行结果
4.1 开启悬浮窗
页面能不能进入悬浮窗,不能只靠 ArkUI 里的布局代码。布局代码解决的是窗口变小以后页面怎么显示,工程配置解决的是应用是否允许进入分屏、悬浮窗这类窗口模式。这个地方如果没有提前配好,后面写再多full、compact、minimal判断,也只能在预览器里模拟宽度变化,没法真正验证小窗场景。
我一般会先打开entry/src/main/module.json5,在abilities里的EntryAbility配置中确认supportWindowMode。这个字段用来声明当前 UIAbility 支持哪些窗口模式,常用的三个值分别是fullscreen、split和floating。其中floating对应自由悬浮窗口,split对应分屏窗口,fullscreen对应全屏窗口。这个字段应配置在module.json5的abilities节点下。
可以参考下面这种写法,保留你项目里原有的name、srcEntry、icon、label等配置,只补充或检查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,页面全屏运行没有问题,但进入悬浮窗或分屏时就缺少入口。这里把split和floating一起打开,后面测试分屏降级和悬浮窗极简界面时会方便一些。
我还会顺手检查deviceTypes。如果这个页面本来就要覆盖 Pura X Max、平板、2in1 这类设备,模块里不要只保留phone。常见写法会包含:
"deviceTypes":["phone","tablet","2in1"]配置完成以后,再回到页面里处理onAreaChange、窗口宽度判断和minimal状态。也就是说,module.json5负责让应用具备进入悬浮窗的条件,页面代码负责在窗口真的变小以后,把标题、状态和主按钮保留下来。这样调试时就不会把工程配置问题误判成 ArkUI 布局问题。
4.2 查看运行结果
我这里提供了“完整”“紧凑”“极简”三个演示按钮,方便在同一台模拟器里观察窗口变窄后的变化。真实项目里不需要这些按钮,页面会根据实际窗口宽度自动切换。
我建议先分别看三种状态,因为悬浮窗适配真正要表达的是信息如何逐步收缩。完整模式里上下文更多,紧凑模式里开始减少辅助内容,极简模式里只保留当前任务所需的信息。
完整模式下,页面显示主卡片和右侧辅助面板,摘要、元信息、识别内容和多个操作入口都会出现。这个状态适合全屏或较宽窗口,用户可以看到完整上下文。
紧凑模式下,右侧辅助面板和完整识别内容被收起,主卡片仍然保留摘要、主按钮和少量次要操作。这个状态适合中等宽度窗口,它还允许用户了解大致内容,但不会把完整识别信息全部展示出来。
极简模式下,页面只剩一个小卡片。标题、状态和主按钮保留,摘要、元信息、次按钮、辅助面板都被隐藏。这个状态更接近悬浮窗里的临时处理界面,用户看到标题以后就可以直接点主按钮,不需要在小窗口里找入口。
五、如何实际放在自己项目中
5.1 演示宽度要删掉
代码里使用了previewWidth和顶部演示按钮,方便在同一个模拟器里观察full、compact、minimal三种状态。真实项目里不需要这些按钮,页面应该直接读取真实窗口宽度。
示例里的写法是:
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分支里。字段越来越多以后,如果没有统一规则,最容易出现的情况就是有人在卡片里顺手加一个字段,小窗口高度又被撑起来,主按钮又被压到后面。
总结
悬浮窗里的页面不适合复刻全屏详情页。窗口缩到小尺寸以后,用户更可能是在临时处理一件事,而不是完整阅读所有信息。标题、状态和主按钮要留在最前面,摘要、元信息、识别内容和次级入口可以随着窗口变宽再出现。
我处理这类页面时,会把full、compact、minimal当成三个不同的信息层级。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; } }) } }