【健康菜谱助手|06】HarmonyOS ArkTS 烹饪计时器实战:处理倒计时、暂停、恢复和到期反馈

烹饪计时器看起来只是每秒减一,但它比普通数字动画更接近一个时间系统。用户会同时煮鸡蛋、炖排骨和蒸鱼;计时期间可能锁屏、切到菜谱步骤页、暂停某一个任务,或者在应用后台时到期。只依赖页面中的 setInterval(),界面在前台短时间演示通常正常,一旦经历调度延迟和页面生命周期,剩余时间就可能漂移、冻结或丢失。

本文基于"健康菜谱助手"1.0.5 的真实 HarmonyOS ArkTS 工程展开。CookingTimerPage.ets 提供 8 个预设、自定义分钟数、多个并行计时器、暂停/恢复、删除和全部清除。当前实现为每个计时器创建独立 interval,每秒把 remainingSeconds 减一;到 0 后 interval 没有清除,暂停时 interval 也持续运行。页面离开时统一 clearInterval,但没有保存目标截止时间或恢复调度,重新进入后计时器可能被销毁,也可能保留状态却不再走动。

可靠倒计时的真源不是 interval 被调用了多少次,而是 timerId、运行状态、目标截止时间和暂停剩余量共同描述的 TimerSnapshot。

HEALTHY-RECIPE-ONE7-COOKING-TIMER-20260730

本文将完成以下内容:

  • 还原 8 个预设、多计时器和当前 interval 链路。
  • 解释逐秒减一为什么会产生时间漂移。
  • 用 targetEndAt 计算前台和生命周期恢复后的剩余时间。
  • 设计 running、paused、completed 和 removed 状态。
  • 用一个调度器更新多个计时器,及时停掉无效 tick。
  • 让暂停、恢复、删除、全部清除具有幂等语义。
  • 设计到期事件去重与可感知反馈。
  • 覆盖非法分钟数、重复点击、前后台切换和多设备布局。

一、真实工程中的预设与状态

页面内置 8 个预设:

预设 秒数 展示时长
焯水 180 03:00
煮鸡蛋 480 08:00
蒸鱼 600 10:00
腌制 900 15:00
蒸米饭 1200 20:00
烤肉 1800 30:00
炖排骨 2400 40:00
煲汤 3600 01:00:00

活跃计时器模型如下:

ts 复制代码
interface ActiveTimer {
  id: number
  name: string
  emoji: string
  color: string
  totalSeconds: number
  remainingSeconds: number
  isRunning: boolean
}

页面状态还包括:

ts 复制代码
@State activeTimers: ActiveTimer[] = []
@State showCustom: boolean = false
@State customMinutes: string = ''
@State customName: string = ''
private nextId: number = 1
private timerIntervalIds: Map<number, number> = new Map()

这是一份适合渲染的模型,却没有描述"何时到期"。

二、当前 addTimer 的运行方式

每添加一个计时器就创建一个 interval:

ts 复制代码
const intervalId = setInterval(() => {
  const newTimers: ActiveTimer[] = []
  for (const t of this.activeTimers) {
    if (t.id === timerId && t.isRunning && t.remainingSeconds > 0) {
      const remaining = t.remainingSeconds - 1
      newTimers.push({ ...t, remainingSeconds: remaining, isRunning: remaining > 0 })
    } else {
      newTimers.push(t)
    }
  }
  this.activeTimers = newTimers
}, 1000)

如果用户启动 6 个计时器,就会存在 6 个 interval;每个 interval 又遍历全部计时器。数据量不大时 CPU 压力仍可接受,但逻辑复杂度和生命周期管理已经不必要地放大。

三、到期后 interval 仍在运行

当 remaining 变成 0 时,模型的 isRunning 变成 false,并显示一次 Toast;但 intervalId 仍保留在 Map 中。后续每秒仍遍历 activeTimers,只是不再减一。

暂停状态也一样:interval 不会暂停,只是每秒把原对象推回新数组。页面看起来静止,后台仍有周期工作。

正确规则应该是:

  • 只要存在 running 计时器,单一调度器运行。
  • 所有计时器都 paused 或 completed 时,调度器停止。
  • removed 项立即从存储和视图移除。
  • completed 只触发一次到期事件。

四、逐秒减一为什么不等于真实时间

