🎉高颜值番茄护眼浏览器拓展震撼开源!
𝓕-𝓓𝓻𝓸𝓲𝓭
2026-08-03 18:50
1
- background.js
// 番茄护眼 TomatoGuard v1.0.0 · Service Worker (MV3)
const SCHEDULE_ALARM = 'eye-schedule';
const POMO_ALARM = 'pomodoro';
const get = keys => chrome.storage.sync.get(keys);
const set = obj => chrome.storage.sync.set(obj);
// ---------- 固定时区(UTC+N,弹窗「智能」面板可调 tzOffset) ----------
function normTz(v) {
const n = parseInt(v, 10);
return (Number.isFinite(n) && n >= -12 && n <= 14) ? n : 8;
}
function todayOf(off) {
const t = new Date(Date.now() + off * 3600000);
return t.getUTCFullYear() + '-' + String(t.getUTCMonth() + 1).padStart(2, '0') + '-' + String(t.getUTCDate()).padStart(2, '0');
}
function hourOf(off) {
return new Date(Date.now() + off * 3600000).getUTCHours();
}
async function todayStr() {
const d = await get('tzOffset');
return todayOf(normTz(d.tzOffset));
}
const DEFAULT_POMO = {
status: 'idle',
phase: 'work',
endAt: 0,
remaining: 25 * 60 * 1000,
total: 25 * 60 * 1000,
workMin: 25,
breakMin: 5,
longMin: 15,
longEvery: 4,
dailyGoal: 8,
rounds: 0,
task: '',
pomoDate: ''
};
function notify(id, title, message) {
chrome.notifications.create(id, {
type: 'basic',
iconUrl: 'icons/icon.png',
title: title,
message: message
});
}
// ---------- 跨天清零 ----------
async function normalizePomo(p) {
p = Object.assign({}, DEFAULT_POMO, p || {});
const today = await todayStr();
if (p.pomoDate !== today) {
p.rounds = 0;
p.pomoDate = today;
}
return p;
}
// ---------- 完成统计(按固定时区的日期,保留 31 天) ----------
async function addStats() {
const d = await get(['stats', 'tzOffset']);
const off = normTz(d.tzOffset);
const stats = Object.assign({}, d.stats || {});
const today = todayOf(off);
stats[today] = (stats[today] || 0) + 1;
const cutoff = new Date(Date.now() - 31 * 86400000 + off * 3600000);
const cutoffStr = cutoff.getUTCFullYear() + '-' + String(cutoff.getUTCMonth() + 1).padStart(2, '0') + '-' + String(cutoff.getUTCDate()).padStart(2, '0');
Object.keys(stats).forEach(k => { if (k < cutoffStr) delete stats[k]; });
await set({ stats: stats });
}
// ---------- 图标角标 ----------
async function refreshBadge() {
const d = await get(['enabled', 'pomodoro']);
const p = d.pomodoro || null;
if (p && p.status === 'running' && p.endAt > 0) {
const mins = Math.max(1, Math.ceil((p.endAt - Date.now()) / 60000));
chrome.action.setBadgeText({ text: String(mins) });
let color = '#12b76a';
if (p.phase === 'work') {
if (mins <= 5) color = '#e5484d';
else if (mins <= 10) color = '#ff9f43';
else color = '#12b76a';
} else {
color = '#ff9f43';
}
chrome.action.setBadgeBackgroundColor({ color: color });
chrome.action.setTitle({ title: (p.phase === 'work' ? '🍅 专注中 · ' : '☕ 休息中 · ') + mins + ' 分钟' });
return;
}
const on = d.enabled !== false;
chrome.action.setBadgeText({ text: on ? 'ON' : 'OFF' });
chrome.action.setBadgeBackgroundColor({ color: on ? '#12b76a' : '#6b7280' });
chrome.action.setTitle({ title: on ? '护眼模式:已开启' : '护眼模式:已关闭' });
}
// ---------- 番茄钟状态机 ----------
async function getPomo() {
const d = await get('pomodoro');
return normalizePomo(d.pomodoro);
}
async function armTimer(p) {
if (p.status === 'running' && p.endAt > 0) {
chrome.alarms.create(POMO_ALARM, { when: p.endAt });
} else {
chrome.alarms.clear(POMO_ALARM);
}
}
async function pomoStartWork() {
const p = await getPomo();
p.status = 'running';
p.phase = 'work';
p.remaining = p.workMin * 60000;
p.total = p.remaining;
p.endAt = Date.now() + p.remaining;
await set({ pomodoro: p });
notify('pomo-start', '🍅 番茄钟开始', (p.task ? '「' + p.task + '」' : '') + '专注 ' + p.workMin + ' 分钟,保护视力');
}
async function pomoPause() {
const p = await getPomo();
if (p.status !== 'running') return;
p.remaining = Math.max(0, p.endAt - Date.now());
p.status = 'paused';
await set({ pomodoro: p });
}
async function pomoResume() {
const p = await getPomo();
if (p.status !== 'paused') return;
p.status = 'running';
p.endAt = Date.now() + p.remaining;
await set({ pomodoro: p });
}
async function pomoReset() {
const p = await getPomo();
p.status = 'idle';
p.phase = 'work';
p.remaining = p.workMin * 60000;
p.total = p.remaining;
p.endAt = 0;
p.rounds = 0;
await set({ pomodoro: p });
}
async function pomoToggle() {
const p = await getPomo();
if (p.status === 'running') await pomoPause();
else if (p.status === 'paused') await pomoResume();
else await pomoStartWork();
}
async function pomoComplete() {
const p = await getPomo();
if (p.status !== 'running' || Date.now() < p.endAt) return;
if (p.phase === 'work') {
p.rounds = p.rounds + 1;
const longBreak = p.rounds % p.longEvery === 0;
p.phase = 'break';
p.remaining = (longBreak ? p.longMin : p.breakMin) * 60000;
p.total = p.remaining;
p.status = 'running';
p.endAt = Date.now() + p.remaining;
addStats();
const task = p.task ? '「' + p.task + '」' : '';
notify('pomo-break', '🍅 完成' + task + '!(' + p.rounds + '/' + p.dailyGoal + ')', (longBreak ? '长休息 ' + p.longMin + ' 分钟' : '休息 ' + p.breakMin + ' 分钟') + ',看看远处 20 秒');
} else {
p.status = 'idle';
p.remaining = p.workMin * 60000;
p.total = p.remaining;
p.endAt = 0;
notify('pomo-done', '☕ 休息结束', '继续下一个番茄吧!今日 ' + p.rounds + '/' + p.dailyGoal);
}
await set({ pomodoro: p });
}
// ---------- 空闲自动暂停 ----------
function checkIdleAutoPause() {
chrome.storage.sync.get(['autoPause', 'pomodoro'], d => {
if (d.autoPause === false) return;
const p = d.pomodoro;
if (!p || p.status !== 'running') return;
chrome.idle.queryState(300, state => {
if (state !== 'idle' && state !== 'locked') return;
chrome.storage.sync.get('pomodoro', pd => {
const cur = pd.pomodoro;
if (cur && cur.status === 'running') {
cur.status = 'paused';
cur.remaining = Math.max(0, cur.endAt - Date.now());
set({ pomodoro: cur });
notify('auto-pause', '⏸ 番茄已自动暂停', '检测到离开超过 5 分钟,回来后点「继续」');
}
});
});
});
}
// ---------- 快捷键 ----------
chrome.commands.onCommand.addListener(cmd => {
if (cmd === 'toggle-eye') {
get('enabled').then(d => set({ enabled: !(d.enabled !== false) }));
} else if (cmd === 'inc-intensity') {
adjustIntensity(0.05);
} else if (cmd === 'dec-intensity') {
adjustIntensity(-0.05);
} else if (cmd === 'toggle-pomodoro') {
pomoToggle();
}
});
async function adjustIntensity(delta) {
const d = await get('opacity');
const cur = Math.min(0.6, Math.max(0.05, parseFloat(d.opacity) || 0.4));
const next = Math.round((cur + delta) * 100) / 100;
await set({ opacity: next });
}
// ---------- 右键菜单 ----------
chrome.contextMenus.onClicked.addListener(info => {
if (info.menuItemId === 'pomo-start') pomoStartWork();
else if (info.menuItemId === 'pomo-toggle') pomoToggle();
});
// ---------- 定时调度 ----------
function scheduleAlarm() {
chrome.alarms.create(SCHEDULE_ALARM, { periodInMinutes: 1 });
}
chrome.runtime.onInstalled.addListener(() => {
scheduleAlarm();
refreshBadge();
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({ id: 'pomo-start', title: '🍅 开始番茄专注', contexts: ['page', 'selection'] });
chrome.contextMenus.create({ id: 'pomo-toggle', title: '⏯ 暂停 / 继续番茄钟', contexts: ['page', 'selection'] });
});
});
chrome.runtime.onStartup.addListener(() => {
scheduleAlarm();
refreshBadge();
getPomo().then(armTimer);
});
chrome.alarms.onAlarm.addListener(alarm => {
if (alarm.name === POMO_ALARM) { pomoComplete(); return; }
if (alarm.name !== SCHEDULE_ALARM) return;
chrome.storage.sync.get(['pomodoro', 'tzOffset', 'autoSchedule', 'autoPause', 'enabled', 'snoozeUntil', 'remind20', 'lastRemind20'], d => {
const now = Date.now();
const off = normTz(d.tzOffset);
refreshBadge();
if (d.pomodoro && d.pomodoro.pomoDate !== todayOf(off)) {
const p = Object.assign({}, DEFAULT_POMO, d.pomodoro);
p.rounds = 0;
p.pomoDate = todayOf(off);
set({ pomodoro: p });
}
if (d.autoPause !== false) checkIdleAutoPause();
if (d.snoozeUntil && now >= d.snoozeUntil) {
set({ enabled: true, snoozeUntil: null });
return;
}
if (d.remind20 && (!d.lastRemind20 || now - d.lastRemind20 >= 20 * 60000)) {
set({ lastRemind20: now });
notify('remind20', '👀 护眼提醒', '看 6 米外 20 秒,让眼睛休息一下');
}
if (!d.autoSchedule) return;
const h = hourOf(off);
const want = h >= 7 && h < 22;
if (want !== (d.enabled !== false)) set({ enabled: want });
});
});
// ---------- 状态联动 ----------
chrome.storage.onChanged.addListener((changes, area) => {
if (area !== 'sync') return;
if (changes.enabled || changes.pomodoro) refreshBadge();
if (changes.pomodoro) {
const p = changes.pomodoro.newValue;
if (p && p.status === 'running' && p.endAt > 0) {
chrome.alarms.create(POMO_ALARM, { when: p.endAt });
} else {
chrome.alarms.clear(POMO_ALARM);
}
}
});
// ---------- 初始化 ----------
refreshBadge();
- content.js
// 番茄护眼 TomatoGuard v1.0.0 · 内容脚本
(() => {
const MASK_ID = 'eye-protect-mask';
const STYLE_ID = 'eye-protect-style';
const FLOATER_ID = 'eye-pomo-floater';
const MEDIA_RULE = 'img,svg,video,canvas,iframe{position:relative;z-index:2147483647;}';
const MASK_RULE = `#${MASK_ID}{position:fixed;inset:0;z-index:2147483646;pointer-events:none;mix-blend-mode:multiply;background:var(--eye-color,#C7EDCC);opacity:0;transition:opacity .25s ease;}`;
const FLOATER_RULE = `#${FLOATER_ID}{position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;align-items:center;gap:6px;padding:8px 14px;border-radius:99px;background:rgba(15,21,36,.88);border:1px solid rgba(61,220,132,.55);color:#3ddc84;font:700 14px/1 system-ui,-apple-system,sans-serif;font-variant-numeric:tabular-nums;box-shadow:0 6px 24px rgba(0,0,0,.45);backdrop-filter:blur(6px);cursor:grab;user-select:none;opacity:0;pointer-events:none;transition:opacity .25s ease;}`;
const FLOATER_ON_RULE = `#${FLOATER_ID}.on{opacity:1;pointer-events:auto}`;
const FLOATER_BREAK_RULE = `#${FLOATER_ID}.break{border-color:rgba(255,170,60,.6);color:#ffb85c}`;
let injected = false;
let announced = false;
let data = {};
let floater = null;
let lastSig = null;
function mount(el) {
const root = document.documentElement;
if (!root) return false;
root.appendChild(el);
return true;
}
function whenReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn, { once: true });
} else {
fn();
}
}
function injectStyle() {
if (injected) return;
const style = document.createElement('style');
style.id = STYLE_ID;
const finish = () => {
injected = true;
try {
style.sheet.insertRule(MASK_RULE);
style.sheet.insertRule(MEDIA_RULE);
style.sheet.insertRule(FLOATER_RULE);
style.sheet.insertRule(FLOATER_ON_RULE);
style.sheet.insertRule(FLOATER_BREAK_RULE);
} catch (e) {
style.textContent = [MASK_RULE, MEDIA_RULE, FLOATER_RULE, FLOATER_ON_RULE, FLOATER_BREAK_RULE].join('\n');
}
};
if (mount(style)) finish();
else whenReady(() => { mount(style); finish(); });
}
function currentHost() {
if (location.protocol === 'file:') return 'file://';
return location.hostname || '';
}
function siteConfig() {
const host = currentHost();
return (data.siteSettings || {})[host] || null;
}
function isSiteIgnored() {
const host = currentHost();
return Array.isArray(data.siteIgnore) && data.siteIgnore.indexOf(host) !== -1;
}
function effectiveColor() {
const site = siteConfig();
if (site && site.color) return site.color;
if (data.followSystem) {
const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
return dark ? '#0d1117' : '#C7EDCC';
}
return /^#[0-9a-fA-F]{6}$/.test(data.color || '') ? data.color : '#C7EDCC';
}
function siteOpacity() {
const site = siteConfig();
if (site && site.opacity != null) return Math.min(0.6, Math.max(0, parseFloat(site.opacity) || 0.4));
return null;
}
function fmt(ms) {
const s = Math.max(0, Math.ceil(ms / 1000));
return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
}
// ---------- 声音提示(默认关闭,需在面板手动开启) ----------
function beep(freq, dur, times) {
try {
const AC = window.AudioContext || window.webkitAudioContext;
const ctx = new AC();
let t = ctx.currentTime;
for (let i = 0; i < times; i++) {
const o = ctx.createOscillator();
const gn = ctx.createGain();
o.type = 'sine';
o.frequency.value = freq;
gn.gain.setValueAtTime(0.001, t);
gn.gain.exponentialRampToValueAtTime(0.3, t + 0.02);
gn.gain.exponentialRampToValueAtTime(0.001, t + dur);
o.connect(gn);
gn.connect(ctx.destination);
o.start(t);
o.stop(t + dur + 0.02);
t += dur + 0.12;
}
setTimeout(() => ctx.close(), (times * (dur + 0.12) + 0.5) * 1000);
} catch (e) { /* 忽略音频错误 */ }
}
function checkSound() {
const p = data.pomodoro;
const sig = p ? p.status + ':' + p.phase : 'idle';
if (lastSig === null) { lastSig = sig; return; }
if (sig === lastSig) return;
if (data.sound === true) {
if (p && p.status === 'running' && p.phase === 'break') beep(880, 0.18, 3);
else if (!p || p.status === 'idle') beep(660, 0.15, 2);
}
lastSig = sig;
}
function apply() {
const root = document.documentElement;
const color = effectiveColor();
if (root) root.style.setProperty('--eye-color', color);
const enabled = isSiteIgnored() ? false : (data.enabled !== false);
let mask = document.getElementById(MASK_ID);
if (!enabled) {
if (mask) mask.style.opacity = '0';
updateFloater();
return;
}
injectStyle();
if (!mask) {
mask = document.createElement('div');
mask.id = MASK_ID;
if (!mount(mask)) {
whenReady(() => { if (mount(mask)) apply(); });
return;
}
}
const base = Math.min(0.6, Math.max(0, parseFloat(data.opacity) || 0.4));
const opacity = siteOpacity() != null ? siteOpacity() : base;
mask.style.opacity = String(opacity);
updateFloater();
if (!announced) {
announced = true;
console.log('[番茄护眼] 遮罩已生效', color, opacity, 'site=' + currentHost());
}
}
// ---------- 悬浮计时器(默认关闭,需在面板手动开启) ----------
function ensureFloater() {
if (floater) return;
floater = document.createElement('div');
floater.id = FLOATER_ID;
mount(floater);
let sx = 0, sy = 0, ox = 0, oy = 0, dragging = false;
floater.addEventListener('pointerdown', e => {
dragging = true;
sx = e.clientX; sy = e.clientY;
const r = floater.getBoundingClientRect();
ox = r.left; oy = r.top;
floater.setPointerCapture(e.pointerId);
});
floater.addEventListener('pointermove', e => {
if (!dragging) return;
floater.style.left = Math.min(innerWidth - 30, Math.max(0, ox + e.clientX - sx)) + 'px';
floater.style.top = Math.min(innerHeight - 30, Math.max(0, oy + e.clientY - sy)) + 'px';
floater.style.right = 'auto';
});
floater.addEventListener('pointerup', () => { dragging = false; });
}
function updateFloater() {
checkSound();
const p = data.pomodoro;
if (!p || p.status !== 'running' || data.showFloater !== true) {
if (floater) floater.classList.remove('on');
return;
}
injectStyle();
ensureFloater();
const remain = Math.max(0, p.endAt - Date.now());
floater.textContent = (p.phase === 'work' ? '🍅 ' : '☕ ') + fmt(remain);
floater.classList.toggle('break', p.phase === 'break');
floater.classList.add('on');
}
setInterval(updateFloater, 1000);
// ---------- 数据加载与监听 ----------
function load() {
chrome.storage.sync.get(['enabled', 'color', 'opacity', 'pomodoro', 'showFloater', 'followSystem', 'sound', 'siteIgnore', 'siteSettings'], d => {
data = d;
apply();
updateFloater();
});
}
load();
chrome.storage.onChanged.addListener(load);
const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
if (mq) {
if (mq.addEventListener) mq.addEventListener('change', load);
else if (mq.addListener) mq.addListener(load);
}
})();
- manifest.json
{
"manifest_version": 3,
"name": "番茄护眼",
"short_name": "TomatoGuard",
"description": "护眼遮罩 + 科学番茄钟 + 休息提醒 + 专注统计,专注与护眼一体。",
"version": "1.0.0",
"minimum_chrome_version": "102",
"author": "EyeCare",
"permissions": ["storage", "alarms", "notifications", "idle", "contextMenus"],
"host_permissions": ["<all_urls>", "file://*/*"],
"background": { "service_worker": "background.js" },
"icons": {
"16": "icons/icon.png",
"48": "icons/icon.png",
"128": "icons/icon.png"
},
"action": {
"default_title": "番茄护眼",
"default_popup": "popup.html",
"default_icon": {
"16": "icons/icon.png",
"48": "icons/icon.png",
"128": "icons/icon.png"
}
},
"commands": {
"toggle-eye": {
"suggested_key": { "default": "Ctrl+Shift+E" },
"description": "全局开关护眼"
},
"toggle-pomodoro": {
"suggested_key": { "default": "Ctrl+Shift+P" },
"description": "开始/暂停番茄钟"
},
"inc-intensity": {
"suggested_key": { "default": "Ctrl+Shift+Up" },
"description": "增强遮罩强度"
},
"dec-intensity": {
"suggested_key": { "default": "Ctrl+Shift+Down" },
"description": "减弱遮罩强度"
}
},
"content_scripts": [
{
"matches": ["<all_urls>", "file://*/*"],
"js": ["content.js"],
"run_at": "document_start",
"all_frames": false
}
]
}
- popup.html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>番茄护眼</title>
<style>
:root{
color-scheme: dark;
--body-grad:linear-gradient(165deg,#0d1322 0%,#151d31 55%,#1a2440 100%);
--card:rgba(255,255,255,.06);
--card-border:rgba(255,255,255,.10);
--text:#eef2f8;
--muted:#9aa5b8;
--accent:#3ddc84;
--accent-deep:#12b76a;
--ctrl:rgba(255,255,255,.12);
--ctrl-strong:rgba(255,255,255,.18);
--ctrl-soft:rgba(255,255,255,.08);
--ctrl-soft2:rgba(255,255,255,.07);
--input-bg:rgba(255,255,255,.10);
--kbd-bg:rgba(255,255,255,.12);
--kbd-border:rgba(255,255,255,.18);
--danger-bg:rgba(244,67,54,.18);
--danger-text:#ff8a80;
--accent-soft:rgba(61,220,132,.10);
--accent-border:rgba(61,220,132,.30);
--ring:#151d31;
--radius:14px;
}
body.light{
color-scheme: light;
--body-grad:linear-gradient(165deg,#eef4f1 0%,#e5edea 55%,#dde7e5 100%);
--card:rgba(255,255,255,.78);
--card-border:rgba(15,23,42,.10);
--text:#1a2332;
--muted:#5b6b82;
--accent:#12b76a;
--accent-deep:#0e9f5b;
--ctrl:rgba(15,23,42,.08);
--ctrl-strong:rgba(15,23,42,.14);
--ctrl-soft:rgba(15,23,42,.05);
--ctrl-soft2:rgba(15,23,42,.04);
--input-bg:rgba(255,255,255,.85);
--kbd-bg:rgba(15,23,42,.08);
--kbd-border:rgba(15,23,42,.14);
--danger-bg:rgba(244,67,54,.10);
--danger-text:#d93036;
--accent-soft:rgba(18,183,106,.12);
--accent-border:rgba(18,183,106,.35);
--ring:#ffffff;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{width:340px}
body{
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
background:var(--body-grad);
color:var(--text);
padding:18px 18px 14px;
user-select:none;
}
header{display:flex;align-items:center;gap:12px;margin-bottom:16px}
.logo{
width:40px;height:40px;border-radius:12px;flex:none;
background:linear-gradient(135deg,#3ddc84,#12b76a);
display:flex;align-items:center;justify-content:center;
font-size:20px;box-shadow:0 4px 14px rgba(61,220,132,.35);
}
.brand{flex:1;min-width:0}
.brand h1{font-size:16px;font-weight:700;letter-spacing:.5px}
.status{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--muted)}
.status::before{content:'';width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 6px var(--accent)}
body.off .status::before{background:#f44336;box-shadow:0 0 6px #f44336}
.switch{position:relative;width:46px;height:26px;flex:none;cursor:pointer}
.switch input{position:absolute;opacity:0;inset:0;cursor:pointer}
.switch .track{
position:absolute;inset:0;border-radius:99px;
background:var(--ctrl);transition:background .25s;
}
.switch .track::after{
content:'';position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;
background:#fff;transition:transform .25s cubic-bezier(.34,1.56,.64,1);
}
.switch input:checked + .track{background:linear-gradient(135deg,#3ddc84,#12b76a)}
.switch input:checked + .track::after{transform:translateX(20px)}
.panel{
background:var(--card);border:1px solid var(--card-border);
border-radius:var(--radius);padding:14px 16px;margin-bottom:12px;
backdrop-filter:blur(6px);
}
.row{display:flex;align-items:center;justify-content:space-between}
.row label{font-size:13px;font-weight:600}
.hint{font-size:11px;color:var(--muted);margin-top:3px}
.value{font-size:13px;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums}
.site-host{font-size:12px;font-weight:700;color:var(--accent);max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pomo-time{
font-size:42px;font-weight:800;text-align:center;letter-spacing:1px;
color:var(--accent);font-variant-numeric:tabular-nums;margin:6px 0 8px;
text-shadow:0 0 18px rgba(61,220,132,.25);
}
.pomo-status{font-size:11px;color:var(--muted);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.pomo-bar{height:4px;background:var(--ctrl);border-radius:99px;overflow:hidden;margin-bottom:12px}
.pomo-bar i{display:block;height:100%;width:0;background:linear-gradient(90deg,#3ddc84,#12b76a);border-radius:99px;transition:width .25s linear}
.pomo-btns{display:flex;gap:8px}
.btn{
flex:1;padding:9px 0;border:none;border-radius:9px;font-size:13px;font-weight:600;
cursor:pointer;background:var(--ctrl);color:var(--text);
transition:filter .15s, transform .1s;
}
.btn:hover{filter:brightness(1.15)}
.btn:active{transform:scale(.97)}
.btn.primary{background:linear-gradient(135deg,#3ddc84,#12b76a);color:#0b1220}
.btn.snooze{width:100%;margin-top:12px;background:var(--danger-bg);color:var(--danger-text)}
.btn.small{padding:8px 0;font-size:12px}
.btn.danger{
width:100%;margin-top:10px;
background:var(--ctrl-soft);color:var(--danger-text);border:1px solid rgba(244,67,54,.45);
}
.task-label{display:block;font-size:11px;color:var(--muted);margin-bottom:6px}
.task-input{
width:100%;margin-bottom:10px;padding:10px 12px;border:1px solid var(--card-border);border-radius:9px;
background:var(--input-bg);color:var(--text);font-size:13px;outline:none;
}
.task-input::placeholder{color:var(--muted)}
.task-input:focus{border-color:var(--accent)}
.pomo-presets{display:flex;align-items:center;gap:6px;margin-top:12px}
.preset-label{font-size:11px;color:var(--muted);flex:none}
.chip{
flex:1;min-width:0;padding:6px 2px;border:1px solid var(--card-border);border-radius:8px;
background:var(--ctrl-soft);color:var(--text);font-size:11.5px;font-weight:600;cursor:pointer;
white-space:nowrap;transition:background .15s, border-color .15s;
}
.chip:hover{background:var(--ctrl-strong)}
.chip.active{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.pomo-steppers{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px}
.stepper{
display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0;
background:var(--ctrl-soft);border:1px solid var(--card-border);border-radius:9px;padding:8px 6px;
}
.stepper-label{font-size:11px;color:var(--muted);white-space:nowrap}
.stepper-ctrl{display:flex;align-items:center;gap:4px;min-width:0}
.stp{
width:24px;height:24px;border:none;border-radius:6px;cursor:pointer;flex:none;
background:var(--ctrl-strong);color:var(--text);font-size:14px;font-weight:700;line-height:1;
}
.stp:hover{background:var(--ctrl)}
.stepper-val{font-size:13.5px;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums;min-width:22px;text-align:center}
.unit{font-size:10px;color:var(--muted);flex:none}
.goal-progress{background:var(--accent-soft);border-color:var(--accent-border)}
.goal-progress .stepper-val{color:var(--text)}
.tz-row{display:flex;align-items:center;gap:6px}
.tz-row .stepper-val{min-width:52px}
.data-row{display:flex;gap:8px;margin-top:12px}
.theme-seg{display:flex;gap:4px}
.seg{
padding:5px 10px;border:1px solid var(--card-border);border-radius:8px;cursor:pointer;
background:var(--ctrl-soft);color:var(--text);font-size:11.5px;font-weight:600;
}
.seg:hover{background:var(--ctrl-strong)}
.seg.active{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.site-rules{display:flex;flex-direction:column;gap:6px;margin-top:8px;max-height:150px;overflow-y:auto}
.site-rule{display:flex;align-items:center;gap:8px;background:var(--ctrl-soft2);border:1px solid var(--card-border);border-radius:8px;padding:6px 8px}
.site-rule .rh{flex:1;min-width:0;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.site-rule .rtag{flex:none;font-size:10px;padding:2px 7px;border-radius:99px;background:var(--ctrl-strong);color:var(--muted);white-space:nowrap}
.site-rule .rtag.ignore{background:rgba(244,67,54,.18);color:var(--danger-text)}
.site-rule .rtag.site{background:var(--accent-soft);color:var(--accent)}
.site-rule .rdel{flex:none;width:20px;height:20px;border:none;border-radius:6px;cursor:pointer;background:var(--ctrl-strong);color:var(--muted);font-size:12px;line-height:1}
.site-rule .rdel:hover{background:rgba(244,67,54,.3);color:#fff}
.stats-row{display:flex;align-items:flex-end;justify-content:space-between;gap:6px;margin-top:12px;height:70px}
.stat-col{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%;justify-content:flex-end;min-width:0}
.stat-bar{width:100%;max-width:26px;border-radius:4px 4px 0 0;background:linear-gradient(180deg,#3ddc84,#12b76a);min-height:2px;transition:height .3s}
.stat-col.today .stat-bar{background:linear-gradient(180deg,#ffb85c,#ff9f43)}
.stat-col.today .stat-day{color:var(--accent);font-weight:700}
.stat-day{font-size:9px;color:var(--muted);white-space:nowrap}
.stat-num{font-size:10px;font-weight:700;color:var(--accent)}
input[type=range]{
-webkit-appearance:none;appearance:none;width:100%;height:6px;
border-radius:99px;background:var(--ctrl);
outline:none;margin:14px 0 6px;
}
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none;appearance:none;width:18px;height:18px;border-radius:50%;
background:#fff;border:4px solid var(--accent);cursor:pointer;
box-shadow:0 2px 8px rgba(0,0,0,.4);transition:transform .15s;
}
input[type=range]::-webkit-slider-thumb:hover{transform:scale(1.15)}
.range-scale{display:flex;justify-content:space-between;font-size:10px;color:var(--muted)}
.swatches{display:flex;justify-content:space-between;margin-top:12px}
.swatch{
appearance:none;border:none;background:none;cursor:pointer;
display:flex;flex-direction:column;align-items:center;gap:6px;padding:0;
}
.swatch i{
width:34px;height:34px;border-radius:50%;background:var(--c,#C7EDCC);
border:2px solid rgba(255,255,255,.15);
position:relative;transition:transform .15s, box-shadow .15s;
}
.swatch:hover i{transform:scale(1.08)}
.swatch.active i{box-shadow:0 0 0 2px var(--ring),0 0 0 4px var(--accent)}
.swatch.active i::after{
content:'✓';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
font-size:15px;color:#fff;text-shadow:0 1px 3px rgba(0,0,0,.6);
}
.swatch span{font-size:10.5px;color:var(--muted)}
.swatch.active span{color:var(--accent);font-weight:700}
.custom-row{display:flex;align-items:center;justify-content:space-between;margin-top:12px}
.custom-row label{font-size:12px;color:var(--muted)}
.color-inputs{display:flex;align-items:center;gap:8px}
input[type=color]{
width:38px;height:28px;border:1px solid var(--card-border);border-radius:6px;
background:var(--input-bg);padding:2px;cursor:pointer;flex:none;
}
input[type=text]{
width:92px;padding:5px 8px;border:1px solid var(--card-border);border-radius:7px;
background:var(--input-bg);color:var(--text);font-size:12px;
font-family:ui-monospace,Consolas,"Courier New",monospace;outline:none;
}
input[type=text]:focus{border-color:var(--accent)}
input[type=text].err{border-color:#f44336;color:var(--danger-text)}
footer{display:flex;justify-content:space-between;font-size:10px;color:var(--muted);padding:2px 4px 0}
kbd{font-family:inherit;background:var(--kbd-bg);padding:1px 6px;border-radius:5px;border:1px solid var(--kbd-border);color:var(--text)}
</style>
</head>
<body>
<header>
<div class="logo">👁</div>
<div class="brand">
<h1>番茄护眼</h1>
<span class="status" id="statusText">已开启 · 豆沙绿</span>
</div>
<label class="switch">
<input type="checkbox" id="toggle">
<span class="track"></span>
</label>
</header>
<section class="panel">
<div class="row">
<label>🍅 番茄钟</label>
<span class="pomo-status" id="pomoStatus">未开始</span>
</div>
<div class="pomo-time" id="pomoTime">25:00</div>
<div class="pomo-bar"><i id="pomoBar"></i></div>
<label class="task-label">本次任务(可选)</label>
<input type="text" class="task-input" id="pomoTask" placeholder="如:写日报" maxlength="40" autocomplete="off">
<div class="pomo-btns">
<button class="btn primary" id="pomoToggle">开始</button>
<button class="btn" id="pomoReset">重置</button>
</div>
<div class="pomo-presets">
<span class="preset-label">方案</span>
<button class="chip" data-work="25" data-break="5" data-long="15">标准 25/5</button>
<button class="chip" data-work="50" data-break="10" data-long="20">深度 50/10</button>
<button class="chip" data-work="52" data-break="17" data-long="25">52/17</button>
</div>
<div class="pomo-steppers">
<div class="stepper">
<span class="stepper-label">专注</span>
<div class="stepper-ctrl">
<button class="stp" data-key="workMin" data-delta="-1">−</button>
<span class="stepper-val" id="sv-workMin">25</span>
<button class="stp" data-key="workMin" data-delta="1">+</button>
<span class="unit">分</span>
</div>
</div>
<div class="stepper">
<span class="stepper-label">休息</span>
<div class="stepper-ctrl">
<button class="stp" data-key="breakMin" data-delta="-1">−</button>
<span class="stepper-val" id="sv-breakMin">5</span>
<button class="stp" data-key="breakMin" data-delta="1">+</button>
<span class="unit">分</span>
</div>
</div>
<div class="stepper">
<span class="stepper-label">长休</span>
<div class="stepper-ctrl">
<button class="stp" data-key="longMin" data-delta="-1">−</button>
<span class="stepper-val" id="sv-longMin">15</span>
<button class="stp" data-key="longMin" data-delta="1">+</button>
<span class="unit">分</span>
</div>
</div>
<div class="stepper">
<span class="stepper-label">长休频率</span>
<div class="stepper-ctrl">
<button class="stp" data-key="longEvery" data-delta="-1">−</button>
<span class="stepper-val" id="sv-longEvery">4</span>
<button class="stp" data-key="longEvery" data-delta="1">+</button>
<span class="unit">个</span>
</div>
</div>
<div class="stepper">
<span class="stepper-label">今日目标</span>
<div class="stepper-ctrl">
<button class="stp" data-key="dailyGoal" data-delta="-1">−</button>
<span class="stepper-val" id="sv-dailyGoal">8</span>
<button class="stp" data-key="dailyGoal" data-delta="1">+</button>
<span class="unit">个</span>
</div>
</div>
<div class="stepper goal-progress">
<span class="stepper-label">已完成</span>
<div class="stepper-ctrl">
<span class="stepper-val" id="pomoRounds">0/8</span>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="row">
<label>遮罩强度</label>
<span class="value" id="opacityValue">40%</span>
</div>
<input type="range" id="opacity" min="5" max="60" step="5">
<div class="range-scale"><span>更亮</span><span>更暗</span></div>
</section>
<section class="panel">
<div class="row"><label>色调</label></div>
<div class="swatches">
<button class="swatch" data-color="#C7EDCC"><i style="--c:#C7EDCC"></i><span>豆沙绿</span></button>
<button class="swatch" data-color="#F5DEB3"><i style="--c:#F5DEB3"></i><span>暖黄</span></button>
<button class="swatch" data-color="#D4E8D4"><i style="--c:#D4E8D4"></i><span>黄绿混</span></button>
<button class="swatch" data-color="#0d1117"><i style="--c:#0d1117"></i><span>夜间黑</span></button>
</div>
<div class="custom-row">
<label>自定义颜色</label>
<div class="color-inputs">
<input type="color" id="customColor" value="#C7EDCC">
<input type="text" id="customColorCode" placeholder="#C7EDCC" maxlength="7" spellcheck="false" autocomplete="off">
</div>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>跟随系统深浅色</label>
<p class="hint">深色系统自动切夜间黑</p>
</div>
<label class="switch">
<input type="checkbox" id="followSystem">
<span class="track"></span>
</label>
</div>
</section>
<section class="panel">
<div class="row">
<label>当前站点</label>
<span class="site-host" id="siteHost">—</span>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>此网站不启用</label>
<p class="hint">加入忽略列表,本站不显示遮罩</p>
</div>
<label class="switch">
<input type="checkbox" id="siteIgnore">
<span class="track"></span>
</label>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>此站单独设置</label>
<p class="hint" id="siteMemHint">记住本站色调与强度</p>
</div>
<label class="switch">
<input type="checkbox" id="siteMemory">
<span class="track"></span>
</label>
</div>
<button class="btn small" id="siteClear" style="width:100%;margin-top:10px">移除本站设置</button>
<div class="row" style="margin-top:12px">
<label>全部规则</label>
<span class="hint" id="siteRulesCount"></span>
</div>
<div class="site-rules" id="siteRules"></div>
</section>
<section class="panel">
<div class="row">
<label>📊 本周统计</label>
<span class="hint" id="statsTotal"></span>
</div>
<div class="stats-row" id="statsRow"></div>
</section>
<section class="panel">
<div class="row">
<div>
<label>夜间自动关闭</label>
<p class="hint">固定时区 22:00 – 07:00 自动切换</p>
</div>
<label class="switch">
<input type="checkbox" id="autoSchedule">
<span class="track"></span>
</label>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>20-20-20 提醒</label>
<p class="hint">每 20 分钟提醒看远处 20 秒</p>
</div>
<label class="switch">
<input type="checkbox" id="remind20">
<span class="track"></span>
</label>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>页面悬浮计时</label>
<p class="hint">默认关闭 · 开启后番茄进行中显示倒计时</p>
</div>
<label class="switch">
<input type="checkbox" id="showFloater">
<span class="track"></span>
</label>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>离开自动暂停</label>
<p class="hint">离开 5 分钟自动暂停番茄</p>
</div>
<label class="switch">
<input type="checkbox" id="autoPause">
<span class="track"></span>
</label>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>声音提示</label>
<p class="hint">默认关闭 · 番茄完成时播放提示音</p>
</div>
<label class="switch">
<input type="checkbox" id="sound">
<span class="track"></span>
</label>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>时区基准</label>
<p class="hint">跨天清零与夜间定时使用</p>
</div>
<div class="tz-row">
<button class="stp" id="tzMinus">−</button>
<span class="stepper-val" id="tzValue">UTC+8</span>
<button class="stp" id="tzPlus">+</button>
</div>
</div>
<div class="row" style="margin-top:12px">
<div>
<label>面板主题</label>
<p class="hint">默认跟随系统</p>
</div>
<div class="theme-seg">
<button class="seg" data-theme="dark">深</button>
<button class="seg" data-theme="light">浅</button>
<button class="seg" data-theme="auto">自动</button>
</div>
</div>
<button class="btn snooze" id="snoozeBtn">暂时关闭 1 小时</button>
<p class="hint" id="snoozeInfo"></p>
<div class="data-row">
<button class="btn small" id="exportBtn">导出设置</button>
<button class="btn small" id="importBtn">导入设置</button>
</div>
<input type="file" id="importFile" accept=".json,application/json" style="display:none">
<p class="hint" id="dataStatus"></p>
<button class="btn danger" id="resetBtn">恢复默认设置</button>
</section>
<footer>
<span><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd> 护眼 · <kbd>P</kbd> 番茄</span>
<span>v1.0.0</span>
</footer>
<script src="popup.js"></script>
</body>
</html>
- popup.js
const $ = id => document.getElementById(id);
const COLOR_NAMES = {
'#C7EDCC': '豆沙绿',
'#F5DEB3': '暖黄',
'#D4E8D4': '黄绿混',
'#0d1117': '夜间黑'
};
const APP_TAG = 'tomato-guard';
const VER = '1.0.0';
const fmt = ms => {
const s = Math.max(0, Math.ceil(ms / 1000));
return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0');
};
const BOUNDS = { workMin: [5, 90], breakMin: [1, 30], longMin: [5, 45], longEvery: [2, 8], dailyGoal: [1, 16] };
function normTz(v) {
const n = parseInt(v, 10);
return (Number.isFinite(n) && n >= -12 && n <= 14) ? n : 8;
}
function tzLabel(tz) {
return 'UTC' + (tz >= 0 ? '+' + tz : tz);
}
let tzOffset = 8;
function todayStr() {
const n = new Date(Date.now() + tzOffset * 3600000);
return n.getUTCFullYear() + '-' + String(n.getUTCMonth() + 1).padStart(2, '0') + '-' + String(n.getUTCDate()).padStart(2, '0');
}
// ---------- 面板主题(默认 auto 跟随系统) ----------
let themeVal = 'auto';
const mqTheme = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
function applyTheme() {
const isDark = themeVal === 'dark' || (themeVal === 'auto' && (!mqTheme || mqTheme.matches));
document.body.classList.toggle('light', !isDark);
document.querySelectorAll('.seg').forEach(b => b.classList.toggle('active', b.dataset.theme === themeVal));
}
if (mqTheme) {
if (mqTheme.addEventListener) mqTheme.addEventListener('change', () => { if (themeVal === 'auto') applyTheme(); });
else if (mqTheme.addListener) mqTheme.addListener(() => { if (themeVal === 'auto') applyTheme(); });
}
const els = {
toggle: $('toggle'),
opacity: $('opacity'),
opacityValue: $('opacityValue'),
autoSchedule: $('autoSchedule'),
remind20: $('remind20'),
showFloater: $('showFloater'),
autoPause: $('autoPause'),
sound: $('sound'),
followSystem: $('followSystem'),
snoozeBtn: $('snoozeBtn'),
snoozeInfo: $('snoozeInfo'),
statusText: $('statusText'),
customColor: $('customColor'),
customColorCode: $('customColorCode'),
swatches: [...document.querySelectorAll('.swatch')],
pomoStatus: $('pomoStatus'),
pomoTime: $('pomoTime'),
pomoBar: $('pomoBar'),
pomoToggle: $('pomoToggle'),
pomoReset: $('pomoReset'),
pomoRounds: $('pomoRounds'),
pomoTask: $('pomoTask'),
chips: [...document.querySelectorAll('.chip')],
stp: [...document.querySelectorAll('.stp')],
tzValue: $('tzValue'),
tzMinus: $('tzMinus'),
tzPlus: $('tzPlus'),
exportBtn: $('exportBtn'),
importBtn: $('importBtn'),
importFile: $('importFile'),
dataStatus: $('dataStatus'),
resetBtn: $('resetBtn'),
siteHost: $('siteHost'),
siteIgnore: $('siteIgnore'),
siteMemory: $('siteMemory'),
siteMemHint: $('siteMemHint'),
siteClear: $('siteClear'),
siteRules: $('siteRules'),
siteRulesCount: $('siteRulesCount'),
statsRow: $('statsRow'),
statsTotal: $('statsTotal')
};
let pomoState = null;
let currentHost = '';
function getActiveHost(cb) {
chrome.tabs.query({ active: true, currentWindow: true }, tabs => {
let host = '';
if (tabs && tabs[0] && tabs[0].url) {
try {
const u = new URL(tabs[0].url);
host = u.hostname || (u.protocol === 'file:' ? 'file://' : '');
} catch (e) {
host = '';
}
}
cb(host);
});
}
function displayHost(h) {
if (!h) return '无法读取';
if (h === 'file://') return '本地文件';
return h;
}
function renderStats(stats) {
const days = [];
for (let i = 6; i >= 0; i--) {
const n = new Date(Date.now() - i * 86400000 + tzOffset * 3600000);
const key = n.getUTCFullYear() + '-' + String(n.getUTCMonth() + 1).padStart(2, '0') + '-' + String(n.getUTCDate()).padStart(2, '0');
const count = (stats || {})[key] || 0;
days.push({ count: count, label: '周' + ['日', '一', '二', '三', '四', '五', '六'][n.getUTCDay()], today: i === 0 });
}
const max = Math.max(1, ...days.map(d => d.count));
const total = days.reduce((s, d) => s + d.count, 0);
els.statsTotal.textContent = '近 7 天共 ' + total + ' 个';
els.statsRow.innerHTML = '';
days.forEach(d => {
const col = document.createElement('div');
col.className = 'stat-col' + (d.today ? ' today' : '');
const bar = document.createElement('div');
bar.className = 'stat-bar';
bar.style.height = Math.max(2, Math.round((d.count / max) * 46)) + 'px';
const num = document.createElement('div');
num.className = 'stat-num';
num.textContent = d.count || '';
const day = document.createElement('div');
day.className = 'stat-day';
day.textContent = d.label;
col.appendChild(bar);
col.appendChild(num);
col.appendChild(day);
els.statsRow.appendChild(col);
});
}
function renderSiteRules(d) {
const map = {};
(Array.isArray(d.siteIgnore) ? d.siteIgnore : []).forEach(h => {
map[h] = map[h] || {};
map[h].ignore = true;
});
Object.keys(d.siteSettings || {}).forEach(h => {
map[h] = Object.assign({}, map[h] || {}, { site: d.siteSettings[h] });
});
const hosts = Object.keys(map);
els.siteRulesCount.textContent = hosts.length ? '共 ' + hosts.length + ' 条' : '';
els.siteRules.innerHTML = '';
if (!hosts.length) {
const empty = document.createElement('div');
empty.className = 'hint';
empty.style.padding = '4px 0';
empty.textContent = '暂无规则';
els.siteRules.appendChild(empty);
return;
}
hosts.forEach(h => {
const row = document.createElement('div');
row.className = 'site-rule';
const name = document.createElement('span');
name.className = 'rh';
name.textContent = h === 'file://' ? '本地文件' : h;
name.title = h;
row.appendChild(name);
const info = map[h];
if (info.ignore) {
const t = document.createElement('span');
t.className = 'rtag ignore';
t.textContent = '已忽略';
row.appendChild(t);
}
if (info.site) {
const t = document.createElement('span');
t.className = 'rtag site';
t.textContent = (COLOR_NAMES[info.site.color] || info.site.color || '自定义') + ' · ' + Math.round((parseFloat(info.site.opacity) || 0.4) * 100) + '%';
row.appendChild(t);
}
const del = document.createElement('button');
del.className = 'rdel';
del.textContent = '×';
del.title = '删除此规则';
del.addEventListener('click', () => {
chrome.storage.sync.get(['siteIgnore', 'siteSettings'], sd => {
const list = (Array.isArray(sd.siteIgnore) ? sd.siteIgnore.slice() : []).filter(x => x !== h);
const m = Object.assign({}, sd.siteSettings || {});
delete m[h];
chrome.storage.sync.set({ siteIgnore: list, siteSettings: m });
});
});
row.appendChild(del);
els.siteRules.appendChild(row);
});
}
function render(d) {
const enabled = d.enabled !== false;
const color = /^#[0-9a-fA-F]{6}$/.test(d.color || '') ? d.color.toLowerCase() : '#c7edcc';
const val = Math.round((parseFloat(d.opacity) || 0.4) * 100);
els.toggle.checked = enabled;
document.body.classList.toggle('off', !enabled);
els.statusText.textContent = enabled ? '已开启 · ' + (COLOR_NAMES[color] || '自定义') : '已关闭';
els.opacity.value = val;
els.opacityValue.textContent = val + '%';
els.autoSchedule.checked = !!d.autoSchedule;
els.remind20.checked = !!d.remind20;
els.showFloater.checked = d.showFloater === true;
els.autoPause.checked = d.autoPause !== false;
els.sound.checked = d.sound === true; // 默认关闭
els.followSystem.checked = !!d.followSystem;
els.customColor.value = color;
els.customColorCode.value = color;
els.swatches.forEach(b => b.classList.toggle('active', b.dataset.color === d.color));
els.tzValue.textContent = tzLabel(normTz(d.tzOffset));
els.siteHost.textContent = displayHost(currentHost);
const canSite = !!currentHost;
els.siteIgnore.disabled = !canSite;
els.siteMemory.disabled = !canSite;
els.siteClear.disabled = !canSite;
const ignored = canSite && Array.isArray(d.siteIgnore) && d.siteIgnore.indexOf(currentHost) !== -1;
els.siteIgnore.checked = !!ignored;
const site = canSite && d.siteSettings ? d.siteSettings[currentHost] : null;
els.siteMemory.checked = !!site;
els.siteMemHint.textContent = site
? '本站:' + (COLOR_NAMES[site.color] || site.color || '自定义') + ' · ' + Math.round((parseFloat(site.opacity) || 0.4) * 100) + '%'
: '记住本站色调与强度';
renderSiteRules(d);
renderStats(d.stats);
if (d.snoozeUntil && Date.now() < d.snoozeUntil) {
els.snoozeInfo.textContent = '已暂时关闭,剩余 ' + Math.ceil((d.snoozeUntil - Date.now()) / 60000) + ' 分钟';
} else {
els.snoozeInfo.textContent = '';
}
}
function renderPomo() {
if (!pomoState) return;
const p = pomoState;
let remain = p.status === 'running' ? p.endAt - Date.now() : p.remaining;
els.pomoTime.textContent = fmt(remain);
const total = p.total || (p.phase === 'work' ? p.workMin : p.breakMin) * 60000;
const prog = Math.min(1, Math.max(0, 1 - remain / total));
els.pomoBar.style.width = (prog * 100).toFixed(1) + '%';
els.pomoRounds.textContent = p.rounds + '/' + p.dailyGoal;
['workMin', 'breakMin', 'longMin', 'longEvery', 'dailyGoal'].forEach(k => {
const el = $('sv-' + k);
if (el) el.textContent = p[k];
});
els.chips.forEach(ch => {
const active = +ch.dataset.work === p.workMin && +ch.dataset.break === p.breakMin && +ch.dataset.long === p.longMin;
ch.classList.toggle('active', active);
});
if (document.activeElement !== els.pomoTask) els.pomoTask.value = p.task || '';
if (p.status === 'running') {
els.pomoStatus.textContent = p.phase === 'work'
? (p.task ? '专注中 · ' + p.task : '专注中')
: '休息中';
els.pomoToggle.textContent = '暂停';
} else if (p.status === 'paused') {
els.pomoStatus.textContent = p.task ? '已暂停 · ' + p.task : '已暂停';
els.pomoToggle.textContent = '继续';
} else {
els.pomoStatus.textContent = '未开始';
els.pomoToggle.textContent = '开始';
}
}
const STORAGE_KEYS = ['enabled', 'color', 'opacity', 'autoSchedule', 'remind20', 'showFloater', 'autoPause', 'sound', 'followSystem', 'snoozeUntil', 'pomodoro', 'tzOffset', 'siteIgnore', 'siteSettings', 'stats', 'theme'];
const load = () => {
getActiveHost(h => {
currentHost = h;
chrome.storage.sync.get(STORAGE_KEYS, d => {
tzOffset = normTz(d.tzOffset);
themeVal = d.theme || 'auto';
applyTheme();
let po = d.pomodoro || null;
if (po && po.pomoDate !== todayStr()) {
po = Object.assign({}, po, { rounds: 0, pomoDate: todayStr() });
chrome.storage.sync.set({ pomodoro: po });
}
pomoState = po || { status: 'idle', phase: 'work', endAt: 0, remaining: 25 * 60000, total: 25 * 60000, workMin: 25, breakMin: 5, longMin: 15, longEvery: 4, dailyGoal: 8, rounds: 0, task: '', pomoDate: todayStr() };
render(d);
renderPomo();
});
});
};
load();
chrome.storage.onChanged.addListener(load);
setInterval(renderPomo, 250);
// ---------- 基础设置 ----------
els.toggle.addEventListener('change', e => chrome.storage.sync.set({ enabled: e.target.checked }));
els.opacity.addEventListener('input', e => {
chrome.storage.sync.set({ opacity: e.target.value / 100 });
els.opacityValue.textContent = e.target.value + '%';
});
els.swatches.forEach(b => b.addEventListener('click', () => chrome.storage.sync.set({ color: b.dataset.color })));
els.customColor.addEventListener('input', e => {
chrome.storage.sync.set({ color: e.target.value });
els.customColorCode.value = e.target.value;
});
els.customColorCode.addEventListener('input', e => {
const v = e.target.value.trim();
if (/^#[0-9a-fA-F]{6}$/.test(v)) {
e.target.classList.remove('err');
const norm = v.toLowerCase();
els.customColor.value = norm;
chrome.storage.sync.set({ color: norm });
} else {
e.target.classList.toggle('err', v.length > 0);
}
});
els.customColorCode.addEventListener('keydown', e => {
if (e.key === 'Enter') e.target.blur();
});
els.autoSchedule.addEventListener('change', e => chrome.storage.sync.set({ autoSchedule: e.target.checked }));
els.remind20.addEventListener('change', e => chrome.storage.sync.set({ remind20: e.target.checked }));
els.showFloater.addEventListener('change', e => chrome.storage.sync.set({ showFloater: e.target.checked }));
els.autoPause.addEventListener('change', e => chrome.storage.sync.set({ autoPause: e.target.checked }));
els.sound.addEventListener('change', e => chrome.storage.sync.set({ sound: e.target.checked }));
els.followSystem.addEventListener('change', e => chrome.storage.sync.set({ followSystem: e.target.checked }));
els.tzMinus.addEventListener('click', () => {
tzOffset = Math.max(-12, tzOffset - 1);
chrome.storage.sync.set({ tzOffset: tzOffset });
});
els.tzPlus.addEventListener('click', () => {
tzOffset = Math.min(14, tzOffset + 1);
chrome.storage.sync.set({ tzOffset: tzOffset });
});
els.snoozeBtn.addEventListener('click', () => {
chrome.storage.sync.set({ enabled: false, snoozeUntil: Date.now() + 3600000 });
els.snoozeInfo.textContent = '已暂时关闭,剩余 60 分钟';
});
// ---------- 站点设置 ----------
els.siteIgnore.addEventListener('change', e => {
if (!currentHost) return;
chrome.storage.sync.get('siteIgnore', d => {
const list = (Array.isArray(d.siteIgnore) ? d.siteIgnore.slice() : []);
const idx = list.indexOf(currentHost);
if (e.target.checked && idx === -1) list.push(currentHost);
if (!e.target.checked && idx !== -1) list.splice(idx, 1);
chrome.storage.sync.set({ siteIgnore: list });
});
});
els.siteMemory.addEventListener('change', e => {
if (!currentHost) return;
chrome.storage.sync.get(['color', 'opacity', 'siteSettings'], d => {
const map = Object.assign({}, d.siteSettings || {});
if (e.target.checked) {
map[currentHost] = { color: d.color || '#C7EDCC', opacity: parseFloat(d.opacity) || 0.4 };
} else {
delete map[currentHost];
}
chrome.storage.sync.set({ siteSettings: map });
});
});
els.siteClear.addEventListener('click', () => {
if (!currentHost) return;
chrome.storage.sync.get(['siteIgnore', 'siteSettings'], d => {
const list = (Array.isArray(d.siteIgnore) ? d.siteIgnore.slice() : []).filter(h => h !== currentHost);
const map = Object.assign({}, d.siteSettings || {});
delete map[currentHost];
chrome.storage.sync.set({ siteIgnore: list, siteSettings: map });
});
});
// ---------- 主题 ----------
document.querySelectorAll('.seg').forEach(b => b.addEventListener('click', () => {
themeVal = b.dataset.theme;
chrome.storage.sync.set({ theme: themeVal });
applyTheme();
}));
// ---------- 导出 / 导入 / 重置 ----------
function setStatus(msg) {
els.dataStatus.textContent = msg;
setTimeout(() => { els.dataStatus.textContent = ''; }, 3000);
}
function exportData() {
chrome.storage.sync.get(null, items => {
const payload = {
app: APP_TAG,
version: VER,
exportedAt: new Date().toISOString(),
data: items
};
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'tomato-guard-backup-' + new Date().toISOString().slice(0, 10) + '.json';
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 60000);
setStatus('已导出设置文件 ✅');
});
}
function importData(file) {
const reader = new FileReader();
reader.onload = () => {
try {
const parsed = JSON.parse(reader.result);
let data = parsed;
if (parsed && parsed.app === APP_TAG && parsed.data && typeof parsed.data === 'object') {
data = parsed.data;
}
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('bad');
chrome.storage.sync.clear(() => {
chrome.storage.sync.set(data, () => {
setStatus('导入成功 ✅');
load();
});
});
} catch (e) {
setStatus('导入失败:文件格式不正确');
}
};
reader.onerror = () => setStatus('导入失败:无法读取文件');
reader.readAsText(file);
}
els.exportBtn.addEventListener('click', exportData);
els.importBtn.addEventListener('click', () => els.importFile.click());
els.importFile.addEventListener('change', e => {
if (e.target.files && e.target.files[0]) importData(e.target.files[0]);
e.target.value = '';
});
els.resetBtn.addEventListener('click', () => {
if (els.resetBtn.dataset.arm !== '1') {
els.resetBtn.dataset.arm = '1';
els.resetBtn.textContent = '确认重置?再点一次';
setTimeout(() => {
els.resetBtn.dataset.arm = '0';
els.resetBtn.textContent = '恢复默认设置';
}, 3000);
return;
}
els.resetBtn.dataset.arm = '0';
els.resetBtn.textContent = '恢复默认设置';
chrome.storage.sync.clear(() => {
setStatus('已恢复默认设置 ✅');
load();
});
});
// ---------- 番茄钟:开始/暂停/重置 ----------
els.pomoToggle.addEventListener('click', () => {
const p = pomoState;
if (p.status === 'running') {
chrome.storage.sync.set({ pomodoro: Object.assign({}, p, { status: 'paused', remaining: Math.max(0, p.endAt - Date.now()) }) });
} else if (p.status === 'paused') {
chrome.storage.sync.set({ pomodoro: Object.assign({}, p, { status: 'running', endAt: Date.now() + p.remaining }) });
} else {
const total = p.workMin * 60000;
const task = els.pomoTask.value.trim();
chrome.storage.sync.set({ pomodoro: Object.assign({}, p, { status: 'running', phase: 'work', endAt: Date.now() + total, remaining: total, total: total, task: task }) });
}
});
els.pomoReset.addEventListener('click', () => {
const p = pomoState || {};
const total = p.workMin * 60000;
chrome.storage.sync.set({ pomodoro: Object.assign({}, p, { status: 'idle', phase: 'work', endAt: 0, remaining: total, total: total, rounds: 0, task: '' }) });
});
els.chips.forEach(ch => ch.addEventListener('click', () => {
const p = pomoState || {};
const patch = { workMin: +ch.dataset.work, breakMin: +ch.dataset.break, longMin: +ch.dataset.long };
if (p.status === 'idle') patch.remaining = patch.workMin * 60000;
chrome.storage.sync.set({ pomodoro: Object.assign({}, p, patch) });
}));
els.stp.forEach(btn => btn.addEventListener('click', () => {
const key = btn.dataset.key;
const delta = parseInt(btn.dataset.delta, 10);
const p = pomoState || {};
const range = BOUNDS[key];
const cur = p[key] != null ? p[key] : 25;
const next = Math.min(range[1], Math.max(range[0], cur + delta));
const patch = {};
patch[key] = next;
if (key === 'workMin' && p.status === 'idle') patch.remaining = next * 60000;
chrome.storage.sync.set({ pomodoro: Object.assign({}, p, patch) });
}));
一键启动
TomatoGuard.zip (35.4 KB)
功能介绍


最新回复 (2)
-
𝓕-𝓓𝓻𝓸𝓲𝓭 楼主 08-03 18:541楼
随机密码生成器.zip
护眼助手.zip
源代码按照MIT协议完全开源
之前搓了一个护眼助手,界面比较简陋,功能不够强大。这次DS V4 Flash出来了,让它彻底的改了改,还把番茄钟塞了进去,修复了很多BUG,颜值提升很大,功能也加了很多。 -
edgyTaro 08-03 20:542楼大佬,请问有没有使用预览,看看效果如何
* 帖子来源Linux.do
附近帖子
- ↑又一个食品暴雷了!牛蛙!我的妈呀我还吃了!
- ↑我发现AI似乎真的能替代我的工作,我本来也没啥事,做的是一重复的工作
- ↑惊了,我的 Claude Code 微操 11 个 Codex session,这才是 Opus 5 / Fable 的隐藏能力
- ↑求祝各位佬友:双非大二啥也不懂如何去找寒假工/实习?
- ↑upcloud 莫名其妙封禁我的账号
- 📍 🎉高颜值番茄护眼浏览器拓展震撼开源!
- ↓想邀请一下实习的Leader老师进入论坛,请问有没有管理员佬可以给个注册链接,有没有prime佬可以发个链接
- ↓CTExcel现在激活根本不需要打电话
- ↓opencode go 大量Alipay付款没能及时激活,建议没上车的等一等
- ↓一个简易逆向API
- ↓dedirock的机器怎么样?想用来部署cpa