第76篇 | HarmonyOS 保险箱详情页:私密照片如何浏览、恢复和导出
第 76 篇讲保险箱详情页。私密照片解锁后不能只显示一个列表,用户还需要像普通相册一样查看前后镜头、滑动浏览、恢复公开相册、导出到系统相册或再次锁定。区别在于这些动作都必须在保险箱上下文里完成。
这一篇从详情查看器、选中记录、私密帧列表、恢复/导出按钮和锁定按钮五个角度拆代码。重点是理解:保险箱复用了普通相册的数据和预览能力,但它在入口、状态和操作上加了更严格的边界。
本篇目标
- 理解保险箱详情页为什么要先检查
vaultUnlocked。 - 掌握
getFeaturedVaultRecord和getFeaturedVaultFrames如何支撑预览。 - 理解私密照片恢复、导出、系统分享和锁定按钮的状态条件。
- 学会让详情查看器复用普通相册帧数据,同时保留保险箱边界。
对应源码位置
superImage/entry/src/main/ets/pages/Index.ets
解锁后才进入私密照片详情
保险箱详情页的体验和普通相册相似:全屏黑底、可滑动查看前后镜头、顶部返回和页码、底部标题地点信息。但进入这个页面之前,用户必须先完成本地认证。
这种设计减少了重复开发。普通相册已经有前后镜头帧的组织方式,保险箱详情只需要在入口处做解锁校验,在页面表现上继续复用图像预览能力。隐私边界放在状态和入口上,而不是重写一套图片查看逻辑。
保险箱详情页在解锁后展示私密照片浏览能力
打开详情前先检查解锁状态
openVaultRecordViewer的第一行就是if (!this.vaultUnlocked) return。这行代码把入口挡在最前面:不管哪个 UI 元素误触发了打开详情,只要保险箱未解锁,就不会显示私密照片。
getFeaturedVaultRecord通过选中 id 找当前记录,找不到时回退到第一条私密记录;getFeaturedVaultFrames复用普通相册的getGalleryDetailFrames。这样保险箱既有自己的入口控制,又不用重复维护前后镜头帧构造逻辑。
openVaultRecordViewer 在未解锁时直接返回
private getFeaturedVaultRecord(): GalleryMoment | undefined { const vaultRecords = this.getVaultRecords(); const selected = vaultRecords.find((record: GalleryMoment) => record.id === this.vaultSelectedId); return selected ?? vaultRecords[0]; } private selectVaultRecord(recordId: string): void { this.vaultSelectedId = recordId; } private openVaultRecordViewer(recordId: string): void { if (!this.vaultUnlocked) { return; } this.vaultSelectedId = recordId; this.vaultDetailPhotoIndex = 0; this.vaultDetailVisible = true; } private closeVaultRecordViewer(): void { this.vaultDetailVisible = false; this.vaultDetailPhotoIndex = 0; } private getFeaturedVaultFrames(): Array<MediaPreviewFrame> { const record = this.getFeaturedVaultRecord(); if (!record) { return []; } return this.getGalleryDetailFrames(record); } private getVaultPreviewRecords(): Array<GalleryMoment> { const featuredRecord = this.getFeaturedVaultRecord(); if (!featuredRecord) { return []; } return this.getVaultRecords() .filter((record: GalleryMoment) => record.id !== featuredRecord.id) .slice(0, 3);详情查看器复用 Swiper 浏览双镜帧
buildVaultDetailViewer使用Swiper遍历getFeaturedVaultFrames。每一帧先铺一层低透明度背景图,再用 contain 模式展示主体图,这样竖图、横图和双镜照片都能在黑底里保持可看性。
顶部的返回按钮和页码、底部的记忆标题和地点时间都来自当前记录。这个实现说明保险箱详情不是简单的图片弹窗,而是保留了双镜记忆的上下文信息。用户知道自己正在看哪一组私密记忆,也能在多帧之间切换。
buildVaultDetailViewer 使用 Swiper 展示私密照片帧
private buildVaultDetailViewer() { if (this.vaultDetailVisible && this.getFeaturedVaultRecord() && this.getFeaturedVaultFrames().length > 0) { Stack({ alignContent: Alignment.TopStart }) { Column() .width('100%') .height('100%') .backgroundColor('#000000') Swiper() { ForEach(this.getFeaturedVaultFrames(), (frame: MediaPreviewFrame) => { Stack({ alignContent: Alignment.Center }) { Image(frame.uri) .width('100%') .height('100%') .objectFit(ImageFit.Cover) .opacity(0.34) Image(frame.uri) .width('100%') .height('74%') .objectFit(ImageFit.Contain) .align(Alignment.Top) } .width('100%') .height('100%') .backgroundColor('#000000') }, (frame: MediaPreviewFrame) => `vault_${frame.id}`) } .width('100%') .height('100%') .index(this.vaultDetailPhotoIndex) .autoPlay(false) .loop(this.getFeaturedVaultFrames().length > 1) .indicator(this.getFeaturedVaultFrames().length > 1) .onChange((index: number) => { this.vaultDetailPhotoIndex = index; }) Column() { Row({ space: 12 }) { Button('\u8fd4\u56de') .height(40) .fontSize(13) .fontWeight(FontWeight.Medium) .fontColor('#FFF7E6') .backgroundColor('#80111317') .borderRadius(18) .onClick(() => { this.closeVaultRecordViewer(); }) Blank() if (this.getFeaturedVaultFrames().length > 1) { Text(`${this.vaultDetailPhotoIndex + 1}/${this.getFeaturedVaultFrames().length}`) .fontSize(12) .fontColor('#FFF7E6') .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .backgroundColor('#80111317') .borderRadius(16) } } .width('100%') .padding({ left: 16, right: 16, top: this.getPageTopPadding(18) }) Blank() Column({ space: 5 }) { Text(this.getCompactMemoryTitle( (this.getFeaturedVaultRecord() as GalleryMoment).memoryTitle, (this.getFeaturedVaultRecord() as GalleryMoment).place )) .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor('#FFF7E6') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(`${(this.getFeaturedVaultRecord() as GalleryMoment).place} / ${(this.getFeaturedVaultRecord() as GalleryMoment).createdLabel}`) .fontSize(12) .fontColor('#D8CBB2') .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') .padding(14) .backgroundColor('#D8111317') .borderRadius(22) .margin({ left: 16, right: 16, bottom: this.getPageBottomPadding(18) }) .alignItems(HorizontalAlign.Start) } .width('100%') .height('100%') } .width('100%') .height('100%') .backgroundColor('#000000') } }锁定态和解锁态是两个 UI 分支
buildEnhancedVaultTab里先判断是否有私密照片,再判断vaultUnlocked和当前记录。未解锁时显示认证按钮;解锁后才显示私密照片数量、马赛克网格和操作按钮。这比在每个按钮上单独隐藏更清晰。
状态分支写清楚后,后续维护会轻松很多。新增一个私密照片操作时,只需要放在解锁分支里,并根据 busy 状态控制按钮可用性。未解锁分支始终保持认证路径,不会意外露出私密内容。
保险箱未解锁时只显示认证和导入入口
if (this.getVaultRecords().length === 0) { Column({ space: 12 }) { Text('还没有私密照片') .fontSize(18) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.ml_on_surface')) Button(this.mediaImportBusy ? '导入中...' : '导入系统相册') .height(42) .width('100%') .enabled(!this.mediaImportBusy) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getWarmActionTextColor()) .backgroundColor(this.getWarmActionBackgroundColor()) .borderRadius(18) .onClick(() => { void this.importSystemAlbumPhotos('vault'); }) Button('去相册选择') .height(42) .width('100%') .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getSecondaryActionTextColor()) .backgroundColor(this.getSecondaryActionBackgroundColor()) .borderRadius(18) .onClick(() => { this.switchTab('gallery'); }) } .width('100%') .padding(18) .backgroundColor($r('app.color.ml_panel_glass')) .borderRadius(28) .alignItems(HorizontalAlign.Start) } else if (!this.vaultUnlocked || !this.getFeaturedVaultRecord()) { Column({ space: 18 }) { Stack({ alignContent: Alignment.Center }) { Circle() .width(118) .height(118) .fill('#263542') .stroke('#E9B65E') .strokeWidth(1) Circle() .width(82) .height(82) .fill('#050809') .stroke('#FFB86B') .strokeWidth(2) Text('锁') .fontSize(28) .fontWeight(FontWeight.Bold) .fontColor('#FFF1D2') } .width(128) .height(128) .shadow({ radius: 30, color: '#6619B8C7', offsetX: 0, offsetY: 0 }) Text('打开保险箱查看私密照片') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.ml_on_surface')) .textAlign(TextAlign.Center) Text('查看私密内容需要验证身份') .fontSize(13) .lineHeight(20) .fontColor($r('app.color.ml_on_surface_variant')) .textAlign(TextAlign.Center) Button(this.vaultAuthBusy ? '认证中...' : '解锁保险箱') .height(48) .width('100%') .enabled(!this.vaultAuthBusy) .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor(this.getWarmActionTextColor()) .backgroundColor(this.getWarmActionBackgroundColor()) .borderRadius(24) .onClick(() => { void this.unlockVaultWithFace(); }) Button(this.mediaImportBusy ? '导入中...' : '导入系统相册') .height(42) .width('100%') .enabled(!this.mediaImportBusy && !this.vaultAuthBusy) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getSecondaryActionTextColor()) .backgroundColor(this.getSecondaryActionBackgroundColor()) .borderRadius(18) .onClick(() => { void this.importSystemAlbumPhotos('vault'); }) Row({ space: 12 }) { Text('人脸识别') .fontSize(12) .fontColor($r('app.color.ml_on_surface')) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(this.getDarkChipBackgroundColor()) .borderRadius(16) .onClick(() => { void this.unlockVaultWithFace(); }) Text('指纹识别') .fontSize(12) .fontColor($r('app.color.ml_on_surface')) .padding({ left: 14, right: 14, top: 8, bottom: 8 }) .backgroundColor(this.getDarkChipBackgroundColor()) .borderRadius(16) .onClick(() => { void this.unlockVaultWithFingerprint(); }) } } .width('100%') .padding({ left: 24, right: 24, top: 30, bottom: 24 }) .backgroundColor($r('app.color.ml_panel_glass')) .borderRadius(34) .border({ width: 1, color: '#5519B8C7' }) .alignItems(HorizontalAlign.Center) } else {恢复、导出、分享和锁定都在解锁分支
解锁分支里的操作按钮很集中:恢复照片调用restoreRecordFromVault,导出到相册调用exportRecordToSystemAlbum,系统分享调用shareRecordWithSystemShare,锁定按钮调用lockVault。每个按钮都结合 busy 状态限制点击。
这组按钮体现了保险箱详情的完整闭环:用户可以把私密照片恢复公开,也可以导出或分享当前记录,操作完还能手动锁定。对实战文章来说,这比只展示解锁页更有价值,因为它覆盖了用户真正会反复使用的路径。
保险箱解锁后提供恢复、导出、系统分享和锁定操作
Row({ space: 10 }) { Button('恢复照片') .height(42) .layoutWeight(1) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getSecondaryActionTextColor()) .backgroundColor(this.getSecondaryActionBackgroundColor()) .borderRadius(18) .enabled(!this.mediaExportBusy && !this.systemShareBusy && !this.vaultAuthBusy) .onClick(() => { void this.restoreRecordFromVault((this.getFeaturedVaultRecord() as GalleryMoment).id); }) Button('导出到相册') .height(42) .layoutWeight(1) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getMutedActionTextColor()) .backgroundColor(this.getMutedActionBackgroundColor()) .borderRadius(18) .enabled(!this.mediaExportBusy && !this.systemShareBusy && !this.vaultAuthBusy) .onClick(() => { void this.exportRecordToSystemAlbum((this.getFeaturedVaultRecord() as GalleryMoment), 'vault'); }) } .width('100%') Row({ space: 10 }) { Button(this.systemShareBusy ? '分享中...' : '系统分享') .height(42) .layoutWeight(1) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getMutedActionTextColor()) .backgroundColor(this.getMutedActionBackgroundColor()) .borderRadius(18) .enabled(!this.mediaExportBusy && !this.systemShareBusy) .onClick(() => { void this.shareRecordWithSystemShare((this.getFeaturedVaultRecord() as GalleryMoment), 'vault'); }) Button('锁定保险箱') .height(42) .layoutWeight(1) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getMutedActionTextColor()) .backgroundColor(this.getMutedActionBackgroundColor()) .borderRadius(18) .enabled(!this.mediaExportBusy && !this.systemShareBusy) .onClick(() => { this.lockVault(); }) }工程检查清单
- 详情入口必须先判断
vaultUnlocked。 - 私密照片帧复用普通相册详情帧构造。
- 锁定态和解锁态用清晰 UI 分支隔离。
- 恢复、导出和分享按钮都要受 busy 状态控制。
今日练习
- 在未解锁状态下调用
openVaultRecordViewer,验证详情不会打开。 - 切换不同私密记录,观察
getFeaturedVaultRecord的回退逻辑。 - 导出过程中连续点击恢复按钮,确认 enabled 条件能阻止并发操作。
训练营里的每一篇都建议按同一个节奏复盘:先看页面行为,再回到源码定位状态和服务层,最后自己改一个很小的参数验证结果。这样写文章时不会停留在 API 名词,读者也能沿着真实工程把功能跑通。