胶囊详情页是用户和"未来的自己"对话的终点——从时间线列表点击一张卡片,跳转到这里,看到封印时写下的内容。这个页面的特殊之处在于它不是静态的:胶囊可能还在等待中,可能刚好到期,也可能已经被开启过。三种状态决定三种完全不同的 UI 和交互——等待中显示倒计时,刚到期引导开启,已开启显示完整内容。

完整效果
在这里插入图片描述

一、页面的路由入口

// 首页 handleCardClick
private handleCardClick(capsule: Capsule): void {
  setCurrentCapsuleId(capsule.id)
  router.pushUrl({
    url: 'pages/CapsuleDetail',
    params: { capsuleId: capsule.id }
  })
}

在这里插入图片描述

两个操作的分工

操作 作用
setCurrentCapsuleId(capsule.id) 在全局记录当前胶囊 ID
router.pushUrl 传 capsuleId 通过路由参数告诉详情页要显示哪个胶囊

为什么要 setCurrentCapsuleId? 路由参数只能传基本类型(string/number/boolean)。如果详情页还需要其他上下文(比如胶囊的完整数据),用全局变量比传多个路由参数更方便。CapsuleDetail 可以用 getCurrentCapsuleId() 从全局获取 ID,再从数据库查完整数据。

路由参数的设计

params: { capsuleId: capsule.id } as Record<string, Object>

只传了 capsuleId——一个 string 类型的 ID。 详情页根据这个 ID 从数据库查询完整数据。传完整胶囊对象的问题是:如果数据库里的数据被修改了(比如用户在其他地方修改了标签),详情页显示的还是旧数据。传 ID + 实时查询保证数据最新。

二、CapsuleDetail 需要处理的三种状态

状态判断逻辑

private getStatus(): string {
  if (this.capsule.isOpened) return 'opened'
  if (this.capsule.unlockAt <= Date.now()) return 'ready'
  return 'sealed'
}

三种状态对应的 UI

状态 条件 页面标题 主要内容 按钮
SEALED 未开启且未到期 胶囊封印中 倒计时 + 胶囊外观
READY 未开启但已到期 胶囊已到期 开启引导 🔓 开启胶囊
OPENED 已开启 胶囊内容 完整内容 + 心情 + 标签 返回

三、封印中状态的 UI

// 推断的封印中状态 UI
Column() {
  Text('🔮').fontSize(80).margin({ bottom: Spacing.lg })
  Text('胶囊封印中').fontSize(FontSize.xl)
    .fontColor(this.getColors().text).fontWeight(FontWeight.Bold)
    .margin({ bottom: Spacing.sm })
  Text('距离开启还有').fontSize(FontSize.sm)
    .fontColor(this.getColors().textSecondary)
    .margin({ bottom: Spacing.sm })
  // 倒计时显示
  Text(this.getTimeLeft())
    .fontSize(FontSize.hero).fontColor(this.getColors().primary)
    .fontWeight(FontWeight.Bold)
  // 内容预览(模糊遮罩)
  Stack() {
    Text(this.capsule.content).fontSize(FontSize.md)
      .fontColor(this.getColors().textSecondary).lineHeight(24)
    // 高斯模糊遮罩
  }
}

倒计时的显示

private getTimeLeft(): string {
  const diff = this.capsule.unlockAt - Date.now()
  if (diff <= 0) return '已到期'
  const days = Math.floor(diff / 86400000)
  const hours = Math.floor((diff % 86400000) / 3600000)
  const minutes = Math.floor((diff % 3600000) / 60000)
  if (days > 0) return `${days}${hours}小时`
  if (hours > 0) return `${hours}小时${minutes}分钟`
  return `${minutes}分钟`
}

详情页的倒计时比 CapsuleCard 更精确——显示到分钟级别。 因为用户专门点进来看详情,说明他们在乎这个胶囊,需要更精确的等待信息。时间线卡片只显示天+小时——因为那是概览,不需要太精确。

内容预览的模糊处理

封印中的胶囊内容不应该完全显示——否则"封印"就失去意义了。 用高斯模糊遮罩让用户能看到"这里有内容"但看不清具体内容。这增加了神秘感和期待感。

