国庆不迷路:我用华为云码道 Agent 写了个鸿蒙「黄金周倒计时与假期规划卡」

源码开源:https://atomgit.com/2501_92427005/golden-week-planner

写一篇「事后总结」,不如把假期本身做成作品——让码道 Agent 替我把「倒计时 + 7 天假期规划」直接落成一个能跑在 Pura X 上的鸿蒙应用。整个过程我几乎没写业务代码,主要做了三件事:写提示词、看 Agent 怎么拆架构、踩坑工程骨架。

在这里插入图片描述
运行总览

0. 缘起:为什么要做这个

每年国庆都过得差不多:眼睛一闭一睁,第七天了。今年想试试给自己立一个看得见的「假期仪表盘」——开屏就是黄金周倒计时,10 月 1 日到 7 日每天一张卡,能勾选、能改文案、能看到完成度。

更关键的是,我想验证一个猜想:码道 Agent 在严格分层约束下,能不能一次性产出可单元测试的鸿蒙工程? 上一轮做中秋小游戏时是纯前端,引擎可以用 Node 内置 node:test 跑测试;鸿蒙 ArkTS 没法直接在 Node 里跑,得想别的办法。

于是定下六条硬约束,写进给 Agent 的提示词里:

  • 纯本地、零权限:requestPermissions 必须是空数组,不联网、不通知、不定位;
  • 数据落盘用 Preferences:用户改过的卡片标题、勾选状态必须断电不丢;
  • domain 层纯净:核心逻辑(倒计时、规划卡、格式化)不 import 任何 @ohos.*,便于在 Node 里跑测试;
  • 倒计时写死 UTC+8:不依赖系统时区,避免「手机换时区,倒数错一天」;
  • UI 资源全走 $r:颜色、字号、字符串全部走资源表,不在代码里写字面量;
  • 测试必须真绿:npm test 用 Node 22 的 --experimental-strip-types 直接跑 ArkTS 转 TS 后的用例。

1. 给 Agent 的提示词:先建仓,再开发

我没有一次性把所有事丢给 Agent,而是分两步:

第一步,建仓。 让码道在 AtomGit 上创建 golden-week-planner 仓库,描述、.gitignore、MIT License、README 骨架一次性写好:

码道建仓

建仓完成,仓库地址落定为 https://atomgit.com/2501_92427005/golden-week-planner:

建仓完成

第二步,开发。 单独开一个会话,把完整需求(倒计时三态、7 张规划卡、Preferences 持久化、分层结构、色板、测试要求)一次性喂给 Agent。这里有个我反复验证过的心得:让 Agent 先做架构设计,再让它写代码,远比直接说「帮我写个 App」稳定。

开发思考

新会话里 Agent 输出的工程结构:

Agent 工程结构

最终 npm test 22 个用例全绿:

测试通过

2. 需求拆解:三个阶段、七张卡、一条进度条

应用功能一句话讲完:

屏幕上同时出现两件事——离国庆还有多久,以及假期里每一天打算怎么过。

具体到三态:

阶段触发条件顶部文案主显示
beforenowMs < 10月1日 00:00距离国庆还有X 天 + HH:mm:ss
during10月1日 00:00 ≤ nowMs ≤ 10月7日 23:59:59.999假期进行中第 N 天 + HH:mm:ss
afternowMs > 10月7日 23:59:59.999假期已结束距离下一个国庆还有 X 天

7 张规划卡的默认文案(升旗观礼、周边短途、家人聚餐、博物馆日、自然徒步、收心阅读、返程调整)写在 domain/constants.ets,用户改过的版本存进 Preferences,启动时做合并。

3. 架构:让 Agent 严格分四层

Agent 给出的分层结构非常标准,我故意没改一行:

entry/src/main/ets/
├── domain/         纯逻辑:不 import 任何 @ohos.*
│   ├── constants.ets    OFFSET_MS、DAY_MS、TITLE_MAX_LENGTH、DEFAULT_TITLES、色板
│   ├── countdown.ets    倒计时三态计算
│   ├── planner.ets      默认卡片、用户卡合并、done 计数
│   └── formatting.ets   日期格式化、pad2
├── security/
│   └── sanitize.ets     输入消毒:去控制字符、截断到 30 字(按 codePoint)
├── services/
│   └── storage.ets      Preferences 读写 + 300ms 写防抖
├── ui/
│   ├── CountdownCard.ets
│   ├── DayCard.ets
│   ├── EditDialog.ets
│   └── ProgressBar.ets
└── pages/
    └── Index.ets        @Entry,组合一切

