PageSourceSearch

https://github.nb-channel.top/js/pomodoro.js?v=7

js nb-channel.top collected 2026-10-06 13:15:45 UTC 22,909 bytes, 468 lines download raw bytes

1/* ============================================================
2   NB频道 · 番茄钟(全局悬浮)
3   ------------------------------------------------------------
4   只在【护眼主题】下出现。挂在右下角,哪个页面都在,
5   切页面不会重置 —— 关键是不存「还剩多少秒」,而是存
6   【这一轮什么时候结束】的绝对时间戳,切页后按时间戳重算。
7
8   番茄钟规则(经典的那套):
9     专注 25 分钟 → 短休息 5 分钟
10     每完成 4 轮专注 → 长休息 15 分钟
11
12   状态存 localStorage:
13     nb_pomo = { phase, endsAt, running, remain, rounds, day }
14       phase   work | short | long
15       endsAt  本轮结束的绝对时刻(毫秒)
16       remain  暂停时剩下的毫秒数(暂停才用得到)
17       rounds  今天已完成几轮专注
18       day     记录属于哪一天,跨天自动清零
19   ============================================================ */
20(function () {
21    'use strict';
22
23    var KEY = 'nb_pomo';
24    var DUR = { work: 25 * 60 * 1000, short: 5 * 60 * 1000, long: 15 * 60 * 1000 };
25    var NAME = { work: '专注中', short: '短休息', long: '长休息' };
26    var CSS_ID = 'nbPomoCss';
27    var BOX_ID = 'nbPomoBox';
28
29    /* ---------- 主题判断 ---------- */
30    function isEyecare() {
31        return document.documentElement.getAttribute('theme') === 'eyecare';
32    }
33
34    /* ---------- 状态 ---------- */
35    function today() {
36        var d = new Date();
37        return d.getFullYear() + '-' + (d.getMonth() + 1) + '-' + d.getDate();
38    }
39    function load() {
40        var st = null;
41        try { st = JSON.parse(localStorage.getItem(KEY) || 'null'); } catch (e) {}
42        if (!st || typeof st !== 'object') st = {};
43        if (st.day !== today()) { st.rounds = 0; st.day = today(); }
44        if (st.phase !== 'work' && st.phase !== 'short' && st.phase !== 'long') st.phase = 'work';
45        if (typeof st.running !== 'boolean') st.running = false;
46        if (typeof st.rounds !== 'number') st.rounds = 0;
47        if (typeof st.remain !== 'number' || st.remain <= 0) st.remain = -1;   /* 占位,下面按 min 重算 */
48        if (typeof st.endsAt !== 'number') st.endsAt = 0;
49        /* 自定义的三段时长(分钟),没设过就用默认的 25/5/15 */
50        if (!st.min || typeof st.min !== 'object') st.min = {};
51        ['work', 'short', 'long'].forEach(function (k) {
52            var v = parseInt(st.min[k], 10);
53            if (!(v >= 1 && v <= 180)) st.min[k] = DUR[k] / 60000;
54        });
55        if (typeof st.total !== 'number') st.total = 0;   /* 今日专注总分钟 */
56        if (st.remain === -1) st.remain = durOf(st, st.phase);
57        return st;
58    }
59    function save(st) {
60        try { localStorage.setItem(KEY, JSON.stringify(st)); } catch (e) {}
61    }
62    /* 当前还剩多少毫秒 */
63    function remainOf(st) {
64        if (!st.running) return st.remain;
65        var r = st.endsAt - Date.now();
66        return r > 0 ? r : 0;
67    }
68    /* 取某一阶段当前的时长(毫秒),以用户设置为准 */
69    function durOf(st, phase) {
70        return (st.min && st.min[phase] ? st.min[phase] : DUR[phase] / 60000) * 60000;
71    }
72    function fmt(ms) {
73        var s = Math.max(0, Math.ceil(ms / 1000));
74        var m = Math.floor(s / 60);
75        s = s % 60;
76        return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
77    }
78
79    /* ---------- 推进到下一阶段 ---------- */
80    function advance(st) {
81        if (st.phase === 'work') {
82            st.rounds += 1;
83            st.total += st.min.work;                    /* 累计今日专注分钟 */
84            st.phase = (st.rounds % 4 === 0) ? 'long' : 'short';
85        } else {
86            st.phase = 'work';
87        }
88        st.remain = durOf(st, st.phase);
89        st.endsAt = Date.now() + st.remain;
90        st.running = true;
91    }
92
93    /* ---------- 样式 ---------- */
94    var CSS = [
95        /* bottom 要避开页面右下角的「回到顶部」按钮:
96           它是 fixed; right:26px; bottom:26px; 46x46,
97           也就是占到 72px 高。所以这里从 88px 起,
98           正好压在它上面,中间留 16px 空隙。
99           移动端它变成 right:18px; bottom:18px; 42x42(占 60px),
100           对应挪到 76px。 */
101        '#nbPomoBox{position:fixed;right:24px;bottom:88px;z-index:9998;',
102        '  font-family:system-ui,-apple-system,"Microsoft YaHei",sans-serif;}',
103        /* 悬浮小球 */
104        '.nb-pomo-ball{width:52px;height:52px;border-radius:50%;cursor:pointer;',
105        '  border:none;padding:0;font-size:24px;line-height:1;',
106        '  background:radial-gradient(circle at 34% 30%,#ff8b7a,#c0392b 70%);',
107        '  box-shadow:0 6px 20px -6px rgba(140,47,35,.7),0 2px 6px rgba(0,0,0,.25);',
108        '  transition:transform .18s ease;display:flex;align-items:center;justify-content:center;}',
109        '.nb-pomo-ball:hover{transform:scale(1.07);}',
110        '.nb-pomo-ball .ring{position:absolute;inset:-4px;border-radius:50%;',
111        '  border:2px solid rgba(140,47,35,.35);}',
112        '.nb-pomo-ball .mini{position:absolute;bottom:-2px;right:-2px;',
113        '  background:#3a3630;color:#f4f0e6;font-size:10px;font-weight:700;',
114        '  padding:1px 5px;border-radius:8px;letter-spacing:.5px;}',
115        /* 展开的面板 */
116        /* 面板要给实底:护眼主题那边的规则会覆盖背景类属性,
117           不加 !important 会变半透明,后面的正文透上来很糊 */
118        '.nb-pomo-panel{position:absolute;right:0;bottom:68px;width:250px;',
119        '  background:#fdfbf6 !important;border:1px solid rgba(58,54,48,.2) !important;',
120        '  border-radius:14px;
120box-shadow:0 20px 48px -20px rgba(40,34,26,.55);',
121        '  padding:18px 18px 14px;display:none;}',
122        '.nb-pomo-panel.open{display:block;animation:nbPomoIn .18s ease-out;}',
123        '@keyframes nbPomoIn{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:none;}}',
124        '.nb-pomo-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;}',
125        '.nb-pomo-head .t{font-size:.78rem;letter-spacing:2px;color:#6b6459;font-weight:700;}',
126        '.nb-pomo-head .x{border:none;background:transparent;cursor:pointer;',
127        '  color:#8b8375;font-size:1.1rem;line-height:1;padding:2px 4px;}',
128        '.nb-pomo-head .x:hover{color:#8c2f23;}',
129        '.nb-pomo-mid{display:flex;align-items:center;gap:14px;}',
130        '.nb-pomo-ring{position:relative;width:96px;height:96px;flex:0 0 auto;}',
131        '.nb-pomo-ring svg{display:block;transform:rotate(-90deg);}',
132        '.nb-pomo-ring .tk{fill:none;stroke:rgba(58,54,48,.14);stroke-width:8;}',
133        '.nb-pomo-ring .br{fill:none;stroke:#8c2f23;stroke-width:8;stroke-linecap:round;',
134        '  transition:stroke-dashoffset .95s linear,stroke .35s;}',
135        '.nb-pomo-ring.rest .br{stroke:#7a8a55;}',
136        '.nb-pomo-ring .time{position:absolute;inset:0;display:flex;align-items:center;',
137        '  justify-content:center;font-size:1.32rem;font-weight:300;color:#2e2a24 !important;',
138        '  font-variant-numeric:tabular-nums;letter-spacing:1px;}',
139        '.nb-pomo-side .ph{font-size:.92rem;font-weight:800;color:#2e2a24 !important;letter-spacing:2px;}',
140        '.nb-pomo-side .rd{margin-top:8px;font-size:.72rem;color:#7a7266;line-height:1.8;}',
141        '.nb-pomo-side .rd b{color:#8c2f23;}',
142        '.nb-pomo-btns{display:flex;gap:6px;margin-top:14px;flex-wrap:wrap;}',
143        '.nb-pomo-btns button{flex:1;min-width:58px;padding:7px 6px;border-radius:7px;cursor:pointer;',
144        '  font-family:inherit;font-size:.72rem;letter-spacing:.5px;border:none;',
145        '  background:#6b5a3e;color:#e0d9cb;}',
146        '.nb-pomo-btns button:hover{background:#544730;}',
147        '.nb-pomo-btns button.gh{background:transparent;color:#6b5a3e;',
148        '  border:1px solid rgba(107,90,62,.4);}',
149        '.nb-pomo-btns button.gh:hover{background:rgba(107,90,62,.12);}',
150        '.nb-pomo-tip{margin-top:10px;font-size:.68rem;color:#8b8375;line-height:1.7;}',
151        /* 内嵌模式(首页那块):整行拉宽,横向排布 */
152        /* 内嵌模式下小球要显示,但那个浮动面板不能冒出来
153           (内容已经在内嵌那块里了,再来一个就重复) */
154        '#nbPomoBox.nb-pomo-inline .nb-pomo-panel{',
155        '  position:relative;right:auto;bottom:auto;width:100%;max-width:none;',
156        '  display:block !important;animation:none;}',
157        '#nbPomoBox.nb-pomo-inline{position:relative;right:auto;bottom:auto;',
158        '  width:100%;display:block;}',
159        '#nbPomoBox.nb-pomo-inline .nb-pomo-ball{',
160        '  position:fixed;right:24px;bottom:88px;z-index:9998;}',
161        '#nbPomoBox.nb-pomo-inline .nb-pomo-panel{padding:26px 30px 22px;}',
162        '#nbPomoBox.nb-pomo-inline .nb-pomo-ring{width:126px;height:126px;}',
163        '#nbPomoBox.nb-pomo-inline .nb-pomo-ring svg{width:126px;height:126px;}',
164        '#nbPomoBox.nb-pomo-inline .nb-pomo-ring .time{font-size:1.85rem;}',
165        '#nbPomoBox.nb-pomo-inline .nb-pomo-side .ph{font-size:1.12rem;}',
166        /* 横向三段:圆环 | 状态与记录 | 按钮 */
167        '#nbPomoBox.nb-pomo-inline .nb-pomo-mid{gap:34px;}',
168        '#nbPomoBox.nb-pomo-inline .nb-pomo-body{display:flex;align-items:center;',
169        '  justify-content:space-between;gap:34px;flex-wrap:wrap;}',
170        '#nbPomoBox.nb-pomo-inline .nb-pomo-side{flex:1;min-width:190px;}',
171        '#nbPomoBox.nb-pomo-inline .nb-pomo-btns{margin-top:0;flex:0 0 auto;',
172        '  flex-direction:column;min-width:120px;}',
173        '#nbPomoBox.nb-pomo-inline .nb-pomo-btns button{width:100%;padding:9px 18px;',
174        '  font-size:.78rem;}',
175        '#nbPomoBox.nb-pomo-inline .nb-pomo-head .t{font-size:.86rem;}',
176        '#nbPomoBox.nb-pomo-inline .nb-pomo-head .stat{font-size:.78rem;color:#6b6459;}',
177        '#nbPomoBox.nb-pomo-inline .nb-pomo-head .stat b{color:#8c2f23;font-size:.95rem;}',
178        /* 今日番茄记录点 */
179        '.nb-pomo-dots{display:flex;gap:5px;margin-top:11px;flex-wrap:wrap;}',
180        '.nb-pomo-dots i{width:13px;height:13px;border-radius:50%;display:block;',
181        '  background:rgba(58,54,48,.16);}',
182        '.nb-pomo-dots i.on{background:#c0392b;}',
183        '.nb-pomo-dots i.now{box-shadow:0 0 0 2px rgba(192,57,43,.3);}',
184        /* 时长设置 */
185        '.nb-pomo-cfg{display:flex;gap:20px;flex-wrap:wrap;margin-top:18px;',
186        '  padding-top:16px;border-top:1px dashed rgba(58,54,48,.16);',
187        '  font-size:.74rem;color:#7a7266;align-items:center;}',
188        '.nb-pomo-cfg label{display:flex;align-items:center;gap:7px;}',
189        '.nb-pomo-cfg input{width:56px;padding:5px 7px;border-radius:6px;text-align:center;',
190        '  border:1px solid rgba(58,54,48,.24);background:#fff;color:#3a3630;',
191        '  font-family:inherit;font-size:.78rem;outline:none;}',
192        '.nb-pomo-cfg input:focus{border-color:#8c2f23;}',
193        '.nb-pomo-cfg .reset{margin-left:auto;padding:5px 12px;border-radius:6px;',
194        '  cursor:pointer;font-family:inherit;font-size:.72rem;border:none;',
195        '  background:rgba(107,90,62,.14);color:#6b5a3e;}',
196        '.nb-pomo-cfg .reset:hover{background:rgba(107,90,62,.26);}',
197        '@media(max-width:520px){',
198        '  #nbPomoBox{right:16px;bottom:76px;}',
199        '  .nb-pomo-panel{width:calc(100vw - 32px);right:0;}',
200        '}',
201        '@media(prefers-reduced-motion:reduce){',
202        '  .nb-pomo-panel.open{animation:none;}',
203        '  .nb-pomo-ball{transition:none;}',
204        '}'
205    ].join('\n');
206
207    /* ---------- 面板 HTML ---------- */
208    var R = 42, CIRC = 2 * Math.PI * R;
209
210    function build() {
211        if (document.getElementById(BOX_ID)) return;
212        if (!isEyecare()) return;
213
214        /* 页面上如果给了内嵌容器(首页就有),就渲染到那儿去,
215           这种情况下不再建右下角的悬浮球,免得两处重复。 */
216        var inlineHost = document.getElementById('nbPomoInline');
217
218        if (!document.getElementById(CSS_ID)) {
219            var st = document.createElement('style');
220            st.id = CSS_ID;
221            st.textContent = CSS;
222            document.head.appendChild(st);
223        }
224
225        var box = document.createElement('div');
226        box.id = BOX_ID;
227        if (inlineHost) box.className = 'nb-pomo-inline';
228        box.innerHTML =
229            '<div class="nb-pomo-panel" data-panel>' +
230              '<div class="nb-pomo-head">' +
231                '<span class="t">🍅 番茄钟</span>' +
232                '<span class="stat" data-stat></span>' +
233                '<button class="x" data-close title="收起">×</button>' +
234              '</div>' +
235              '<div class="nb-pomo-body">' +
236              '<div class="nb-pomo-mid">' +
237                '<div class="nb-pomo-ring" data-ring>' +
238                  '<svg width="96" height="96" viewBox="0 0 96 96">' +
239                    '<circle class="tk" cx="48" cy="48" r="' + R + '"/>' +
240                    '<circle class="br" cx="48" cy="48" r="' + R + '" ' +
241                      'stroke-dasharray="' + CIRC.toFixed(1) + '" stroke-dashoffset="0"/>' +
242                  '</svg>' +
243                  '<div class="time" data-time>25:00</div>' +
244                '</div>' +
245                '<div class="nb-pomo-side">' +
246                  '<div class="ph" data-phase>还没开始</div>' +
247                  '<div class="rd"><span data-hint>点「开始」进入专注</span></div>' +
248                  '<div class="nb-pomo-dots" data-dots></div>' +
249                '</div>' +
250              '</div>' +
251              '<div class="nb-pomo-btns">' +
252                '<button data-act="toggle">开始</button>' +
253                '<button class="gh" data-act="skip">跳过</button>' +
254                '<button class="gh" data-act="reset">重置</button>' +
255              '</div>' +
256              '</div>' +
257              '<div class="nb-pomo-cfg">' +
258                '<label>专注 <input type="number" min="1" max="180" data-min="work"> 分</label>' +
259                '<label>短休息 <input type="number" min="1" max="60" data-min="short"> 分</label>' +
260                '<label>长休息 <input type="number" min="1" max="90" data-min="long"> 分</label>' +
261                '<button class="reset" data-act="defdur">恢复默认</button>' +
262              '</div>' +
263              '<div class="nb-pomo-tip">切页面不会重置 —— 进度按时间戳算,' +
264                '换页回来接着走。每 4 轮专注后自动进长休息</div>' +
265            '</div>' +
266            '<button class="nb-pomo-ball" data-ball title="番茄钟">' +
267              '<span>🍅</span><span class="mini" data-mini></span>' +
268            '</button>';
269
270        (inlineHost || document.body).appendChild(box);
271
272        var panel = box.querySelector('[data-panel]');
273        var ringEl = box.querySelector('[data-ring]');
274        var barEl = box.querySelector('.br');
275        var timeEl = box.querySelector('[data-time]');
276        var phaseEl = box.querySelector('[data-phase]');
277        var statEl = box.querySelector('[data-stat]');
278        var dotsEl = box.querySelector('[data-dots]');
279        var hintEl = box.querySelector('[data-hint]');
280        var miniEl = box.querySelector('[data-mini]');
281        var toggleBtn = box.querySelector('[data-act="toggle"]');
282        var ballEl = box.querySelector('[data-ball]');
283
284        /* 内嵌模式:内嵌那块面板常开(它本来就是页面的一部分)。
285           注意这段必须放在上面那批 querySelector 之后 ——
286           之前写在了 appendChild 后面,panel 还是 undefined,
287           一调 classList 就抛错,后面的 render() 全没执行。
288
289           小球在首页【也保留】:站长说右下角那个更顺手,
290           在别的页面用惯了,回首页反而找不到。
291           点它的行为在下面分两种处理。 */
292        if (inlineHost) {
293            panel.classList.add('open');
294        }
295
296        /* 点小球:
297             首页(有内嵌面板)→ 平滑滚到内嵌那块,不另开浮层
298             别的页面         → 照旧展开 / 收起浮动面板  */
299        ballEl.addEventListener('click', function () {
300            if (inlineHost) {
301                inlineHost.scrollIntoView({ behavior: 'smooth', block: 'center' });
302                return;
303            }
304            panel.classList.toggle('open');
305        });
306        box.querySelector('[data-close]').addEventListener('click', function () {
307            panel.classList.remove('open');
308        });
309
310        /* 操作 */
311        toggleBtn.addEventListener('click', function () {
312            var st = load();
313            if (st.running) {                       /* 暂停:把剩余固化下来 */
314                st.remain = remainOf(st);
315                st.running = false;
316            } else {                                /* 继续:从剩余重新起算 */
317                st.endsAt = Date.now() + st.remain;
318                st.running = true;
319            }
320            save(st);
321            render();
322        });
323        box.querySelector('[data-act="skip"]').addEventListener('click', function () {
324            var st = load();
325            advance(st);
326            save(st);
327            render();
328        });
329        box.querySelector('[data-act="reset"]').addEventListener('click', function () {
330            var st = load();
331            st.phase = 'work';
332            st.remain = durOf(st, 'work');
333            st.running = false;
334            st.endsAt = 0;
335            save(st);
336            render();
337        });
338
339        /* ---------- 时长设置 ---------- */
340        box.querySelectorAll('[data-min]').forEach(function (inp) {
341            inp.addEventListener('change', function () {
342                var st = load();
343                var v = parseInt(inp.value, 10);
344                if (!(v >= 1 && v <= 180)) { inp.value = st.min[inp.dataset.min]; return; }
345                st.min[inp.dataset.min] = v;
346                /* 改的正好是当前阶段、又没在跑,就顺手把剩余时间也更新 */
347                if (st.phase === inp.dataset.min && !st.running) {
348                    st.remain = durOf(st, st.phase);
349                }
350                save(st);
351                render();
352            });
353        });
354        box.querySelector('[data-act="defdur"]').addEventListener('click', function () {
355            var st = load();
356            st.min = { work: 25, short: 5, long: 15 };
357            st.remain = durOf(st, st.phase);
358            if (st.running) st.endsAt = Date.now() + st.remain;
359            save(st);
360            render();
361        });
362
363        /* ---------- 渲染 ---------- */
364        function render() {
365            var st = load();
366            var left = remainOf(st);
367
368            /* 时间到了:自动推进(可能是关着页面时走完的,所以用 while) */
369            var guard = 0;
370            while (st.running && left <= 0 && guard++ < 8) {
371                advance(st);
372                save(st);
373                st = load();
374                left = remainOf(st);
375            }
376
377            var total = durOf(st, st.phase);
378            var ratio = total > 0 ? Math.max(0, left) / total : 0;
379            barEl.style.strokeDashoffset = (CIRC * (1 - ratio)).toFixed(1);
380            ringEl.classList.toggle('rest', st.phase !== 'work');
381
382            timeEl.textContent = fmt(left);
383
384            /* 顶部统计 */
385            var hh = Math.floor(st.total / 60), mm = st.total % 60;
386            if (statEl) statEl.innerHTML = '今日 <b>' + st.rounds + '</b> 轮' +
387                (st.total > 0 ? ' · 专注 <b>' + (hh > 0 ? hh + ' 时 ' : '') + mm + '</b> 分' : '');
388
389            /* 今日番茄记录点:8 个格子,超出就折叠显示 */
390            var SHOW = 8;
391            var n = Math.min(st.rounds, SHOW);
392            var html = '';
393            for (var i = 0; i < SHOW; i++) {
394                var cls = i < n ? 'on' : '';
395                if (i === n && st.phase === 'work' && st.running) cls += ' now';
396                html += '<i class="' + cls + '"></i>';
397            }
398            if (st.rounds > SHOW) html += '<span style="font-size:.7rem;color:#8c2f23;' +
399                'align-self:center;margin-left:4px">+' + (st.rounds - SHOW) + '</span>';
400            if (dotsEl) dotsEl.innerHTML = html;
401
402            if (st.running) {
403                phaseEl.textContent = NAME[st.phase];
404                hintEl.textContent = st.phase === 'work'
405                    ? (st.rounds % 4 === 3 ? '这轮结束就长休息' : '别碰手机')
406                    : '起来走走,看看远处';
407                toggleBtn.textContent = '暂停';
408            } else {
409                phaseEl.textContent = left < total ? '已暂停' : '还没开始';
410                hintEl.textContent = '点「开始」进入专注';
411                toggleBtn.textContent = left < total ? '继续' : '开始';
412            }
413            miniEl.textContent = fmt(left);
414
415            /* 时长输入框同步(别在用户正在输入时覆盖) */
416            box.querySelectorAll('[data-min]').forEach(function (inp) {
417                if (document.activeElement === inp) return;
418                var v = st.min[inp.dataset.min];
419                if (inp.value !== String(v)) inp.value = v;
420            });
421
422            /* 标签页标题也带上,切到别的标签也能看见 */
423            var base = document.title.replace(/^🍅 [\d:]+ · /, '');
424            document.title = (st.running ? '🍅 ' + fmt(left) + ' · ' : '') + base;
425        }
426
427        render();
428        setInterval(render, 1000);
429
430        /* 页面从后台切回来时立刻刷新一次(浏览器会节流定时器) */
431        document.addEventListener('visibilitychange', function () {
432            if (!document.hidden) render();
433        });
434
435        /* 点在面板外收起(内嵌模式不收起) */
436        if (!inlineHost) {
437            document.addEventListener('click', function (e) {
438                if (!box.contains(e.target)) panel.classList.remove('open');
439            });
440        }
441    }
442
443    function destroy() {
444        var b = document.getElementById(BOX_ID);
445        if (b) b.remove();
446        var c = document.getElementById(CSS_ID);
447        if (c) c.remove();
448    }
449
450    function boot() {
451        build();
452        /* 主题切换时跟着显示 / 隐藏 */
453        window.addEventListener('nb-theme-change', function () {
454            destroy();
455            build();
456        });
457    }
458
459    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
460    else boot();
461    /* 给外面调:首页那边挂上内嵌容器后会喊一声,这里重建一次 */
462    window.NBPomo = {
463        rebuild: function () { destroy(); build(); },
464        KEY: KEY,
465        DUR: DUR
466    };
467
468})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.