用 ChatGPT 做需求和文案,用 OpenAI Codex CLI 在本地写代码,从零做出一个带真实交互功能的番茄钟落地页,最后免费部署到 Cloudflare Pages,拿到一个能分享给别人的网址。
先看清这个项目做成什么样
成品是一个单页网站 Focus:顶部是标题和一句话介绍,中间是能开始、暂停、重置的 25/5 分钟番茄钟,并统计当天完成次数,下方是三张功能卡片。纯 HTML、CSS、JavaScript,不需要构建工具。文中代码已在本地浏览器实际运行并通过测试。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 一个线上网站 | 地址形如 你的项目名.pages.dev |
| 一个番茄钟功能 | 开始、暂停、重置,专注与休息自动切换 |
| 本地数据 | 每天完成的番茄数存在浏览器里,第二天自动从 0 开始 |
| 一套可复用流程 | ChatGPT 规划,Codex 编码,测试验收,一条命令部署 |
整个过程大约 1 到 2 小时,大部分时间花在和 AI 来回修改上。
需要准备哪些环境?
| 项目 | 要求 | 检查命令 |
|---|---|---|
| 电脑 | macOS、Linux 或 Windows | — |
| Node.js | 22 或更高(部署工具 Wrangler 的要求) | node --version |
| Git | 任意较新版本 | git --version |
| ChatGPT 账号 | 用于规划和登录 Codex | — |
| Cloudflare 账号 | 免费账号即可 | — |
| 浏览器 | Chrome、Edge 或 Firefox | — |
注意:Node.js 低于 22 时,
npx wrangler会直接报错退出。先升级 Node 再往下做。
按 OpenAI 帮助中心的最新说明,Codex 已包含在各档 ChatGPT 套餐中(含 Free 和 Go),本地 CLI 都能用,只是不同套餐的用量额度不同,以官方说明为准。也可以用 API Key 登录,按 API 用量计费。
第一步:怎样用 ChatGPT 把需求想清楚?
不要一上来就让 AI 写代码。先花 10 分钟让 ChatGPT 帮你把需求、页面结构和文案定下来,后面 Codex 写出来的东西会准得多。
1. 发送需求梳理提示词
把下面这段完整粘贴到 ChatGPT:
我想做一个极简番茄钟网站,名字叫 Focus,目标用户是需要专注学习或工作的学生和上班族。
要求:
1. 单页面,纯 HTML/CSS/JavaScript,不用任何框架和构建工具
2. 核心功能:25 分钟专注、5 分钟休息,可开始、暂停、重置,到点自动切换
3. 统计当天完成的番茄数,存在浏览器本地,不需要登录
4. 深色主题,手机和电脑都要好看
请你输出:
- 页面从上到下的区块结构
- 每个区块的中文文案(标题不超过 8 个字,描述不超过 30 个字)
- 功能验收清单(我会用它逐条检查成品)
不要写代码。
2. 检查结果
你应该拿到三部分:区块结构、文案、验收清单。
验收清单最重要,下面是整理后的版本,后面每一步都会用到:
| 编号 | 验收项 |
|---|---|
| A1 | 打开页面显示 25:00 和“专注中” |
| A2 | 点开始后每秒递减,按钮变成“暂停” |
| A3 | 暂停后时间不再变化,再点开始从暂停处继续 |
| A4 | 重置回到当前模式的完整时长 |
| A5 | 专注结束自动切到休息 5:00,完成数加 1 |
| A6 | 刷新页面后当天完成数不丢 |
| A7 | 切到别的标签页几分钟再回来,时间依然准确 |
| A8 | 手机宽度下布局不乱 |
3. 让 ChatGPT 写给 Codex 的项目说明
接着在同一个对话里发:
根据上面的内容,帮我写一份 AGENTS.md,给 AI 编程助手看。
包含:项目目标、技术约束、目录结构、代码风格、测试要求、完成标准。
控制在 40 行以内,用中文。
把输出保存下来,下一步会放进项目目录。
第二步:怎样安装并登录 Codex CLI?
1. 安装
macOS 或 Linux 用官方安装脚本:
curl -fsSL https://chatgpt.com/codex/install.sh | sh
已经装了 Node.js 的话,也可以用 npm:
npm install -g @openai/codex
Windows 在新的 PowerShell 窗口里执行:
powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"
2. 检查安装
codex --version
能打印出版本号就说明装好了。
3. 建项目目录并初始化 Git
mkdir focus && cd focus
git init
注意:一定要用 Git。官方建议在每次让 Codex 动手前后都做一次提交,改坏了可以随时退回。
4. 启动并登录
codex
第一次运行时选择 Sign in with ChatGPT,浏览器里授权后回到终端。
5. 确认会话设置
在 Codex 里输入:
/status
可以看到当前模型、权限模式和可写目录。想换模型就输入 /model,想调整权限就输入 /permissions。
第三步:怎样让 Codex 写出整个网站?
1. 放入 AGENTS.md
退出 Codex(或另开一个终端),把 ChatGPT 生成的说明保存成项目根目录下的 AGENTS.md。下面是本项目实际使用的版本:
# Focus 项目说明
## 目标
极简番茄钟落地页:25 分钟专注 / 5 分钟休息,统计当天完成数。
## 技术约束
- 纯 HTML + CSS + 原生 JavaScript(ES Module),不引入任何框架或 npm 运行时依赖
- 所有网站文件放在 site/ 目录,部署时只上传这个目录
- 计时逻辑放在 site/timer.js,不访问 DOM;页面交互放在 site/app.js
## 代码要求
- 计时基于“结束时间戳”计算剩余时间,不要靠 setInterval 每次减 1
- 中文界面,深色主题,移动端优先
- 按钮要有键盘焦点样式
## 测试
- 用 node --test 测试 timer.js,测试文件放在根目录 timer.test.mjs
- 每次改完代码都要运行测试并报告结果
## 完成标准
- 测试全部通过
- 本地起服务后页面能正常打开、计时
也可以在 Codex 里输入 /init 让它自动生成一份草稿,再用 ChatGPT 的版本替换或合并。
2. 先提交一次
git add AGENTS.md && git commit -m "项目说明"
3. 发送建站提示词
启动 codex,粘贴下面的提示词:
请按 AGENTS.md 实现 Focus 网站。
页面结构从上到下:
1. 标题区:大标题“Focus”,副标题“打开就能用的番茄钟。25 分钟专注,5 分钟休息,不用注册。”
2. 计时卡片:模式文字(专注中/休息一下)、大号时间 MM:SS、“开始/暂停”按钮和“重置”按钮、“今天完成 N 个番茄”
3. 三张功能卡片:零注册 / 本地保存 / 不漂移,各配一句说明
4. 页脚版权
要求:
- timer.js 导出 formatTime(seconds) 和 createTimer({ focusMin, breakMin, now }),now 可注入方便测试
- 完成数按日期存 localStorage,第二天自动从 0 开始
- 浏览器标签标题同步显示剩余时间
- 写好 timer.test.mjs 和 package.json(type 设为 module,test 脚本为 node --test)
做完后运行测试,并列出你创建的所有文件。
4. 审批它要执行的操作
Codex 会先读目录、再创建文件、最后运行测试。默认权限下,写文件和执行命令前它会请求确认。
逐条看清楚再同意。它要运行的应该只是 node --test 这类命令。
5. 检查生成的文件结构
完成后应当得到这样的结构:
focus/
├── AGENTS.md
├── package.json
├── timer.test.mjs
└── site/
├── index.html
├── style.css
├── timer.js
└── app.js
在 Codex 里输入 /diff 可以查看它改动的全部内容。
关键代码长什么样?
AI 每次生成的代码细节会不一样。下面是本项目最终验收通过的版本,可以拿来对照,或者直接用。
1. 计时逻辑 site/timer.js
核心思路是记录“结束时间戳”,每次显示时用结束时间减当前时间。这样即使浏览器把后台标签页的定时器降频,回来时时间也是准的。
// 纯逻辑:不碰 DOM,方便测试
export function formatTime(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds));
const m = String(Math.floor(s / 60)).padStart(2, "0");
const r = String(s % 60).padStart(2, "0");
return `${m}:${r}`;
}
export function createTimer({ focusMin = 25, breakMin = 5, now = () => Date.now() } = {}) {
let mode = "focus";
let remaining = focusMin * 60;
let endAt = null; // 运行中时记录结束时间戳,避免 setInterval 漂移
const durationOf = (m) => (m === "focus" ? focusMin : breakMin) * 60;
return {
get mode() { return mode; },
get running() { return endAt !== null; },
remaining() {
if (endAt === null) return remaining;
return Math.max(0, Math.ceil((endAt - now()) / 1000));
},
start() { if (endAt === null) endAt = now() + remaining * 1000; },
pause() { if (endAt !== null) { remaining = this.remaining(); endAt = null; } },
reset() { endAt = null; remaining = durationOf(mode); },
// 每次 tick 调用;到点返回 true 并切换模式
tick() {
if (endAt !== null && this.remaining() === 0) {
mode = mode === "focus" ? "break" : "focus";
endAt = null;
remaining = durationOf(mode);
return true;
}
return false;
},
};
}
2. 页面交互 site/app.js
import { createTimer, formatTime } from "./timer.js";
const $ = (id) => document.getElementById(id);
const timer = createTimer({ focusMin: 25, breakMin: 5 });
const todayKey = () => `focus-done-${new Date().toLocaleDateString("sv-SE")}`;
let done = Number(localStorage.getItem(todayKey()) || 0);
function render() {
const text = formatTime(timer.remaining());
$("time").textContent = text;
$("mode").textContent = timer.mode === "focus" ? "专注中" : "休息一下";
$("toggle").textContent = timer.running ? "暂停" : "开始";
$("done").textContent = done;
document.title = `${text} · Focus`;
}
$("toggle").addEventListener("click", () => {
timer.running ? timer.pause() : timer.start();
render();
});
$("reset").addEventListener("click", () => { timer.reset(); render(); });
setInterval(() => {
const wasFocus = timer.mode === "focus";
if (timer.tick() && wasFocus) {
done += 1;
localStorage.setItem(todayKey(), String(done));
}
render();
}, 250);
render();
3. 页面结构 site/index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Focus · 极简番茄钟</title>
<meta name="description" content="打开即用的番茄钟,无需注册,数据只存在你的浏览器里。">
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="hero">
<h1>Focus</h1>
<p>打开就能用的番茄钟。25 分钟专注,5 分钟休息,不用注册。</p>
</header>
<main>
<section class="timer" aria-live="polite">
<p id="mode">专注中</p>
<p id="time" class="time">25:00</p>
<div class="actions">
<button id="toggle" type="button">开始</button>
<button id="reset" type="button" class="ghost">重置</button>
</div>
<p class="stat">今天完成 <strong id="done">0</strong> 个番茄</p>
</section>
<section class="features">
<article><h2>零注册</h2><p>不收集任何个人信息。</p></article>
<article><h2>本地保存</h2><p>完成次数存在浏览器 localStorage。</p></article>
<article><h2>不漂移</h2><p>按结束时间计算,切到后台也准。</p></article>
</section>
</main>
<footer>© 2026 Focus</footer>
<script type="module" src="app.js"></script>
</body>
</html>
样式文件 site/style.css 约 30 行,主要是深色配色、居中卡片、圆角按钮和三列自适应网格,交给 Codex 生成即可。
注意:
app.js用了import,所以页面里的 script 标签必须写type="module",并且必须通过本地服务器打开,不能直接双击 HTML 文件。
4. 测试文件 timer.test.mjs
import test from "node:test";
import assert from "node:assert/strict";
import { createTimer, formatTime } from "./site/timer.js";
test("formatTime", () => {
assert.equal(formatTime(1500), "25:00");
assert.equal(formatTime(65), "01:05");
assert.equal(formatTime(-3), "00:00");
});
test("倒计时、暂停与切换模式", () => {
let t = 0;
const timer = createTimer({ focusMin: 1, breakMin: 1, now: () => t });
timer.start();
t = 30_000;
assert.equal(timer.remaining(), 30);
timer.pause();
t = 100_000;
assert.equal(timer.remaining(), 30);
timer.start();
t = 130_000;
assert.equal(timer.tick(), true);
assert.equal(timer.mode, "break");
assert.equal(timer.remaining(), 60);
});
now 被替换成一个可以手动拨动的假时钟,所以测试不需要真的等 25 分钟。
第四步:怎样在本地验收?
1. 跑测试
npm test
期望输出里有 pass 2、fail 0。
2. 起本地服务器
任选一种:
python3 -m http.server 8080 -d site
npx serve site
浏览器打开 http://localhost:8080(用 serve 时以终端打印的地址为准)。
3. 按验收清单逐条检查
| 编号 | 怎么验 | 通过标准 |
|---|---|---|
| A1 | 直接打开 | 显示 25:00、专注中 |
| A2 | 点开始 | 每秒减 1,按钮变“暂停” |
| A3 | 暂停等 10 秒再开始 | 从暂停时的数字继续 |
| A4 | 点重置 | 回到 25:00 |
| A5 | 临时把 focusMin 改成 0.1 | 约 6 秒后切到休息,完成数变 1 |
| A6 | 刷新页面 | 完成数仍是 1 |
| A7 | 开始后切走 2 分钟 | 回来时少了约 2 分钟 |
| A8 | 开发者工具切到手机尺寸 | 卡片变成一列,按钮不溢出 |
注意:A5 测完记得把 focusMin 改回 25。也可以直接让 Codex 加一个仅本地生效的调试参数,但上线前要删掉。
4. 提交
git add -A && git commit -m "Focus 第一版"
出问题时该怎么跟 AI 说?
修 bug 的关键是给 AI 三样东西:现象、期望、证据(报错原文或复现步骤)。不要只说“不能用了”。
1. 直接双击 HTML 打开,按钮没反应
浏览器控制台报跨域错误,因为 ES Module 不能从 file:// 加载。这其实不是代码 bug。
我双击 index.html 打开后按钮没反应,控制台报错:
Access to script at 'file:///.../app.js' from origin 'null' has been blocked by CORS policy
这是不是因为 ES Module 必须通过服务器访问?请确认原因,
并在 README.md 里写清楚本地预览方法,不要为此改成非模块写法。
2. 完成数第二天没有清零
第一版代码常把完成数存在一个固定的 key 里,结果“今天完成”变成了“累计完成”。
“今天完成 N 个番茄”在第二天没有清零。期望:每天从 0 开始。
请把 localStorage 的 key 改成包含本地日期的形式,例如 focus-done-2026-10-21,
旧数据不用迁移。改完运行测试,并告诉我怎么在浏览器里手动验证。
本文的最终代码用 toLocaleDateString("sv-SE") 生成 YYYY-MM-DD 格式的本地日期,就是这样修出来的。
3. 切到后台再回来,时间慢了
如果 AI 写成了每秒 remaining -= 1,后台标签页的定时器会被浏览器降频,时间就会变慢。
计时在切到其他标签页几分钟后回来会变慢。
请改为记录结束时间戳、每次渲染时用 结束时间 - Date.now() 计算剩余秒数,
并在 timer.test.mjs 里加一个用假时钟验证暂停和继续的测试。
4. 改坏了想重来
git diff # 看改了什么
git checkout -- . # 放弃所有未提交的改动
注意:
git checkout -- .会丢掉所有未提交的修改,执行前确认没有想保留的内容。
5. 让 Codex 自己审一遍
/review
它会检查当前工作区的改动并列出问题,不会直接修改文件。
第五步:怎样免费部署到 Cloudflare Pages?
这里用 Cloudflare Pages 的 Direct Upload,直接把本地 site/ 目录传上去。
1. 登录 Wrangler
npx wrangler login
浏览器会打开 Cloudflare 授权页,同意后回到终端。
2. 创建项目
npx wrangler pages project create
按提示输入项目名,例如 focus-timer-demo,生产分支填 main。
网站地址就是 项目名.pages.dev。名字被占用时 Cloudflare 会在后面加几个随机字符。
3. 部署
npx wrangler pages deploy site --project-name=focus-timer-demo
成功后终端会打印部署地址。打开它,再按验收清单跑一遍 A1 到 A8。
4. 以后更新网站
改完代码、测试通过、提交后,重复第 3 步的命令即可。
想先发一个预览版:
npx wrangler pages deploy site --project-name=focus-timer-demo --branch=preview
预览地址形如 preview.focus-timer-demo.pages.dev,不影响正式站。
注意:用 Direct Upload 创建的项目以后不能改成 Git 自动部署。想要推送代码就自动上线,需要另建一个连接 Git 仓库的项目。
5. 绑定自己的域名(可选)
在 Cloudflare 控制台进入 Workers & Pages,选中项目,打开 Custom domains,按提示添加域名。
域名已经托管在 Cloudflare 时,DNS 记录会自动配置;托管在别处时,需要按页面提示添加 CNAME 记录。
常见报错怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
Wrangler requires at least Node.js v22.0.0 |
Node 版本太低 | 升级到 Node 22 或更高 |
codex: command not found |
安装目录不在 PATH 中 | 重开终端,或按安装脚本提示把目录加入 PATH |
| Codex 说没权限写文件 | 当前是只读模式 | 输入 /permissions 切到允许编辑的模式 |
| 双击 HTML 按钮无反应 | ES Module 不能从 file:// 加载 | 用本地服务器打开 |
控制台报 does not provide an export named |
timer.js 和 app.js 的函数名对不上 | 把报错原文发给 Codex,让它统一导出名 |
测试报 Cannot use import statement outside a module |
package.json 缺少 "type": "module" |
补上该字段 |
| 部署后访问是 404 | 上传错了目录,index.html 不在根上 | 确认部署命令的目录是 site |
| 线上样式丢失 | HTML 里用了绝对路径或写错文件名 | 用 style.css 这样的相对路径,注意大小写 |
| 完成数刷新后丢失 | 用了无痕模式,或 key 写错 | 用普通窗口测试,检查 localStorage 里的 key |
启动报 approval_policy = "untrusted" is no longer supported |
旧配置里还留着已废弃的审批策略(0.149.0 起不再支持) | 删掉 ~/.codex/config.toml 里的这一行,需要保守设置时改用 sandbox_mode = "read-only" 加 approval_policy = "on-request" |
| Codex 用到一半提示额度用完 | 达到套餐用量上限 | 在 Codex 里输入 /status 查看额度和重置时间 |
wrangler login 后仍提示未认证 |
授权没完成或登录了别的账号 | 运行 npx wrangler whoami 确认账号 |
做完之后还能怎么扩展?
每次只让 Codex 做一个功能,做完就测、就提交,比一次提一堆需求成功率高得多。
| 扩展方向 | 可以直接发给 Codex 的提示词 |
|---|---|
| 自定义时长 | “加一个设置面板,可以把专注和休息时长改成 15 到 60 分钟,保存到 localStorage” |
| 结束提醒 | “专注结束时播放一声提示音,并用 Notification API 弹通知,用户未授权时不报错” |
| 英文版 | “加中英文切换,文案放在一个对象里,默认跟随浏览器语言” |
| 离线可用 | “加 Service Worker,让网站断网后也能打开” |
| 数据导出 | “把最近 30 天每天完成数导出成 CSV” |
项目资源
| 用途 | 地址 |
|---|---|
| ChatGPT | https://chatgpt.com |
| Codex CLI 文档 | https://developers.openai.com/codex/cli |
| Codex CLI 源码与安装说明 | https://github.com/openai/codex |
| Codex 套餐说明 | https://help.openai.com/en/articles/11369540-codex-in-chatgpt |
| Cloudflare Pages 直接上传 | https://developers.cloudflare.com/pages/get-started/direct-upload/ |
| Wrangler Pages 命令 | https://developers.cloudflare.com/workers/wrangler/commands/pages/ |
| Pages 自定义域名 | https://developers.cloudflare.com/pages/configuration/custom-domains/ |
| Node.js 下载 | https://nodejs.org |
建议先不装 Codex,直接把上面的四个关键文件复制到本地,跑一遍 npm test 和本地服务器,确认番茄钟能正常工作。
心里有了成品的样子,再从第一步开始让 ChatGPT 和 Codex 从零做一遍,对照着看 AI 写的代码哪里不一样,学得最快。