前言
我在处理 OCR 原图查看页时,最先感到别扭的是反复切换。用户拍完一张通知、票据或者会议照片以后,通常会做两件事:一边确认原图是否清晰,一边核对识别结果是否可信。外屏状态下,图片和识别结果通过按钮切换还能接受,因为窗口宽度有限,用户一次重点处理一个区域,页面关系也比较容易理解。
到了 Pura X Max 展开态以后,这个页面继续沿用上下切换就有点浪费空间。左侧完全可以展示原图预览,右侧承接识别结果、结构化摘要和操作按钮。用户对照原图确认识别文本时,不需要在两个页面区域之间反复跳转,也不需要一会儿滑到上方观察原图,一会儿滑到下方核对识别内容。
图片预览页经常出现在这些场景里:
- OCR 原图查看
- 拍照确认页
- 票据识别结果页
- 通知截图整理页
- 会议白板拍照整理页
- 资料扫描后的文字校对页
这些页面的共同特点是,原始内容和整理结果之间存在对应关系,用户真正要完成的是对照、确认和处理。外屏只能通过切换按钮承接这个动作,而展开态已经有了并排展示的空间,页面结构就可以从单区域切换调整为左侧预览 + 右侧信息。
Pura X Max 的尺寸变化足够让图片预览页改变组织方式,但左右分栏也需要边界。图片区域要保留最小展示宽度,右侧识别面板要有固定宽度,中间还要预留间距。如果遇到空间不足,页面需要继续退回原图和识别结果的切换结构。
我这里用占位图形模拟原图区域,不依赖本地图片资源。这样放到Index.ets后就能直接运行,重点观察图片预览区和右侧识别面板在不同宽度下的布局变化。
一、原来切换方式的问题
1.1 外屏仍然适合切换
外屏下,我一般会把图片预览和识别结果处理成两个状态。顶部放一个分段按钮,用户可以在原图和识别结果之间切换。这个结构对窄窗口比较实用,因为图片本身需要高度,识别结果也需要阅读空间,左右同时展示会让两边都被压缩。
最早的结构大概会写成这样:
if (this.activePane === 'image') { this.PreviewPanel() } else { this.ResultPanel() }这个写法没有复杂的布局判断,状态也容易维护。外屏里用户点击识别结果,页面切到文本和操作按钮;再点击原图,页面回到图片预览。这个过程多了一次切换,但小屏里很难长期展示两块内容,保留切换按钮更符合外屏的空间条件。
在拍照确认、票据识别、短通知整理这些场景里,外屏上下切换可以继续保留。用户通常只是确认一次原图,再确认一次识别结果,切换成本还在可接受范围内。真正让我重新调整结构的,是展开态下仍然要求用户在两块内容之间来回切换。
1.2 但展开态会拖慢校对
我把图片预览页切到展开态后,最先观察到的是横向空间没有参与页面组织。用户查看原图时,识别结果被隐藏;用户查看识别结果时,原图又离开当前视野。遇到识别错误时,用户要重新回到原图确认,再切换回识别结果继续处理。
OCR 页面很依赖对照关系。比如原图里有日期、金额、地点,识别结果里也提取了这些字段。用户校对时需要反复确认字段和原图之间的对应关系。两块内容无法同时展示时,校对过程会变成观察原图、记住内容、切换结果、继续核对。这个过程在短文本里还能接受,遇到票据、通知、白板照片时就会让人频繁中断。
所以,在展开态里,我会把图片预览页拆成两个明确区域:
- 左侧承接原图预览,尽量保留足够大的可视区域。
- 右侧承接识别结果、结构化摘要和操作按钮。
- 窄窗口继续使用原图和结果的切换按钮。
- 宽窗口满足最小宽度后,再进入左图右信息结构。
二、图片区要保留最小宽度
2.1 左侧预览不能被压缩
展开态下增加右侧面板时,我不会只用一个宽度阈值决定分栏。图片预览页有一个很实际的前提:左侧图片区必须足够大。右侧识别面板一出现,如果左侧原图只剩一条窄区域,用户仍然看不清原图细节,左右分栏就失去了校对价值。
我给图片预览区设置了最小宽度,右侧面板固定为 320vp,中间间距为 16vp。页面进入分栏前,会先计算当前可用宽度能否放下这些区域。
private readonly previewMinWidth: number = 520; private readonly resultPanelWidth: number = 320; private readonly twoColumnGap: number = 16;这三个值可以根据真实页面继续调整。票据类图片需要展示更多细节,左侧宽度可以继续提高;右侧只展示少量识别字段时,面板宽度可以收缩到 300vp 左右;右侧如果需要加入编辑按钮、校对状态和处理建议,就要把面板宽度提升到 340vp 或 380vp。
我会优先保住图片区。OCR 校对的前提是用户能看清原图,如果左侧预览被压缩得太厉害,右侧识别结果再完整也很难核对。这个判断和普通详情页不一样,图片预览页里的原图可读性直接决定页面结构是否成立。
2.2 宽度计算放在页面层
示例里的判断集中在canUseSplit()中。
private canUseSplit(): boolean { const width = this.getEffectiveWidth(); const availableWidth = width - this.getPagePadding() * 2; const requiredWidth = this.previewMinWidth + this.resultPanelWidth + this.twoColumnGap; return width >= this.expandedThreshold && availableWidth >= requiredWidth; }这里会先扣掉页面左右 padding,再比较图片区、结果面板和中间间距的总宽度。这样可以避免一个常见情况,窗口看起来已经很宽,实际加上边距和右侧面板后,左侧图片区仍然被压缩。
我会把这个判断放在页面层。图片预览组件只负责展示原图,右侧面板只负责展示识别结果。是否进入左右分栏,由页面统一判断。后面调整右侧面板宽度,或者给图片区提高最小宽度时,不需要在两个组件里重复修改判断。
三、右侧面板承接校对内容
3.1 识别结果贴近原图展示
图片预览页里的右侧面板,我会把它当成校对区。它负责展示识别文本、结构化摘要和当前操作,让用户观察左侧原图时,右侧可以直接确认提取结果。发现识别错误时,用户不用离开当前页面,也不用重新切换区域。
示例里右侧包含三类内容:
- 识别文本行
- 结构化摘要
- 保存、重新识别、复制结果等按钮
这些内容都和原图校对有关,不会把右侧面板改造成完整详情页。右侧继续加入历史记录、附件、长说明和复杂表单时,用户会在面板里重新寻找重点,左图右信息的价值也会被削弱。
我会把右侧面板控制成结果确认区。它的任务是帮助用户判断识别内容是否可用,然后完成保存或重新识别。复杂编辑可以进入独立页面,右侧面板只承接当前校对动作。
3.2 小屏状态仍然使用切换按钮
小屏下,右侧面板没有足够空间展示,页面继续通过原图 / 识别结果按钮切换。这个状态下,用户一次只处理一块内容。为了让切换过程保留上下文,activePane放在页面层。
@State private activePane: string = 'image';点击原图或识别结果只改变展示区域,不会重置识别结果,也不会影响保存状态。等窗口宽度满足分栏条件后,页面进入左右结构,activePane不再决定主布局,但这个状态仍然保留,方便窗口缩回小屏时继续使用。
这种状态处理很适合 OCR 页面。用户可能在外屏里先查看识别结果,再展开设备继续对照原图;也可能在展开态里保存结果后,又缩回外屏继续处理。布局形态可以切换,识别结果和操作状态不能跟着丢。
四、运行页面验证差异
为了观察这个结构,我把页面压缩成一个独立示例。示例不依赖真实图片文件,左侧原图区域通过占位图形模拟通知截图,里面放了标题、金额、日期和地点等文字块;右侧识别面板展示对应识别行和结构化摘要。这样在模拟器里不需要准备图片资源,也能验证页面结构。
外屏状态下,页面顶部有原图和识别结果切换按钮。当前显示原图时,页面只展示图片预览区域;切到识别结果后,页面展示识别文本和操作按钮。
展开态状态下,页面进入左图右信息结构。左侧原图保持较大的展示区域,右侧识别结果固定显示。用户可以一边观察原图上的文字块,一边核对右侧识别结果。
五、实际项目中怎么处理
5.1 占位图替换成真实图片组件
示例里没有使用真实图片资源,而是通过 ArkUI 组件模拟原图区域。迁回真实项目时,可以把PreviewPlaceholder()替换成真实图片展示组件,图片来源可以是相册、拍照后的沙箱路径、OCR 原图路径或者处理后的压缩图。
真实图片预览还要继续处理缩放、长图、旋转和清晰度。如果是票据、通知、白板这类图片,用户可能需要放大观察局部文字。示例里的占位图只用于验证布局结构,真实项目里还需要补充图片缩放、双击查看、局部放大和查看原图等交互。
5.2 图片和识别结果使用同一份数据
图片预览和识别结果属于同一条记录的两个视角。真实项目里,不建议图片区域维护一套状态,识别结果区域再维护一套状态。可以把当前材料、图片路径、识别文本、结构化摘要和保存状态都放在页面层或数据服务层,再让图片区域和右侧面板分别读取这份数据。
这样窗口从外屏切到展开态时,页面不会重新识别,也不会丢掉用户刚刚修改过的结果。图片和识别结果只是同一条记录的不同展示区域,布局可以切换,数据不要拆成两份。
5.3 右侧面板控制在轻量校对范围
右侧面板适合放识别结果和轻量操作。比如保存结果、重新识别、复制文本、加入待办,这些动作都适合放在右侧。复杂校对、逐字段编辑、长文本修订、图片裁剪,最好进入完整页面。
如果右侧面板继续增加内容,左侧原图也会被挤压,用户反而看不清预览。图片预览页需要保留原图和识别结果之间的对照关系。右侧面板完成校对和处理动作就够了,完整编辑流程可以交给详情页或专门的编辑页。
总结
图片预览页在 Pura X Max 展开态里,可以把原图和识别结果放到同一屏。外屏下继续使用原图 / 识别结果切换,避免左右区域互相挤压;展开态里使用左图右信息结构,让用户可以直接对照原图和识别文本,减少区域切换带来的中断。
我后面处理这类 OCR 预览页时,会先确认几件事:
- 原图区域要有最小宽度,避免被右侧面板压缩到看不清。
- 识别结果适合在右侧展示,方便用户对照原图。
- 小屏继续使用切换按钮,不强行左右分栏。
- 右侧只放识别文本、摘要和轻量操作,复杂编辑进入独立页面。
- 图片路径、识别结果和保存状态放在同一套数据里,布局切换时继续保留。
放到实际项目里,占位图要替换成真实图片组件,右侧识别面板要接入 OCR 结果和结构化摘要。页面真正要保留的是原图和结果之间的对照关系。窗口宽度足够时,让它们并排出现;窗口宽度不足时,继续使用上下切换,让用户一次处理一个区域。
完整代码
interface RecognitionLine { id: number; label: string; text: string; confidence: string; } interface SummaryItem { id: number; label: string; value: string; } @Entry @Component struct Index { // 页面真实宽度,由 onAreaChange 写入 @State private pageWidth: number = 0; // 演示宽度覆盖真实窗口宽度,方便在同一台模拟器里观察外屏和展开态 @State private previewWidth: number = 0; // 小屏状态下用于切换原图和识别结果 @State private activePane: string = 'image'; // 模拟保存次数,用来验证布局切换后操作状态是否保留 @State private saveCount: number = 0; private readonly expandedThreshold: number = 860; private readonly previewMinWidth: number = 520; private readonly resultPanelWidth: number = 320; private readonly twoColumnGap: number = 16; private readonly recognitionLines: RecognitionLine[] = [ { id: 1, label: '标题', text: '社区物业缴费提醒', confidence: '98%' }, { id: 2, label: '金额', text: '¥ 680.00', confidence: '96%' }, { id: 3, label: '截止日期', text: '2026 年 5 月 28 日', confidence: '97%' }, { id: 4, label: '地点', text: '社区物业服务中心一楼', confidence: '92%' } ]; private readonly summaryItems: SummaryItem[] = [ { id: 1, label: '材料类型', value: '缴费通知' }, { id: 2, label: '建议动作', value: '保存为待办提醒' }, { id: 3, label: '来源', value: '拍照整理' } ]; private getEffectiveWidth(): number { if (this.previewWidth > 0) { return this.previewWidth; } return this.pageWidth; } private getPagePadding(): number { if (this.getEffectiveWidth() >= this.expandedThreshold) { return 24; } return 16; } // 分栏前先确认左侧图片区、右侧识别面板和中间间距都能放下 private canUseSplit(): boolean { const width = this.getEffectiveWidth(); const availableWidth = width - this.getPagePadding() * 2; const requiredWidth = this.previewMinWidth + this.resultPanelWidth + this.twoColumnGap; return width >= this.expandedThreshold && availableWidth >= requiredWidth; } private isExpanded(): boolean { return this.canUseSplit(); } private getContentWidth(): Length { if (this.previewWidth > 0) { return this.previewWidth; } return '100%'; } private getTitleSize(): number { return this.isExpanded() ? 28 : 23; } private getModeText(): string { return this.isExpanded() ? 'expanded · 左图右信息' : 'compact · 图片/结果切换'; } private getModeDesc(): string { if (this.isExpanded()) { return '展开态下原图展示在左侧,识别结果和操作区固定在右侧。'; } return '小屏下原图和识别结果通过按钮切换,避免左右区域互相挤压。'; } private setPreview(width: number) { this.previewWidth = width; } private saveResult() { this.saveCount += 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 PaneButton(text: string, key: string) { Text(text) .fontSize(13) .fontColor(this.activePane === key ? '#FFFFFF' : '#2F8F83') .textAlign(TextAlign.Center) .layoutWeight(1) .height(36) .backgroundColor(this.activePane === key ? '#2F8F83' : '#E6F4F1') .borderRadius(18) .onClick(() => { this.activePane = key; }) } @Builder private HeaderPanel() { Column({ space: 10 }) { Row({ space: 10 }) { Column({ space: 4 }) { Text('图片预览页增加右侧信息面板') .fontSize(this.getTitleSize()) .fontWeight(FontWeight.Bold) .fontColor('#111827') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.getModeText()) .fontSize(14) .fontColor('#2F8F83') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .layoutWeight(1) 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%') 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('外屏', 430) this.PreviewButton('展开态', 1040) } .width('100%') } .width('100%') } @Builder private FakeDocumentLine(text: string, width: Length, bgColor: string) { Text(text) .fontSize(13) .fontColor('#374151') .height(30) .width(width) .padding({ left: 10, right: 10 }) .backgroundColor(bgColor) .borderRadius(8) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } @Builder private FakeParagraphLine(width: Length) { Rect() .width(width) .height(8) .radiusWidth(4) .radiusHeight(4) .fill('#E5E7EB') } @Builder private FakeDocumentBody() { Column({ space: 18 }) { Text('社区物业缴费提醒') .fontSize(24) .fontWeight(FontWeight.Bold) .fontColor('#111827') .width('100%') .textAlign(TextAlign.Center) Column({ space: 12 }) { this.FakeDocumentLine('缴费金额:¥ 680.00', '78%', '#FFF7ED') this.FakeDocumentLine('截止日期:2026 年 5 月 28 日', '92%', '#EFF6FF') this.FakeDocumentLine('办理地点:社区物业服务中心一楼', '86%', '#F0FDF4') } .width('100%') .alignItems(HorizontalAlign.Center) Column({ space: 10 }) { Text('通知内容') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#111827') this.FakeDocumentLine('请在截止日期前完成缴费。', '100%', '#F9FAFB') this.FakeDocumentLine('逾期可能影响后续服务办理。', '88%', '#F9FAFB') this.FakeDocumentLine('如已缴费,请忽略本提醒。', '72%', '#F9FAFB') Column({ space: 8 }) { this.FakeParagraphLine('96%') this.FakeParagraphLine('88%') this.FakeParagraphLine('92%') this.FakeParagraphLine('76%') this.FakeParagraphLine('84%') this.FakeParagraphLine('64%') } .width('100%') .padding({ top: 4 }) } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .borderRadius(18) .border({ width: 1, color: '#E5E7EB' }) Column({ space: 10 }) { Text('底部说明') .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor('#111827') this.FakeDocumentLine('业务办理时间:工作日 09:00 - 17:30', '100%', '#F9FAFB') this.FakeDocumentLine('咨询电话:物业服务中心前台', '86%', '#F9FAFB') this.FakeDocumentLine('请携带业主卡或身份证件办理。', '92%', '#F9FAFB') } .width('100%') .padding(16) .backgroundColor('#FFFFFF') .borderRadius(18) .border({ width: 1, color: '#E5E7EB' }) } .width('100%') .padding(22) .backgroundColor('#FDF7ED') .borderRadius(24) .border({ width: 1, color: '#F3E4C8' }) } @Builder private PreviewPlaceholder() { Column({ space: 14 }) { Row() { Text('OCR 原图占位') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#111827') Blank() Text('可滑动预览') .fontSize(12) .fontColor('#2F8F83') .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor('#E6F4F1') .borderRadius(999) } .width('100%') .flexShrink(0) // 原图内容可能比可视区域更高,所以这里让原图区域自己滚动。 // 左右分栏时,左侧卡片不会再裁掉底部内容。 Scroll() { Column() { this.FakeDocumentBody() } .width('100%') .padding({ bottom: 8 }) } .width('100%') .layoutWeight(1) .edgeEffect(EdgeEffect.Spring) } .width('100%') .height('100%') .padding(18) .backgroundColor('#FFFFFF') .borderRadius(26) .shadow({ radius: 12, color: '#12000000', offsetX: 0, offsetY: 4 }) } @Builder private RecognitionRow(item: RecognitionLine) { Column({ space: 6 }) { Row() { Text(item.label) .fontSize(12) .fontColor('#9CA3AF') Blank() Text(item.confidence) .fontSize(12) .fontColor('#2F8F83') } .width('100%') Text(item.text) .fontSize(14) .fontColor('#111827') .lineHeight(21) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') .padding(12) .backgroundColor('#F7F8FA') .borderRadius(16) } @Builder private SummaryRow(item: SummaryItem) { Row() { Text(item.label) .fontSize(12) .fontColor('#9CA3AF') .width(72) .flexShrink(0) Text(item.value) .fontSize(13) .fontColor('#374151') .layoutWeight(1) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') } @Builder private ResultPanel() { Column({ space: 14 }) { Row() { Column({ space: 4 }) { Text('识别结果') .fontSize(20) .fontWeight(FontWeight.Bold) .fontColor('#111827') Text('和左侧原图对应校对') .fontSize(13) .fontColor('#6B7280') } .layoutWeight(1) } .width('100%') .flexShrink(0) Scroll() { Column({ space: 12 }) { Column({ space: 10 }) { ForEach(this.recognitionLines, (item: RecognitionLine) => { this.RecognitionRow(item) }, (item: RecognitionLine) => item.id.toString()) } .width('100%') Column({ space: 10 }) { Text('结构化摘要') .fontSize(16) .fontWeight(FontWeight.Medium) .fontColor('#111827') ForEach(this.summaryItems, (item: SummaryItem) => { this.SummaryRow(item) }, (item: SummaryItem) => item.id.toString()) } .width('100%') .padding(14) .backgroundColor('#F3F8F7') .borderRadius(18) Text('识别到物业费缴纳金额、截止日期和办理地点,建议保存为待办提醒,并在截止日前一天通知。') .fontSize(14) .fontColor('#6B7280') .lineHeight(22) .maxLines(4) .textOverflow({ overflow: TextOverflow.Ellipsis }) .padding(14) .backgroundColor('#F7F8FA') .borderRadius(18) } .width('100%') .padding({ bottom: 8 }) } .layoutWeight(1) .width('100%') .edgeEffect(EdgeEffect.Spring) Button('保存识别结果') .height(44) .width('100%') .fontSize(15) .fontColor('#FFFFFF') .backgroundColor('#2F8F83') .borderRadius(22) .flexShrink(0) .onClick(() => { this.saveResult(); }) Row({ space: 10 }) { Button('重新识别') .height(40) .layoutWeight(1) .fontSize(14) .fontColor('#2F8F83') .backgroundColor('#E6F4F1') .borderRadius(20) Button('复制文本') .height(40) .layoutWeight(1) .fontSize(14) .fontColor('#4B5563') .backgroundColor('#F3F4F6') .borderRadius(20) } .width('100%') .flexShrink(0) Text('已保存 ' + this.saveCount.toString() + ' 次') .fontSize(12) .fontColor('#6B7280') .width('100%') .textAlign(TextAlign.Center) .flexShrink(0) } .width('100%') .height('100%') .padding(18) .backgroundColor('#FFFFFF') .borderRadius(26) .shadow({ radius: 12, color: '#10000000', offsetX: 0, offsetY: 4 }) } @Builder private CompactSwitch() { Row({ space: 8 }) { this.PaneButton('原图', 'image') this.PaneButton('识别结果', 'result') } .width('100%') .padding(4) .backgroundColor('#FFFFFF') .borderRadius(22) .flexShrink(0) } @Builder private MainContent() { if (this.isExpanded()) { Row({ space: this.twoColumnGap }) { Column() { this.PreviewPlaceholder() } .layoutWeight(1) .height('100%') Column() { this.ResultPanel() } .width(this.resultPanelWidth) .height('100%') .flexShrink(0) } .width('100%') .height('100%') .alignItems(VerticalAlign.Top) } else { Column({ space: 14 }) { this.CompactSwitch() Column() { if (this.activePane === 'image') { this.PreviewPlaceholder() } else { this.ResultPanel() } } .layoutWeight(1) .width('100%') } .width('100%') .height('100%') } } build() { Column() { Column({ space: 16 }) { this.HeaderPanel() Column() { this.MainContent() } .layoutWeight(1) .width('100%') } .width(this.getContentWidth()) .height('100%') .padding({ left: this.getPagePadding(), right: this.getPagePadding(), top: 18, bottom: 16 }) } .width('100%') .height('100%') .alignItems(HorizontalAlign.Center) .backgroundColor('#F6F7F9') .onAreaChange((_: Area, newValue: Area) => { const width = Number(newValue.width); if (!Number.isNaN(width) && width > 0) { this.pageWidth = width; } }) } }