四、可开启状态的 UI

Column() {
  Text('✨').fontSize(80).margin({ bottom: Spacing.lg })
  Text('胶囊已到期').fontSize(FontSize.xl)
    .fontColor(this.getColors().text).fontWeight(FontWeight.Bold)
    .margin({ bottom: Spacing.sm })
  Text('你的时间胶囊已经准备好了').fontSize(FontSize.sm)
    .fontColor(this.getColors().textSecondary)
    .margin({ bottom: Spacing.lg })
  // 开启按钮
  Row() {
    Text('🔓 开启胶囊').fontSize(FontSize.lg)
      .fontColor('#FFFFFF').fontWeight(FontWeight.Bold)
  }
  .width('100%').height(52)
  .justifyContent(FlexAlign.Center)
  .backgroundColor(this.getColors().primary)
  .borderRadius(BorderRadius.lg)
  .onClick(() => this.openCapsule())
}

在这里插入图片描述

开启胶囊的逻辑

private async openCapsule(): Promise<void> {
  this.capsule.isOpened = true
  await this.database.updateCapsule(this.capsule)
  // 重新加载数据
  this.capsule = this.database.getCapsuleById(this.capsuleId)
}

开启操作只改一个字段——isOpened = true 不删除胶囊,不改变内容,只是标记为已读。这保证了胶囊数据的完整性——用户可以在统计 Tab 里看到所有历史胶囊,包括已开启的。

开启后的 UI 变化

开启前:大号 ✨ + "胶囊已到期" + 开启按钮
开启后:完整内容 + 心情 + 标签 + 创建时间 + 返回按钮

一次性展示——没有渐变动画。 因为胶囊的核心体验是"内容"本身,而不是"开启动画"。简洁直接比花哨动画更符合"写给未来的自己"这个产品调性。

五、已开启状态的 UI

Column() {
  // 顶部状态栏
  Row() {
    Text(this.capsule.mood).fontSize(24)
    Blank()
    Text(this.formatDate(this.capsule.createdAt))
      .fontSize(FontSize.xs).fontColor(this.getColors().textSecondary)
  }.width('100%').margin({ bottom: Spacing.md })

  // 胶囊内容
  Text(this.capsule.content)
    .fontSize(FontSize.md).fontColor(this.getColors().text)
    .lineHeight(28).width('100%')
    .margin({ bottom: Spacing.lg })

  // 标签
  if (this.capsule.tags.length > 0) {
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(this.capsule.tags, (tag: string) => {
        Text('#' + tag)
          .fontSize(FontSize.sm).fontColor(this.getColors().primary)
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .backgroundColor(this.getColors().primary + '15')
          .borderRadius(BorderRadius.full)
          .margin({ right: Spacing.xs, bottom: Spacing.xs })
      })
    }
  }
}

信息展示的优先级

优先级 内容 字号 颜色
1 胶囊内容 md text(主色)
2 心情 24px emoji 原色
3 创建时间 xs textSecondary
4 标签 sm primary

内容最重要——字号最大,位于页面中央。 心情和时间是辅助信息——放在顶部作为上下文。标签是分类信息——放在底部作为补充。

时间格式化

private formatDate(timestamp: number): string {
  const date = new Date(timestamp)
  const year = date.getFullYear()
  const month = date.getMonth() + 1
  const day = date.getDate()
  const hour = date.getHours().toString().padStart(2, '0')
  const minute = date.getMinutes().toString().padStart(2, '0')
  return `${year}${month}${day}${hour}:${minute}`
}

显示"2024年7月16日 17:58"格式——比时间戳更可读。 用户不需要知道 Unix 时间戳,他们需要知道"这是哪天写的"。

六、数据库的查询方法

从首页调用推断的数据库方法

// 首页
this.database.getAllCapsules()     // 获取所有胶囊

// 详情页需要
this.database.getCapsuleById(id)   // 根据 ID 获取单个胶囊
this.database.updateCapsule(capsule) // 更新胶囊(标记已开启)

getCapsuleById 的实现推断

// 方案 A:遍历查找
getCapsuleById(id: string): Capsule | null {
  for (let i = 0; i < this.capsules.length; i++) {
    if (this.capsules[i].id === id) return this.capsules[i]
  }
  return null
}