setInterval(callback, 1000) 只表示"尽量在一秒后调度",不保证精确每秒执行。主线程繁忙、页面不可见、系统节能或应用切到后台时,回调可能延迟。

例如一个 600 秒计时器实际经过 30 秒,但回调只执行 20 次,逐秒减一会显示 580 秒;真实剩余应是 570 秒。误差会累积。

可靠算法使用绝对时间:

ts 复制代码
remainingSeconds = ceil((targetEndAt - now) / 1000)

interval 只负责让 UI 定期重新计算,不负责定义时间本身。

五、重新定义 TimerSnapshot

ts 复制代码
export type TimerStatus =
  'running' | 'paused' | 'completed'

export interface TimerSnapshot {
  timerId: string
  name: string
  emoji: string
  color: string
  totalSeconds: number
  remainingSeconds: number
  status: TimerStatus
  targetEndAt?: number
  createdAt: number
  updatedAt: number
  completedAt?: number
  completionNotified: boolean
}

字段边界如下:

  • running 必须有 targetEndAt。
  • paused 不依赖 targetEndAt,以 remainingSeconds 为真源。
  • completed 的 remainingSeconds 固定为 0。
  • completionNotified 防止同一到期事件重复弹出。
  • timerId 是稳定字符串,不随剩余时间改变。

六、创建计时器时计算目标截止时间

ts 复制代码
function createTimer(
  name: string,
  seconds: number,
  now: number
): TimerSnapshot {
  return {
    timerId: `timer_${now}_${Math.random().toString(36).slice(2, 8)}`,
    name,
    emoji: '⏱',
    color: '#4CAF50',
    totalSeconds: seconds,
    remainingSeconds: seconds,
    status: 'running',
    targetEndAt: now + seconds * 1000,
    createdAt: now,
    updatedAt: now,
    completionNotified: false
  }
}

如果项目不希望使用随机字符串,可由服务层维护持久化序列;关键是 ID 不能在页面重建后复用。

七、统一剩余时间计算

ts 复制代码
function calculateRemaining(timer: TimerSnapshot, now: number): number {
  if (timer.status === 'completed') return 0
  if (timer.status === 'paused') {
    return Math.max(0, Math.floor(timer.remainingSeconds))
  }
  if (timer.targetEndAt === undefined) return 0
  return Math.max(0, Math.ceil((timer.targetEndAt - now) / 1000))
}

Math.ceil() 可以避免尚有 100 毫秒时提前显示 0。最终到期判断仍使用 targetEndAt <= now,不要只依赖格式化文本。

八、tick 应该是纯状态归一

ts 复制代码
interface TickResult {
  timers: TimerSnapshot[]
  completedTimerIds: string[]
}

function tickTimers(timers: TimerSnapshot[], now: number): TickResult {
  const completedTimerIds: string[] = []
  const next = timers.map((timer) => {
    if (timer.status !== 'running') return timer
    const remaining = calculateRemaining(timer, now)
    if (remaining > 0) {
      return { ...timer, remainingSeconds: remaining, updatedAt: now }
    }
    completedTimerIds.push(timer.timerId)
    return {
      ...timer,
      remainingSeconds: 0,
      status: 'completed' as TimerStatus,
      completedAt: now,
      updatedAt: now
    }
  })
  return { timers: next, completedTimerIds }
}

该函数不弹 Toast、不调用 ArkUI,也不访问存储,可用固定 now 做单元测试。

九、一个调度器管理所有计时器

ts 复制代码
private schedulerId?: number

private startSchedulerIfNeeded(): void {
  const hasRunning = this.activeTimers.some((timer) => timer.status === 'running')
  if (!hasRunning || this.schedulerId !== undefined) return
  this.schedulerId = setInterval(() => this.onTick(), 250)
}

private stopScheduler(): void {
  if (this.schedulerId === undefined) return
  clearInterval(this.schedulerId)
  this.schedulerId = undefined
}

250 毫秒不是为了让秒数更快变化,而是减少跨秒边界的视觉延迟;真实剩余仍由 Date.now 计算。若更关注省电,可以使用 500 或 1000 毫秒。

十、每次 tick 后判断是否还需要调度

ts 复制代码
private onTick(): void {
  const result = tickTimers(this.activeTimers, Date.now())
  this.activeTimers = result.timers
  result.completedTimerIds.forEach((id) => this.handleCompletion(id))
  if (!this.activeTimers.some((timer) => timer.status === 'running')) {
    this.stopScheduler()
  }
}