这个分层的价值在写测试时才真正体现出来。

3.1 倒计时:所有「偏移」集中在一处

// domain/constants.ets
export const OFFSET_MS: number = 8 * 3600 * 1000;   // UTC+8 写死

// domain/countdown.ets
export function msAtCST(year, month, day, hour = 0, minute = 0, second = 0, ms = 0): number {
  return Date.UTC(year, month - 1, day, hour, minute, second, ms) - OFFSET_MS;
}

export function phase(nowMs: number, year: number): Phase {
  if (nowMs < nationalDayMs(year)) return 'before';
  if (nowMs <= holidayEndMs(year)) return 'during';
  return 'after';
}

关键设计:

  • 不调用 new Date().getTimezoneOffset(),不依赖设备时区;
  • 所有"现在几点"的比较都先把 nowMs 加上 OFFSET_MS 再按 UTC 解读;
  • domain/ 里禁止 Date.now(),所有函数接收 nowMs 作为参数——这是能被 Node 测试的前提。

computeCountdown 一个函数返回完整结果:

export function computeCountdown(nowMs: number): CountdownResult {
  const year = yearOfNow(nowMs);
  const p = phase(nowMs, year);
  if (p === 'before') {
    const target = nationalDayMs(year);
    const t = hms(nowMs, target);
    return { phase: 'before', days: daysUntil(nowMs, target), /* ... */ };
  }
  if (p === 'during') {
    const target = holidayEndMs(year) + 1;
    const t = hms(nowMs, target);
    return { phase: 'during', days: 0, dayIndex: dayIndex(nowMs, year), /* ... */ };
  }
  // after:指向次年
  const nextYear = year + 1;
  /* ... */
}

UI 层只需要 @State nowMs: number 每秒刷新一次,把 computeCountdown(this.nowMs) 喂给 CountdownCard 即可。

3.2 规划卡:默认 + 用户覆盖

// domain/planner.ets
export function mergeUserCards(
  defaults: readonly DayCardData[],
  user: Record<string, UserCardOverride>
): readonly DayCardData[] {
  return defaults.map((d) => {
    const u = user[d.dateISO];
    if (!u) return d;
    const done = u.done === true;
    const hasTitle = u.title !== undefined && u.title !== '';
    if (!hasTitle) {
      return { dateISO: d.dateISO, title: d.title, isDefault: true, done };
    }
    return { dateISO: d.dateISO, title: u.title as string, isDefault: false, done };
  });
}

isDefault 字段很关键——它让 UI 可以给「默认文案」和「用户自定义文案」不同的颜色(色板里的 auxiliary 绿 vs primary 红),告诉用户「这条是你改过的」。

3.3 持久化:Preferences + 写防抖

// services/storage.ets
const PREFS_KEY: string = 'gw-planner:prefs:v1';
const WRITE_DEBOUNCE_MS: number = 300;

save(prefs: UserPrefs): void {
  this.pending = prefs;
  if (this.writeTimer !== -1) clearTimeout(this.writeTimer);
  this.writeTimer = setTimeout(async () => {
    /* ... 序列化落盘 ... */
  }, WRITE_DEBOUNCE_MS) as number;
}

勾选连续点 7 次只触发一次落盘。load() 时所有数据过 sanitizePrefs 消毒——哪怕是 Preferences 里被手工改坏的数据也不会让 UI 崩。

3.4 输入消毒:把恶意输入挡在 UI 之外

// security/sanitize.ets
export function sanitizeTitle(raw: string | null | undefined): string {
  if (raw === null || raw === undefined) return '';
  let cleaned: string = '';
  const src: string = String(raw);
  for (const ch of src) {
    const cp = ch.codePointAt(0);
    if (cp === undefined) continue;
    if (cp >= 0x0000 && cp <= 0x001F) continue;   // C0 控制字符
    if (cp === 0x007F) continue;                  // DEL
    cleaned += ch;
  }
  const trimmed: string = cleaned.trim();
  if (trimmed === '') return '';
  const cps: string[] = Array.from(trimmed);      // 按 codePoint 切,避免切断 emoji
  if (cps.length <= TITLE_MAX_LENGTH) return trimmed;
  return cps.slice(0, TITLE_MAX_LENGTH).join('');
}

注意 Array.from 而不是 split(''):前者按 Unicode codePoint 切,一个 emoji(比如 🇨🇳,是 4 个 UTF-16 单元)不会被切成两个孤立 surrogate。这是个常见坑,Agent 主动避开了,省得我再提醒。

4. UI:三张组件 + 一个对话框

