国庆不迷路:我用华为云码道 Agent 写了个鸿蒙「黄金周倒计时与假期规划卡」
国庆不迷路:我用华为云码道 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 输出的工程结构:

最终 npm test 22 个用例全绿:

2. 需求拆解:三个阶段、七张卡、一条进度条
应用功能一句话讲完:
屏幕上同时出现两件事——离国庆还有多久,以及假期里每一天打算怎么过。
具体到三态:
| 阶段 | 触发条件 | 顶部文案 | 主显示 |
|---|---|---|---|
before | nowMs < 10月1日 00:00 | 距离国庆还有 | X 天 + HH:mm:ss |
during | 10月1日 00:00 ≤ nowMs ≤ 10月7日 23:59:59.999 | 假期进行中 | 第 N 天 + HH:mm:ss |
after | nowMs > 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.json | app_name 字符串 |
AppScope/resources/base/media/app_icon.png | 应用图标 |
hvigor/hvigor-config.json5 | hvigor modelVersion |
entry/src/main/resources/base/media/icon.png | 模块图标 |
还有几个隐蔽的错误:
build-profile.json5里写了"buildMode": "debug"(应为"buildModeSet": [...]数组),schema 直接拒绝;oh-package.json5里modelVersion: "26.0.0"与build-profile里compatibleSdkVersion: "5.0.0(12)"不匹配——设备是 HarmonyOS 6 / API 26,根modelVersion要跟着抬到 26.0.0;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」:

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

期间还验证了几个边界:
- 杀进程重启 → 勾选与自定义文案仍在(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 工程。三点心得:
-
约束比需求重要。把「domain 不 import @ohos.*」「倒计时按 UTC+8 写死」「Preferences 键命名
gw-planner:prefs:v1」这种硬约束写进提示词,Agent 的输出会立刻从「能跑」升级到「可测试、可维护」。 -
测试是守门员,不是终点。22 条 Node 单元测试护住了 domain 层,但 DevEco 工程骨架(AppScope、hvigor-config、图标、modelVersion 一致性)Agent 还是会漏。把工程骨架检查也写进 CI 才是终点。
-
「打开即工程」是底线。
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 条单元测试全绿
更多推荐


所有评论(0)