纯前端单文件番茄钟 + 待办源码|本地存储·专注统计(解压即用)

前言

工作学习总需要一个不打扰的计时器。今天分享一套纯前端单文件的「番茄钟 + 待办」源码:一个 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>

📦 源码下载

下载 pomodoro-todo.zip(完整可用源码)

下载解压后按文中「部署步骤」即可运行。若正文代码与压缩包不一致,以压缩包为准。

部署步骤

  1. 下载 zip 解压,双击 index.html 本地使用。
  2. 若要在线访问:上传到任意静态托管(对象存储 / Nginx / GitHub Pages)。
  3. 可按需修改默认番茄时长(搜索 data-min)。

安全与合规说明

  • 数据只存本机浏览器,不上传任何服务器。
  • 清理浏览器数据会导致待办与统计丢失,请自行备份。
  • 本源码仅供学习交流,请勿用于违法违规用途。

小结

一个 HTML 文件就能落地专注工具。下一步可扩展:多套时长预设、白噪音、周报统计、PWA 离线安装。需要增强版可在评论区留言。

© 版权声明
THE END
喜欢就支持一下吧
点赞7 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容