这样暂停全部计时器、全部完成或删除全部后都不会继续空转。

十一、暂停是把截止时间固化为剩余量

ts 复制代码
function pauseTimer(timer: TimerSnapshot, now: number): TimerSnapshot {
  if (timer.status !== 'running') return timer
  const remaining = calculateRemaining(timer, now)
  return {
    ...timer,
    remainingSeconds: remaining,
    status: remaining > 0 ? 'paused' : 'completed',
    targetEndAt: undefined,
    updatedAt: now,
    completedAt: remaining > 0 ? undefined : now
  }
}

暂停后等待多久都不会减少,因为 remainingSeconds 已固定。

十二、恢复是重新建立截止时间

ts 复制代码
function resumeTimer(timer: TimerSnapshot, now: number): TimerSnapshot {
  if (timer.status !== 'paused' || timer.remainingSeconds <= 0) return timer
  return {
    ...timer,
    status: 'running',
    targetEndAt: now + timer.remainingSeconds * 1000,
    updatedAt: now
  }
}

恢复后调用 startSchedulerIfNeeded(),不要为该 timer 再创建一个独立 interval。

十三、完成状态不能用 isRunning=false 猜测

当前模型中暂停和完成都是 isRunning === false,只能再检查 remainingSeconds 区分。显式 status 能让 UI 和服务更清楚:

ts 复制代码
switch (timer.status) {
  case 'running': return '计时中'
  case 'paused': return '已暂停'
  case 'completed': return '已完成'
}

删除不是一个可恢复展示状态,服务完成删除后从数组移除即可;如果需要撤销,可以临时保存 DeleteSnapshot。

十四、到期事件必须去重

如果页面每 250 毫秒 tick,一旦状态已是 completed,后续 tick 不应重复通知。可以在处理后设置 completionNotified = true:

ts 复制代码
private handleCompletion(timerId: string): void {
  const timer = this.activeTimers.find((item) => item.timerId === timerId)
  if (!timer || timer.completionNotified) return
  this.activeTimers = this.activeTimers.map((item) =>
    item.timerId === timerId
      ? { ...item, completionNotified: true }
      : item
  )
  promptAction.showToast({
    message: `${timer.name}计时结束`,
    duration: 3000
  })
}

持久化 notified 能避免页面重建后再次提示同一完成事件。

十五、Toast 不是完整的到期提醒

Toast 只在用户正看页面时容易被发现。烹饪场景需要更明确的前台反馈,例如:

  • 计时卡片变为完成态并置顶。
  • 播放用户可控制的短提示音。
  • 触发适度振动,但应遵守平台能力、权限和用户设置。
  • 提供"知道了"或删除完成项。

如果要在应用后台可靠提醒,必须使用 HarmonyOS 官方允许的后台/通知能力,明确用户场景、权限与隐私说明;不能依赖隐藏常驻 interval 或规避系统限制。本文的本地页面方案只保证前台恢复时按绝对时间纠正,并触发未通知的完成事件。

十六、生命周期恢复不能只 clearInterval

当前 aboutToDisappear() 清掉所有 interval,但未保存;如果组件状态被保留,回来后 activeTimers 还在,调度却没有重启;如果组件销毁,则计时器全部消失。

建议生命周期链路:

ts 复制代码
aboutToAppear(): void {
  this.restoreTimers()
  this.reconcileTimers(Date.now())
  this.startSchedulerIfNeeded()
}

aboutToDisappear(): void {
  this.stopScheduler()
  this.persistTimers()
}

restore 后立即用当前时间重算,已经到期的任务直接进入 completed,而不是从旧 remaining 继续倒数。

十七、持久化哪些字段

可以使用 Preferences 保存少量计时器快照:

ts 复制代码
interface TimerStore {
  schemaVersion: number
  revision: number
  timers: TimerSnapshot[]
  updatedAt: number
}

只保存业务字段,不保存 schedulerId 等运行时资源。应用重启后 running 项依据 targetEndAt 恢复,paused 项依据 remainingSeconds 恢复。

十八、存储结果要可观察

服务接口:

ts 复制代码
interface SaveTimerResult {
  ok: boolean
  revision?: number
  reason?: 'not_initialized' | 'serialize_failed' | 'write_failed'
}

