当前位置: 首页 > news >正文

第76篇 | HarmonyOS 保险箱详情页:私密照片如何浏览、恢复和导出

第76篇 | HarmonyOS 保险箱详情页:私密照片如何浏览、恢复和导出

第 76 篇讲保险箱详情页。私密照片解锁后不能只显示一个列表,用户还需要像普通相册一样查看前后镜头、滑动浏览、恢复公开相册、导出到系统相册或再次锁定。区别在于这些动作都必须在保险箱上下文里完成。

这一篇从详情查看器、选中记录、私密帧列表、恢复/导出按钮和锁定按钮五个角度拆代码。重点是理解:保险箱复用了普通相册的数据和预览能力,但它在入口、状态和操作上加了更严格的边界。

本篇目标

  • 理解保险箱详情页为什么要先检查vaultUnlocked
  • 掌握getFeaturedVaultRecordgetFeaturedVaultFrames如何支撑预览。
  • 理解私密照片恢复、导出、系统分享和锁定按钮的状态条件。
  • 学会让详情查看器复用普通相册帧数据,同时保留保险箱边界。

对应源码位置

  • 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 状态控制。

今日练习

  1. 在未解锁状态下调用openVaultRecordViewer,验证详情不会打开。
  2. 切换不同私密记录,观察getFeaturedVaultRecord的回退逻辑。
  3. 导出过程中连续点击恢复按钮,确认 enabled 条件能阻止并发操作。

训练营里的每一篇都建议按同一个节奏复盘:先看页面行为,再回到源码定位状态和服务层,最后自己改一个很小的参数验证结果。这样写文章时不会停留在 API 名词,读者也能沿着真实工程把功能跑通。

http://www.jsqmd.com/news/984894/

相关文章:

  • 2026义乌UV双喷服务机构整理推荐 - 奔跑123
  • 通诚无忧-通辽信息港信息平台运营策略:打造用户喜爱的通辽市本地服务社区
  • Kotlin单表达式函数在安卓开发中的精简艺术
  • MySQL(三):库操作与表操作
  • 大理黄金回收2026全流程高价避坑攻略 - 润富黄金回收
  • 自流平材料在现代装修设计中的创新应用及魅力解析
  • Playwright视觉比较(图片比对测试)
  • 伺服电机仿真(7):非线性因素的建模
  • 手把手教你用MATLAB复现圆柱绕流POD分解:从Brunton的代码到自己的流场图
  • 大医精诚·孙思邈
  • /etc/passwd和/etc/shadow区别?用户信息与密码哈希分工详解
  • 2026年实测:各类大赛人气投票链接生成方法,3分钟搞定(免费+强防刷) - 微信投票小程序
  • Linux驱动程序机制
  • AgentWatch MCP 服务说明文档
  • 聚焦脑机接口领域基础研究:国家自然科学基金委与术理创新共同设立民营企业创新发展联合基金(术理创新)
  • 基于 LlamaIndex + DeepSeek + Streamlit 搭建智能问答系统
  • 阳极与阴极浇铸质量检测仪哪家靠谱?上规模生产企业青岛普锐思介绍 - 品牌推荐大师1
  • 高效核销网点系统开发全解析
  • 10kV配网故障识别:波形分析全攻略
  • UVM源码探秘:start_item的sequencer参数怎么用?解锁更灵活的sequence驱动方式
  • 2026最新渭南市黄金回收价格一览表 回收避坑攻略靠谱商家推荐 - 余生黄金回收
  • 镇江丹徒区金价高企,市民闲置黄金变现正当时 - 专业黄金回收
  • 2026年佛山铰链供应商深度横评:全屋定制五金一站式采购避坑指南 - 年度推荐企业名录
  • 人工智能专业术语详解(I)
  • 手上资金少怎么创业?2026零基础低投入创业实操指南
  • Linux基础知识(二)
  • 【国产电脑python编译器配置】麒麟V10系统anaconda配置pycharm
  • 不只是降阶:用POD方法给你的CFD流场做一次‘体检’与‘瘦身’
  • Vue3自定义指令实战:从拖拽到权限按钮,3个真实项目案例手把手教学
  • AI技术落地商业化破局:明图科技以技术创新驱动数字产业实景发展