前言
工作学习总需要一个不打扰的计时器。今天分享一套纯前端单文件的「番茄钟 + 待办」源码:一个 HTML 双击就能用,本地保存任务与统计,零依赖、可直接部署。
声明:本文源码为原创教学示例,请勿用于违法违规用途;请遵守当地法律法规与目标服务条款。
功能特性
- 专注 25 分钟 / 短休 5 / 长休 15 一键切换
- 倒计时进度条、完成提示音、标题同步剩余时间
- 待办清单:添加、勾选、删除,localStorage 持久化
- 今日统计:专注分钟、番茄个数、任务完成数
- 深色 UI,手机可用,单文件可发给朋友直接用
环境要求
- 任意现代浏览器(Chrome / Edge / Firefox / Safari)
- 无需服务器与构建工具;也可丢到静态空间在线访问
目录结构
pomodoro/
├── index.html # 全部逻辑在这一个文件里
└── README.md
完整源码
index.html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>番茄钟 · 待办</title>
<style>
:root {
--bg: #0f1115;
--panel: #1a1d24;
--border: #2a2f3a;
--text: #e8eaed;
--muted: #8b93a7;
--accent: #f04494;
--ok: #3dd68c;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "PingFang SC", sans-serif;
background: radial-gradient(1200px 500px at 80% -10%, rgba(240,68,148,.18), transparent 60%), var(--bg);
color: var(--text);
min-height: 100vh;
}
.wrap {
max-width: 920px;
margin: 0 auto;
padding: 28px 16px 48px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 22px;
}
.logo {
width: 42px; height: 42px; border-radius: 12px;
background: linear-gradient(135deg, var(--accent), #ff8f3a);
display: grid; place-items: center; font-weight: 800; color: #fff;
}
h1 { margin: 0; font-size: 24px; }
.sub { color: var(--muted); font-size: 13px; }
.grid {
display: grid;
grid-template-columns: 1.1fr .9fr;
gap: 16px;
}
@media (max-width: 820px) {
.grid { grid-template-columns: 1fr; }
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 18px;
padding: 20px;
}
.timer {
text-align: center;
}
.mode {
display: inline-flex;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px;
gap: 4px;
}
.mode button {
border: 0;
background: transparent;
color: var(--muted);
border-radius: 999px;
padding: 8px 14px;
cursor: pointer;
font: inherit;
}
.mode button.active {
background: var(--accent);
color: #fff;
}
.time {
font: 700 64px/1.1 ui-monospace, Consolas, monospace;
letter-spacing: 2px;
margin: 18px 0 8px;
}
.rounds {
color: var(--muted);
font-size: 13px;
margin-bottom: 16px;
}
.btn-row {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
}
button, .btn {
border: 0;
border-radius: 999px;
padding: 11px 18px;
font: inherit;
font-weight: 600;
cursor: pointer;
background: var(--accent);
color: #fff;
}
.btn-ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.progress {
height: 10px;
background: var(--bg);
border-radius: 999px;
overflow: hidden;
margin: 18px 0 8px;
}
.progress > i {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), #ff8f3a);
}
h3 { margin: 0 0 12px; font-size: 16px; }
.input-row {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
input[type="text"] {
flex: 1;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
padding: 11px 12px;
font: inherit;
outline: 0;
}
input[type="text"]:focus { border-color: var(--accent); }
.list { list-style: none; margin: 0; padding: 0; }
.item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 8px;
border-top: 1px solid var(--border);
}
.item:first-child { border-top: 0; }
.item input { accent-color: var(--accent); width: 18px; height: 18px; }
.item.done .title {
text-decoration: line-through;
color: var(--muted);
}
.item .title { flex: 1; word-break: break-word; }
.item .del {
background: transparent;
border: 1px solid var(--border);
color: var(--muted);
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
}
.item .del:hover { color: #ff8f8f; border-color: rgba(255,120,120,.4); }
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 14px;
}
.stat {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
text-align: center;
}
.stat b {
display: block;
font-size: 22px;
color: var(--accent);
}
.stat span { color: var(--muted); font-size: 12px; }
.toast {
position: fixed;
left: 50%;
bottom: 28px;
transform: translateX(-50%) translateY(20px);
background: rgba(240,68,148,.95);
color: #fff;
padding: 10px 16px;
border-radius: 999px;
opacity: 0;
pointer-events: none;
transition: .25s ease;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo">P</div>
<div>
<h1>番茄钟 · 待办</h1>
<div class="sub">专注计时 + 任务清单 · 本地保存 · 零依赖</div>
</div>
</header>
<div class="grid">
<section class="card timer">
<div class="mode" id="mode">
<button data-min="25" class="active">专注 25</button>
<button data-min="5">短休 5</button>
<button data-min="15">长休 15</button>
</div>
<div class="time" id="time">25:00</div>
<div class="rounds" id="rounds">已完成 0 个番茄</div>
<div class="progress"><i id="bar"></i></div>
<div class="btn-row">
<button id="start">开始</button>
<button class="btn-ghost" id="pause">暂停</button>
<button class="btn-ghost" id="reset">重置</button>
</div>
<div class="stats">
<div class="stat"><b id="statMin">0</b><span>今日专注分钟</span></div>
<div class="stat"><b id="statDone">0</b><span>今日番茄数</span></div>
<div class="stat"><b id="statTask">0</b><span>待办完成</span></div>
</div>
</section>
<section class="card">
<h3>今日待办</h3>
<form class="input-row" id="todoForm">
<input id="todoInput" type="text" placeholder="添加任务,回车确认" maxlength="80">
<button type="submit">添加</button>
</form>
<ul class="list" id="todoList"></ul>
</section>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
(function () {
const $ = (id) => document.getElementById(id);
/* ---------- 状态 ---------- */
const LS = {
get(k, d) {
try { return JSON.parse(localStorage.getItem(k)) ?? d; } catch (e) { return d; }
},
set(k, v) { localStorage.setItem(k, JSON.stringify(v)); }
};
let duration = 25 * 60;
let remain = duration;
let timer = null;
let running = false;
let modeMin = 25;
let todos = LS.get('pomo.todos', []);
let stats = LS.get('pomo.stats', { date: todayKey(), min: 0, done: 0 });
function todayKey() {
const d = new Date();
return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate();
}
if (stats.date !== todayKey()) {
stats = { date: todayKey(), min: 0, done: 0 };
}
/* ---------- 计时 ---------- */
function renderTime() {
const m = Math.floor(remain / 60);
const s = remain % 60;
$('time').textContent = String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
const pct = ((duration - remain) / duration) * 100;
$('bar').style.width = pct + '%';
document.title = $('time').textContent + ' · 番茄钟';
}
function toast(msg) {
const el = $('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 1800);
}
function tick() {
if (remain <= 0) {
finish();
return;
}
remain--;
if (modeMin === 25) {
// 专注才计入统计
}
renderTime();
}
function start() {
if (running) return;
running = true;
timer = setInterval(tick, 1000);
toast('开始专注');
}
function pause() {
running = false;
clearInterval(timer);
toast('已暂停');
}
function reset() {
running = false;
clearInterval(timer);
remain = duration;
renderTime();
}
function finish() {
running = false;
clearInterval(timer);
remain = 0;
renderTime();
if (modeMin === 25) {
stats.done += 1;
stats.min += 25;
LS.set('pomo.stats', stats);
renderStats();
$('rounds').textContent = '已完成 ' + stats.done + ' 个番茄';
toast('专注完成!休息一下');
} else {
toast('休息结束,继续加油');
}
// 响铃
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.frequency.value = 880;
g.gain.value = 0.08;
o.start();
o.stop(ctx.currentTime + 0.25);
} catch (e) {}
}
function setMode(min, btn) {
running = false;
clearInterval(timer);
modeMin = min;
duration = min * 60;
remain = duration;
document.querySelectorAll('#mode button').forEach((b) => b.classList.remove('active'));
if (btn) btn.classList.add('active');
renderTime();
}
/* ---------- 待办 ---------- */
function renderTodos() {
const ul = $('todoList');
ul.innerHTML = '';
todos.forEach((t, i) => {
const li = document.createElement('li');
li.className = 'item' + (t.done ? ' done' : '');
li.innerHTML =
'<input type="checkbox" ' + (t.done ? 'checked' : '') + ' data-i="' + i + '">' +
'<span class="title"></span>' +
'<button class="del" data-del="' + i + '">删除</button>';
li.querySelector('.title').textContent = t.text;
ul.appendChild(li);
});
}
function renderStats() {
$('statMin').textContent = stats.min;
$('statDone').textContent = stats.done;
$('statTask').textContent = todos.filter((t) => t.done).length;
$('rounds').textContent = '已完成 ' + stats.done + ' 个番茄';
}
$('start').onclick = start;
$('pause').onclick = pause;
$('reset').onclick = reset;
document.querySelectorAll('#mode button').forEach((b) => {
b.addEventListener('click', () => setMode(parseInt(b.dataset.min, 10), b));
});
$('todoForm').addEventListener('submit', (e) => {
e.preventDefault();
const v = $('todoInput').value.trim();
if (!v) return;
todos.unshift({ text: v, done: false });
$('todoInput').value = '';
LS.set('pomo.todos', todos);
renderTodos();
renderStats();
toast('已添加任务');
});
$('todoList').addEventListener('click', (e) => {
const t = e.target;
if (t.dataset.del !== undefined) {
todos.splice(parseInt(t.dataset.del, 10), 1);
} else if (t.dataset.i !== undefined) {
const i = parseInt(t.dataset.i, 10);
todos[i].done = !todos[i].done;
} else {
return;
}
LS.set('pomo.todos', todos);
renderTodos();
renderStats();
});
renderTime();
renderTodos();
renderStats();
})();
</script>
</body>
</html>
部署步骤
- 下载 zip 解压,双击
index.html本地使用。 - 若要在线访问:上传到任意静态托管(对象存储 / Nginx / GitHub Pages)。
- 可按需修改默认番茄时长(搜索
data-min)。
安全与合规说明
- 数据只存本机浏览器,不上传任何服务器。
- 清理浏览器数据会导致待办与统计丢失,请自行备份。
- 本源码仅供学习交流,请勿用于违法违规用途。
小结
一个 HTML 文件就能落地专注工具。下一步可扩展:多套时长预设、白噪音、周报统计、PWA 离线安装。需要增强版可在评论区留言。
© 版权声明
文章版权归作者所有,未经允许请勿转载。
THE END






暂无评论内容