HarmonyOS时光胶囊——详情页的胶囊查看与状态管理
胶囊详情页是用户和"未来的自己"对话的终点——从时间线列表点击一张卡片,跳转到这里,看到封印时写下的内容。这个页面的特殊之处在于它不是静态的:胶囊可能还在等待中,可能刚好到期,也可能已经被开启过。三种状态决定三种完全不同的 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 设计要配合情绪。 封印中是"等待的仪式",可开启是"拆礼物的兴奋",已开启是"翻看老照片的温暖"。
核心设计原则: 封印中尽量少展示信息(保持神秘),已开启尽量多展示信息(满足好奇),可开启用最醒目的按钮引导操作(减少决策成本)。
更多推荐

所有评论(0)