添加计时器后如果保存失败,页面可以保留本次前台计时,但应标记"未保存";不要提示"已开始"后让用户误以为离开页面仍能恢复。

十九、自定义分钟数要有边界

当前逻辑是:

ts 复制代码
const mins = Number(this.customMinutes)
if (mins > 0) {
  this.addTimer(name, '⏱', mins * 60, color)
}

它没有限制小数、极大值或非有限数。建议:

ts 复制代码
function parseTimerMinutes(input: string): number | undefined {
  const minutes = Number(input.trim())
  if (!Number.isFinite(minutes)) return undefined
  if (!Number.isInteger(minutes)) return undefined
  if (minutes < 1 || minutes > 24 * 60) return undefined
  return minutes
}

上限应按产品场景决定。烹饪计时通常不需要无限时长,明确边界也避免乘法溢出与异常展示。

二十、限制同时活跃数量

页面允许无限点击预设。对本地计时工具,可设定例如 12 个活动项上限:

ts 复制代码
const MAX_ACTIVE_TIMERS = 12

function canAddTimer(timers: TimerSnapshot[]): boolean {
  return timers.filter((timer) => timer.status !== 'completed').length < MAX_ACTIVE_TIMERS
}

达到上限时提示先完成或删除一个,不要继续创建大量卡片。

二十一、全部清除属于破坏性动作

当前"全部清除"立即停止所有 interval 并清空数组。多计时器烹饪中误触的代价很高。建议:

  • 有 running 任务时二次确认。
  • 或清除后提供短时撤销。
  • 先持久化清空结果,再显示成功。
  • 调用 stopScheduler(),不遗漏运行时资源。

二十二、删除单项应幂等

ts 复制代码
function removeTimer(
  timers: TimerSnapshot[],
  timerId: string
): TimerSnapshot[] {
  return timers.filter((timer) => timer.timerId !== timerId)
}

重复删除同一 ID 返回相同结果,不抛异常。删除后如果没有 running timer,停止调度器。

二十三、ForEach key 不能包含剩余秒数

当前 key 是:

ts 复制代码
`timer_${timer.id}_${timer.remainingSeconds}`

每秒 remaining 变化,key 也变化,ArkUI 会把卡片视作新节点。动画、焦点和内部状态都可能被重建。应只用稳定 timerId:

ts 复制代码
ForEach(this.activeTimers, (timer: TimerSnapshot) => {
  this.TimerCard(timer)
}, (timer: TimerSnapshot) => timer.timerId)

二十四、进度值必须钳制

当前:

ts 复制代码
return (totalSeconds - remainingSeconds) / totalSeconds * 100

恢复旧数据或异常输入时 remaining 可能超出范围。安全计算:

ts 复制代码
function getProgress(timer: TimerSnapshot): number {
  if (timer.totalSeconds <= 0) return 0
  const completed = timer.totalSeconds - timer.remainingSeconds
  const percent = completed / timer.totalSeconds * 100
  return Math.max(0, Math.min(100, percent))
}

二十五、当前圆环不是角度进度

页面叠加两个完整圆形边框,再用 opacity 表示进度。结果是整圈颜色逐渐变深,不是从 0 到 360 度增长的进度环。

如果需要真正环形进度,应使用平台标准进度组件或 Canvas 绘制弧线;若不想增加绘制复杂度,保留横向进度条并把圆形区域作为状态图标更诚实。不要让视觉暗示与真实数值不一致。

二十六、状态分组比"进行中数量"更准确

当前标题显示 activeTimers.length,其中包含 paused 和 completed。建议分别统计:

ts 复制代码
interface TimerCounts {
  running: number
  paused: number
  completed: number
}

UI 可以显示"计时中 2 · 暂停 1 · 已完成 1",完成项可折叠或提供一键清除。

二十七、架构分层

27.1 TimerModel

定义状态、截止时间、暂停剩余量和完成通知标记。

27.2 TimerEngine

负责 create、tick、pause、resume、complete、remove 等纯状态转换。

27.3 TimerScheduler

只管理一个运行时 interval,页面离开时释放,出现时重建。

27.4 TimerRepository

负责 Preferences 读取、迁移、revision 和保存结果。

27.5 CookingTimerPage

负责预设、自定义输入、卡片、弹层和用户反馈。