UI 层完全声明式,没有任何 imperative 操作 DOM 的代码。

4.1 倒计时要"呼吸"

CountdownCard 根据 phase 切换主标题字号和颜色:

Text(this.headline())
  .fontSize(this.result.phase === 'before' ? 56 : 32)
  .fontColor($r('app.color.primary'))
  .fontFamily('monospace')

monospace 是为了让 23:06:13 这种数字每秒跳变时不左右晃动——等宽字体每一位宽度固定,眼睛不会被跳变的字符宽度闪到。这种细节 Agent 没提示就写对了,说明它训练数据里真的吃过倒计时 UI 的经验。

4.2 7 段进度条,不用 Progress 组件

没有用内置 Progress 组件,而是用 Row + ForEach 拼 7 段圆角小柱:

Row() {
  ForEach(this.buildSegments(), (seg: boolean, idx: number) => {
    Column()
      .layoutWeight(1)
      .height(8)
      .backgroundColor(seg ? $r('app.color.progress_segment_done')
                           : $r('app.color.progress_segment_pending'))
      .borderRadius($r('app.float.radius_progress'))
      .margin({ right: idx < this.total - 1 ? 4 : 0 })
  }, (_seg, idx) => `${idx}`)
}
.width('100%')

理由很简单:内置 Progress 是连续的条形,跟「7 天假期」的离散感不匹配;7 段小柱天然传达「7 天」这个数字。

4.3 编辑对话框:实时显示剩余字数

@State private inputText: string = '';

private remaining(): number {
  return TITLE_MAX_LENGTH - sanitizeTitle(this.inputText).length;
}

TextInput({ text: this.inputText, placeholder: '请输入文案,最多 30 字' })
  .onChange((v: string) => { this.inputText = v; })
  .maxLength(30)

Text(`还剩 ${this.remaining()} 字`)
  .fontColor(this.remaining() >= 0 ? $r('app.color.text_secondary')
                                   : $r('app.color.primary'))

注意这里 remaining 调用的是 sanitizeTitle,而不是直接 inputText.length——前者剥掉控制字符再算长度,跟最终落盘的真实数据严格一致,避免 UI 显示"还剩 5 字"但保存时实际只剩 3 字。

5. 单元测试:把 ArkTS 编译成 TS 跑在 Node 里

这是整个工程里我觉得最妙的一步。

ArkTS 是 TS 超集,但带装饰器(@Component、@State)、资源引用($r、$rawfile)、@ohos.* 模块,没法直接给 Node 跑。Agent 给的方案是写一个极简转译器 tests/transpile.mjs:

// tests/transpile.mjs(节选)
// 只处理 domain/ 和 security/,这两个目录按规则不依赖 @ohos.*
// 转译逻辑:剥掉装饰器、保留 export、改后缀 .ets -> .ts

转译产物落在 tests/.build/,然后:

"scripts": {
  "test": "node tests/transpile.mjs && node --experimental-strip-types --test tests/*.test.mjs"
}

--experimental-strip-types 是 Node 22.6+ 引入的特性,直接执行 .ts 文件、扔掉类型注解、不做完整编译。这样 domain/ 里的纯函数就能在 Node 里被真单元测试。

覆盖面:

  • countdown.test.mjs:三态切换、跨年时 after 指向次年、UTC+8 边界(10 月 1 日 00:00 前 1ms / 后 1ms);
  • planner.test.mjs:默认 7 张卡、用户覆盖、空 title 回退默认、done 计数;
  • formatting.test.mjs:pad2、中文日期格式化;
  • sanitize.test.mjs:控制字符、30 字截断、emoji 不切断、null/undefined 安全。
$ npm test
# tests 22
# pass 22
# fail 0

这 22 条用例是后面 DevEco 集成测试的守门员。

6. 踩坑实录:码道 Agent 不知道 DevEco 的工程骨架

代码逻辑写完了,但 File → Open 工程时报错:

加载工程失败,一些基础功能(如编…

排查下来,Agent 输出的工程缺了一批 DevEco 必需的骨架文件:

缺失文件作用
AppScope/app.json5应用级 bundleName / versionCode / icon / label
AppScope/resources/base/element/string.jsonapp_name 字符串
AppScope/resources/base/media/app_icon.png应用图标
hvigor/hvigor-config.json5hvigor modelVersion
entry/src/main/resources/base/media/icon.png模块图标

还有几个隐蔽的错误:

  1. build-profile.json5 里写了 "buildMode": "debug"(应为 "buildModeSet": [...] 数组),schema 直接拒绝;
  2. oh-package.json5 里 modelVersion: "26.0.0" 与 build-profile 里 compatibleSdkVersion: "5.0.0(12)" 不匹配——设备是 HarmonyOS 6 / API 26,根 modelVersion 要跟着抬到 26.0.0;
  3. module.json5 的 ability icon 引用了 $media:app_icon,但 app_icon 在 AppScope 才能用,模块内要用 $media:icon。

更糟的是工程路径含中文——z_pig\华为云码道 AI 编程挑战赛\第十七篇-鸿蒙国庆\ 这一串 hvigor 直接拒绝,报:

Invalid project path. Current path does not match …

只能把整个工程移到纯英文路径 C:\Users\nwu\Desktop\golden-week-planner 才能跑 sync。

经验沉淀:

  • 给码道 Agent 写鸿蒙提示词时,除了业务需求,最好附一句「请同时输出 AppScope/、hvigor/hvigor-config.json5、模块级 media/icon.png 占位图」,否则 DevEco 无法加载;
  • 中文路径是 hvigor 的硬性禁区,参赛时把工程目录放在纯英文路径下;
  • Agent 产出的 modelVersion 字段不一定跟你本地 SDK 一致,遇到 schema 错误先对齐 build-profile 的 compatibleSdkVersion 与根 oh-package.json5 的 modelVersion。

修完以上,DevEco Sync 通过,Pura X View 模拟器一键 Run:

运行总览

7. 实测:从空仓到跑通

在 Pura X 模拟器(HarmonyOS 6,API 26)上实测核心闭环:

自定义文案。点 10 月 1 日「升旗观礼」右侧的「编辑」→ 弹出对话框,实时显示「还剩 26 字」:

编辑对话框

勾选打卡。点第 2、3 天左侧圆圈 → 卡片勾选变红 → 进度条跳到「完成度 3 / 7」:

进度 3/7

继续勾到 5/7,进度条前 5 段全部点亮:

进度 5/7

期间还验证了几个边界:

  • 杀进程重启 → 勾选与自定义文案仍在(Preferences 生效);
  • 把系统时区改成纽约 → 倒计时仍按 UTC+8 计算(OFFSET_MS 写死生效);
  • 输入框粘贴一段含 ANSI 转义序列的字符串 → 控制字符被剥掉、剩下字符按 codePoint 截断到 30。

8. 提交记录

最终 Agent 推送到 AtomGit 的提交:

05198d9 feat: 国庆黄金周倒计时与假期规划卡
fbb5247 chore: add Node .gitignore and enrich README with project sections
70cfb4e chore: 初始化仓库,添加 MIT License 与 README

工程骨架修复(AppScope、hvigor-config、icon、build-profile schema 对齐、根 oh-package 的 modelVersion)我作为后续修复合到了 main 分支。

仓库地址:https://atomgit.com/2501_92427005/golden-week-planner

9. 小结

这篇是系列里第一次让码道 Agent 独立写鸿蒙 ArkTS 工程。三点心得:

  1. 约束比需求重要。把「domain 不 import @ohos.*」「倒计时按 UTC+8 写死」「Preferences 键命名 gw-planner:prefs:v1」这种硬约束写进提示词,Agent 的输出会立刻从「能跑」升级到「可测试、可维护」。

  2. 测试是守门员,不是终点。22 条 Node 单元测试护住了 domain 层,但 DevEco 工程骨架(AppScope、hvigor-config、图标、modelVersion 一致性)Agent 还是会漏。把工程骨架检查也写进 CI 才是终点。

  3. 「打开即工程」是底线。File → Open 必须一次过,这条过不去,前面所有功能都不算数。这次踩的中文路径 + modelVersion 错位 + schema 字段错误三连,下期提示词里我会直接加一条「请输出完整 DevEco 5.0 工程骨架,包括 AppScope 与 hvigor 配置」。

下一个节日是重阳,想试试让码道 Agent 写一个「登高海拔测算器」——结合 GPS 和气压计做实时海拔显示,加上登山进度卡。如果到时还在挑战赛周期内,继续更。


附:参赛信息

  • 应用名称:Golden Week Planner(黄金周规划)
  • 仓库地址:https://atomgit.com/2501_92427005/golden-week-planner
  • 技术栈:HarmonyOS ArkTS + ArkUI 声明式 · API 12+(实测 API 26)· Preferences 持久化 · Node 22 node:test
  • 核心特性:零权限、纯本地、UTC+8 倒计时、7 段进度条、可编辑规划卡、输入消毒、22 条单元测试全绿
Logo

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

更多推荐