开发技巧

ChatGPT + Codex 实战:从零制作番茄钟网站并部署到 Cloudflare Pages

用 ChatGPT 做需求和文案,用 OpenAI Codex CLI 在本地写代码,从零做出一个带真实交互功能的番茄钟落地页,最后免费部署到 Cloudflare Pages,拿到一个能分享给别人的网址

BINBHO · 图文教程

用 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 写的代码哪里不一样,学得最快。