二十八、测试矩阵

编号 场景 预期
T01 启动 8 分钟预设 targetEndAt=now+480000
T02 调度延迟 5 秒 按 now 重算,不只减 1
T03 暂停 remaining 固定,targetEndAt 清空
T04 暂停 30 秒再恢复 从暂停剩余量建立新截止时间
T05 到期 status=completed,remaining=0
T06 连续 tick 到期项 completion 只通知一次
T07 多个计时器 只有一个 scheduler interval
T08 全部暂停 scheduler 停止
T09 删除最后一个 running scheduler 停止且列表为空
T10 页面离开 20 秒 返回后按绝对时间修正
T11 后台期间到期 返回时立即 completed 并补一次反馈
T12 输入 0、负数、空值 拒绝创建
T13 输入小数或非有限数 拒绝创建
T14 输入超过上限 提示允许范围
T15 达到活动上限 不再新增
T16 全部清除误触 有确认或撤销
T17 保存失败 标记未保存,不假报可恢复
T18 每秒更新 timerId 与卡片身份保持稳定

二十九、可复核验收步骤

29.1 时间准确性

用可注入 Clock 创建 10 秒计时器。模拟 now 前进 3.4 秒,预期剩余 7 秒;再前进到 targetEndAt 之后,预期 completed。测试不需要真的等待 10 秒。

29.2 暂停恢复

在剩余 40 秒时 pause,模拟时钟前进 2 分钟,remaining 仍为 40;resume 后 targetEndAt 应等于恢复时刻加 40 秒。

29.3 生命周期

启动两个计时器后离开页面,确认 scheduler 已清除且快照已保存。等待一段时间再进入,running 项按截止时间更新,paused 项保持原值。

29.4 到期反馈

让两个计时器在相邻时刻到期,确认每个 timerId 只通知一次,完成卡片可见。再次重建页面不得重复提示已通知事件。

29.5 多设备

在手机竖屏、横屏、平板和 2in1 下检查横向预设、计时卡、操作按钮、自定义面板和底部安全区;深色模式下检查警告红色、暂停橙色、完成状态和进度背景的对比度。

三十、常见问题与定位

30.1 切到后台后时间慢了

原因: 每次 interval 只减 1,没有按真实 elapsed time 计算。

处理: 保存 targetEndAt,每次 tick 和恢复都用 Date.now 重算。

30.2 暂停后仍有周期工作

原因: interval 没清,只是不再修改剩余秒数。

处理: 单一调度器;没有 running 项时立即停止。

30.3 到 0 后 CPU 仍有活动

原因: completed timer 的 interval 留在 Map 中。

处理: 状态完成后判断 running 数量并停止 scheduler。

30.4 返回页面后计时器冻结

原因: aboutToDisappear 清了 interval,但 aboutToAppear 未重建。

处理: 恢复快照、校准状态并按需启动调度器。

30.5 完成提示重复出现

原因: 到期判断每次 tick 都触发,没有 notified 标记。

处理: completionNotified 持久化并按 timerId 去重。

30.6 卡片每秒闪动

原因: ForEach key 包含 remainingSeconds。

处理: key 只使用稳定 timerId。

三十一、落地顺序

  1. 先引入 TimerStatus、targetEndAt 和稳定 timerId。
  2. 把每 timer interval 改成单一 scheduler。
  3. 实现 pause/resume 的截止时间转换。
  4. 增加 completed 去重与前台到期反馈。
  5. 增加 Preferences 快照和生命周期恢复。
  6. 校验自定义输入、活动上限与清除确认。
  7. 最后优化圆形进度、多设备布局和可访问性。

每一步都可用 T01--T18 的相关用例验证,不需要依赖真实等待。

三十二、源码事实与建议实现的边界

本文讨论了截止时间、单一调度器和生命周期恢复,但这些属于针对当前代码缺口的改造方案。为避免把方案误认为已经上线的能力,先把可直接复核的源码事实列清楚:

维度 当前源码事实 本文建议,不代表已经实现
预设 页面内置 8 个预设,秒数从 180 到 3600 预设数据可继续配置化,但当前已能直接点击启动
活跃模型 ActiveTimer 只有 id、名称、emoji、颜色、总秒数、剩余秒数、isRunning 增加显式 status、targetEndAt、completionNotified、revision
调度方式 每次 addTimer() 都创建一个独立 setInterval(..., 1000),并写入 Map 所有 running 项共享一个调度器
时间计算 每次有效 tick 固定把 remainingSeconds 减 1 使用 targetEndAt - now 计算剩余时间
暂停恢复 togglePause() 只翻转 isRunning;对应 interval 仍存在 暂停时固化剩余量,恢复时重建截止时间
到期处理 剩余量减到 0 时将 isRunning 设为 false,并显示 3 秒 Toast 到期事件按 timerId 去重,并设计前台可感知反馈
资源释放 删除单项会清对应 interval;离开页面会遍历并清 interval 离开时保存快照,返回时校准并按需重建调度
自定义输入 仅以 Number(customMinutes) > 0 判定有效,没有上限或活动数量限制 增加整数、小数、非有限值、最大分钟数和并发上限校验
列表身份 ForEach key 包含 timer.id 与 remainingSeconds,所以每秒都会变化 key 只使用稳定 timerId
持久化 当前页面没有计时器 Preferences 读写,也没有后台到期能力 TimerRepository、后台提醒和系统能力接入需要另行实现并验证
验证状态 本文完成源码级复核、配图检查和文章质量检查 构建、安装、锁屏、前后台切换及真实到期行为未在本文中宣称通过

因此,TimerSnapshot、TimerEngine、TimerScheduler、TimerRepository 以及 T01--T18 测试矩阵都是建议实现与验收设计。当前 1.0.5 工程的真实能力仍以 CookingTimerPage.ets 为准,不能仅凭本文示例就认定已具备后台计时、持久化恢复或系统级通知。

三十三、总结

健康菜谱助手的计时器页已经有 8 个预设、自定义分钟数、多计时器、暂停、恢复、删除和空态,交互入口完整。真正的风险是把 interval 次数当作时间、为每个任务创建独立调度、到期和暂停仍空转,以及页面生命周期只清资源却不恢复业务状态。

可靠计时器需要守住五条原则:

  • targetEndAt 是 running 状态的时间真源,interval 只刷新 UI。
  • paused、running 和 completed 必须显式区分。
  • 所有计时器共用一个调度器,无 running 项立即停止。
  • 页面离开保存快照,回来按当前时间校准。
  • 到期反馈按 timerId 去重,后台能力只使用官方合规路径。

当用户开始煲汤计时、切去查看菜谱再回来,剩余时间仍与真实经过时间一致时,这个页面才从倒数动画升级为可信的烹饪工具。

参考资料

AI 辅助声明:本文由 AI 辅助完成源码统计、结构校对与配图设计,当前能力结论均以文中列出的本地 ArkTS 源码为依据,并由作者复核后发布;截止时间模型、调度器、持久化方案和测试矩阵不代表工程已经构建、安装或上线。

相关推荐
贾伟康5 小时前
【健康菜谱助手|05】HarmonyOS ArkTS 购物清单实战:去重食材并同步勾选、数量与清空动作
软件测试·移动开发·harmonyos·arkts·arkui
特立独行的猫a6 小时前
仓颉语言cj-tauri 跨端开发框架实战:给鸿蒙侧加一个「读设备电量」的原生能力
ui·harmonyos·tauri·1024程序员节·仓颉·鸿蒙pc
贾伟康16 小时前
【健康菜谱助手|01】HarmonyOS ArkTS 菜谱详情页实战:组织食材、营养、步骤和收藏状态
软件测试·移动开发·harmonyos·arkts·arkui
一目Leizi1 天前
折叠屏适配踩坑实录:从“一展开就变形“到一套代码适配多种形态【鸿蒙心迹】
华为·harmonyos
传奇开心果编程1 天前
【Jetpack Compose娓娓道来】 第25课:ViewModel就是UI界面的大脑
android·kotlin·移动开发·jetpack compose·编程语言理论·编程框架·声明式ui
马剑威(威哥爱编程)1 天前
【HarmonyOS 7 小艺智能体实战】03:第一次建智能体——在小艺开放平台把骨架立起来
人工智能·华为·harmonyos
KevinChaos1 天前
HarmonyOS 7.0 端侧3DGS实验⑤:空洞、漂浮伪影与视角稳定
harmonyos
fellow991 天前
DSH Desktop - HarmonyOS Edition
harmonyos·openharmony·deepseek·deepseekharness