// 方案 B:Map 查找
getCapsuleById(id: string): Capsule | null {
  return this.capsuleMap.get(id) || null
}

方案 A 更可能——因为首页已经把所有胶囊加载到内存了。 不需要额外的 Map 数据结构,遍历数组在数据量小的情况下足够快。CapsuleDetail 的调用频率很低(用户不会频繁切换详情页),性能不是瓶颈。

updateCapsule 的实现推断

async updateCapsule(capsule: Capsule): Promise<void> {
  // 找到对应位置,替换数据
  for (let i = 0; i < this.capsules.length; i++) {
    if (this.capsules[i].id === capsule.id) {
      this.capsules[i] = capsule
      break
    }
  }
  // 持久化到文件
  await this.saveToFile()
}

内存更新 + 文件持久化两步走。 先改内存中的数组,再写入文件。这保证了数据一致性——内存和文件里的数据始终同步。

七、setCurrentCapsuleId 的作用

// 首页
setCurrentCapsuleId(capsule.id)
router.pushUrl({ url: 'pages/CapsuleDetail', params: { capsuleId: capsule.id } })

全局 ID 的使用场景

场景 用途
CapsuleDetail 读取 获取当前胶囊 ID,查询数据库
MemoryShuffle 读取 记住当前洗牌展示的胶囊
返回后恢复 用户从详情页返回,时间线能定位到刚才的位置

全局 ID 是一个轻量级的状态共享机制。 比传多个路由参数更简洁。在单用户 App 里,全局变量不会有并发冲突——任何时候只有一个"当前胶囊"。

八、详情页的嵌套结构

Column                              页面容器
  ├→ Row (顶部栏)
  │    ├→ Text(mood)                心情 emoji
  │    └→ Text(createdAt)           创建时间
  ├→ Text(content)                  胶囊内容
  └→ Flex (标签区)
       └→ ForEach(tags)             标签列表

嵌套深度 3 层——比首页更浅。 因为详情页不需要 Stack 层叠(没有底部导航栏),也不需要 Scroll(内容通常不会太长)。

和首页的嵌套对比

页面 嵌套深度 是否有 Scroll 是否有 Stack
Index 3 层 是(底部导航栏)
CapsuleDetail 3 层 可能

详情页可能不需要 Scroll——胶囊内容通常不会超过一屏。 但如果用户写了很长的内容,Scroll 还是需要的。这取决于 CapsuleDetail 的实际实现。

九、页面生命周期

aboutToAppear(): void {
  // 从路由参数获取胶囊 ID
  // 从数据库查询胶囊数据
  // 判断状态并渲染对应 UI
}

onPageShow(): void {
  // 刷新胶囊状态
  // 可能从"可开启"变为"已开启"
}

aboutToAppear 做初始化,onPageShow 做刷新。 和首页的生命周期模式一致。onPageShow 在页面每次显示时触发——用户从其他页面返回时,详情页会重新检查胶囊状态。

状态可能在页面间变化

用户打开 CapsuleDetail(状态:ready)
  → 按返回键回到首页
  → 时间过去,胶囊状态没变化
  → 用户再次点击同一胶囊
  → CapsuleDetail 重新渲染(状态:ready)

如果用户不点击"开启胶囊",状态不会变化。 但如果用户在另一个设备上开启了同一胶囊(云同步场景),onPageShow 会刷新为 opened 状态。这是 onPageShow 存在的意义——处理跨页面的状态变化。

十、详情页的三种状态与用户心理

状态 用户心理 设计策略
SEALED 期待、好奇 倒计时增加仪式感,模糊遮罩保持神秘
READY 兴奋、迫不及待 大按钮引导开启,减少干扰
OPENED 回味、感慨 完整内容展示,时间信息唤起记忆

三种状态对应三种情绪——UI 设计要配合情绪。 封印中是"等待的仪式",可开启是"拆礼物的兴奋",已开启是"翻看老照片的温暖"。

核心设计原则: 封印中尽量少展示信息(保持神秘),已开启尽量多展示信息(满足好奇),可开启用最醒目的按钮引导操作(减少决策成本)。

Logo

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

更多推荐