|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="zh-CN"> |
| 3 | +<head> |
| 4 | +<meta charset="UTF-8"> |
| 5 | +<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> |
| 6 | +<title>🍅 番茄工作计时器</title> |
| 7 | +<style> |
| 8 | +*{margin:0;padding:0;box-sizing:border-box} |
| 9 | +:root{ |
| 10 | + --focus-from:#ff9a9e;--focus-to:#fad0c4; |
| 11 | + --break-from:#a1c4fd;--break-to:#c2e9fb; |
| 12 | + --bg-from:var(--focus-from);--bg-to:var(--focus-to); |
| 13 | + --card:rgba(255,255,255,0.55);--card-border:rgba(255,255,255,0.7); |
| 14 | + --text:#2d3436;--text-light:#636e72;--accent:#e17055;--accent-light:#fab1a0; |
| 15 | + --green:#00b894;--shadow:0 8px 32px rgba(0,0,0,0.08); |
| 16 | +} |
| 17 | +body{ |
| 18 | + font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; |
| 19 | + min-height:100vh;background:linear-gradient(135deg,var(--bg-from),var(--bg-to)); |
| 20 | + color:var(--text);transition:background .6s ease;display:flex;flex-direction:column;align-items:center; |
| 21 | + padding:20px 16px 40px; |
| 22 | +} |
| 23 | +h1{font-size:1.4rem;font-weight:700;margin-bottom:16px;letter-spacing:-.5px;text-align:center} |
| 24 | +.mode-tabs{display:flex;gap:6px;margin-bottom:20px;background:var(--card);border-radius:14px;padding:4px;backdrop-filter:blur(12px);border:1px solid var(--card-border)} |
| 25 | +.mode-tabs button{ |
| 26 | + padding:8px 20px;border:none;border-radius:11px;font-size:.9rem;font-weight:600; |
| 27 | + cursor:pointer;background:transparent;color:var(--text-light);transition:all .25s; |
| 28 | +} |
| 29 | +.mode-tabs button.active{background:white;color:var(--accent);box-shadow:var(--shadow)} |
| 30 | +.timer-ring{position:relative;width:240px;height:240px;margin-bottom:24px} |
| 31 | +.timer-ring svg{transform:rotate(-90deg);width:100%;height:100%} |
| 32 | +.timer-ring .bg-circle{fill:none;stroke:rgba(255,255,255,0.5);stroke-width:8} |
| 33 | +.timer-ring .fg-circle{fill:none;stroke:white;stroke-width:8;stroke-linecap:round;transition:stroke-dashoffset .5s linear;filter:drop-shadow(0 0 8px rgba(255,255,255,0.4))} |
| 34 | +.timer-display{ |
| 35 | + position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center; |
| 36 | +} |
| 37 | +.timer-display .time{font-size:3.2rem;font-weight:800;font-variant-numeric:tabular-nums;letter-spacing:-1px} |
| 38 | +.timer-display .label{font-size:.85rem;color:var(--text-light);margin-top:2px} |
| 39 | +.controls{display:flex;gap:12px;margin-bottom:24px} |
| 40 | +.controls button{ |
| 41 | + width:52px;height:52px;border:none;border-radius:50%;font-size:1.3rem;cursor:pointer; |
| 42 | + background:var(--card);backdrop-filter:blur(12px);border:1px solid var(--card-border); |
| 43 | + color:var(--text);box-shadow:var(--shadow);transition:all .2s;display:flex;align-items:center;justify-content:center; |
| 44 | +} |
| 45 | +.controls button:hover{transform:scale(1.08);background:rgba(255,255,255,0.8)} |
| 46 | +.controls button:active{transform:scale(.95)} |
| 47 | +.controls button.primary{background:white;color:var(--accent);width:60px;height:60px;font-size:1.5rem} |
| 48 | +.task-input-area{width:100%;max-width:400px;margin-bottom:20px} |
| 49 | +.task-input-area input{ |
| 50 | + width:100%;padding:12px 16px;border:none;border-radius:12px;font-size:.95rem; |
| 51 | + background:var(--card);backdrop-filter:blur(12px);border:1px solid var(--card-border); |
| 52 | + color:var(--text);outline:none;transition:border .2s; |
| 53 | +} |
| 54 | +.task-input-area input:focus{border-color:var(--accent)} |
| 55 | +.task-input-area input::placeholder{color:var(--text-light)} |
| 56 | +.stats{width:100%;max-width:400px;margin-bottom:24px} |
| 57 | +.stats-row{display:flex;gap:10px} |
| 58 | +.stat-card{ |
| 59 | + flex:1;padding:14px 12px;background:var(--card);backdrop-filter:blur(12px); |
| 60 | + border:1px solid var(--card-border);border-radius:14px;text-align:center;box-shadow:var(--shadow); |
| 61 | +} |
| 62 | +.stat-card .val{font-size:1.8rem;font-weight:800;color:var(--accent)} |
| 63 | +.stat-card .lbl{font-size:.75rem;color:var(--text-light);margin-top:2px} |
| 64 | +.task-list{width:100%;max-width:400px} |
| 65 | +.task-list h2{font-size:1rem;font-weight:700;margin-bottom:10px} |
| 66 | +.task-item{ |
| 67 | + display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:8px; |
| 68 | + background:var(--card);backdrop-filter:blur(12px);border:1px solid var(--card-border); |
| 69 | + border-radius:12px;box-shadow:var(--shadow);transition:all .2s; |
| 70 | +} |
| 71 | +.task-item .dot{width:8px;height:8px;border-radius:50%;background:var(--accent-light);flex-shrink:0} |
| 72 | +.task-item .dot.done{background:var(--green)} |
| 73 | +.task-item .name{flex:1;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} |
| 74 | +.task-item .count{font-size:.8rem;color:var(--text-light);font-weight:600;white-space:nowrap} |
| 75 | +.task-item .del{background:none;border:none;font-size:1.1rem;cursor:pointer;color:var(--text-light);padding:0 2px} |
| 76 | +.task-item .del:hover{color:#d63031} |
| 77 | +.empty{color:var(--text-light);font-size:.85rem;text-align:center;padding:16px 0} |
| 78 | +@media(max-width:420px){ |
| 79 | + .timer-ring{width:200px;height:200px} |
| 80 | + .timer-display .time{font-size:2.6rem} |
| 81 | + .stat-card .val{font-size:1.4rem} |
| 82 | + h1{font-size:1.2rem} |
| 83 | +} |
| 84 | +</style> |
| 85 | +</head> |
| 86 | +<body> |
| 87 | +<h1>🍅 番茄工作计时器</h1> |
| 88 | +<div class="mode-tabs"> |
| 89 | + <button class="active" onclick="setMode('focus')">专注 25:00</button> |
| 90 | + <button onclick="setMode('break')">休息 05:00</button> |
| 91 | +</div> |
| 92 | +<div class="timer-ring"> |
| 93 | + <svg viewBox="0 0 240 240"> |
| 94 | + <circle class="bg-circle" cx="120" cy="120" r="108"/> |
| 95 | + <circle class="fg-circle" id="fgCircle" cx="120" cy="120" r="108" |
| 96 | + stroke-dasharray="678.58" stroke-dashoffset="0"/> |
| 97 | + </svg> |
| 98 | + <div class="timer-display"> |
| 99 | + <div class="time" id="timerText">25:00</div> |
| 100 | + <div class="label" id="modeLabel">专注时间</div> |
| 101 | + </div> |
| 102 | +</div> |
| 103 | +<div class="controls"> |
| 104 | + <button onclick="resetTimer()" title="重置">↺</button> |
| 105 | + <button class="primary" id="playBtn" onclick="toggleTimer()" title="开始/暂停">▶</button> |
| 106 | + <button onclick="skipTimer()" title="跳过">⏭</button> |
| 107 | +</div> |
| 108 | +<div class="task-input-area"> |
| 109 | + <input id="taskInput" type="text" placeholder="输入当前任务,回车添加…" maxlength="40" |
| 110 | + onkeydown="if(event.key==='Enter')addTask()"> |
| 111 | +</div> |
| 112 | +<div class="stats"> |
| 113 | + <div class="stats-row"> |
| 114 | + <div class="stat-card"><div class="val" id="statPomos">0</div><div class="lbl">今日番茄</div></div> |
| 115 | + <div class="stat-card"><div class="val" id="statMins">0</div><div class="lbl">专注分钟</div></div> |
| 116 | + <div class="stat-card"><div class="val" id="statTasks">0</div><div class="lbl">完成任务</div></div> |
| 117 | + </div> |
| 118 | +</div> |
| 119 | +<div class="task-list"> |
| 120 | + <h2>📋 今日任务</h2> |
| 121 | + <div id="taskContainer"></div> |
| 122 | +</div> |
| 123 | + |
| 124 | +<script> |
| 125 | +const FOCUS_SEC=25*60, BREAK_SEC=5*60, CIRCUM=2*Math.PI*108; |
| 126 | +let mode='focus', remaining=FOCUS_SEC, total=FOCUS_SEC, running=false, interval=null; |
| 127 | +let tasks=[], currentTaskIdx=-1; |
| 128 | + |
| 129 | +// --- Audio --- |
| 130 | +function playSound(type){ |
| 131 | + const ac=new (window.AudioContext||window.webkitAudioContext)(); |
| 132 | + if(type==='complete'){ |
| 133 | + [523.25,659.25,783.99].forEach((f,i)=>{ |
| 134 | + const o=ac.createOscillator(),g=ac.createGain(); |
| 135 | + o.type='sine';o.frequency.value=f; |
| 136 | + g.gain.setValueAtTime(.3,ac.currentTime+i*.18); |
| 137 | + g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+i*.18+.5); |
| 138 | + o.connect(g);g.connect(ac.destination); |
| 139 | + o.start(ac.currentTime+i*.18);o.stop(ac.currentTime+i*.18+.5); |
| 140 | + }); |
| 141 | + } else { |
| 142 | + const o=ac.createOscillator(),g=ac.createGain(); |
| 143 | + o.type='sine';o.frequency.value=880; |
| 144 | + g.gain.setValueAtTime(.15,ac.currentTime); |
| 145 | + g.gain.exponentialRampToValueAtTime(.001,ac.currentTime+.15); |
| 146 | + o.connect(g);g.connect(ac.destination); |
| 147 | + o.start();o.stop(ac.currentTime+.15); |
| 148 | + } |
| 149 | +} |
| 150 | + |
| 151 | +// --- Persistence --- |
| 152 | +function todayKey(){return new Date().toISOString().slice(0,10)} |
| 153 | +function loadData(){ |
| 154 | + try{ |
| 155 | + const d=JSON.parse(localStorage.getItem('pomo_'+todayKey())); |
| 156 | + if(d){tasks=d.tasks||[];return d} |
| 157 | + }catch(e){} |
| 158 | + tasks=[];return null; |
| 159 | +} |
| 160 | +function saveData(){ |
| 161 | + const d={tasks,ts:Date.now()}; |
| 162 | + localStorage.setItem('pomo_'+todayKey(),JSON.stringify(d)); |
| 163 | + updateStats(); |
| 164 | +} |
| 165 | + |
| 166 | +// --- Timer --- |
| 167 | +function fmt(s){const m=Math.floor(s/60),sec=s%60;return String(m).padStart(2,'0')+':'+String(sec).padStart(2,'0')} |
| 168 | +function renderTimer(){ |
| 169 | + document.getElementById('timerText').textContent=fmt(remaining); |
| 170 | + const pct=1-remaining/total; |
| 171 | + document.getElementById('fgCircle').style.strokeDashoffset=CIRCUM*pct; |
| 172 | +} |
| 173 | +function updateTheme(){ |
| 174 | + const r=document.documentElement.style; |
| 175 | + if(mode==='focus'){r.setProperty('--bg-from','#ff9a9e');r.setProperty('--bg-to','#fad0c4')} |
| 176 | + else{r.setProperty('--bg-from','#a1c4fd');r.setProperty('--bg-to','#c2e9fb')} |
| 177 | +} |
| 178 | +function setMode(m){ |
| 179 | + mode=m;total=m==='focus'?FOCUS_SEC:BREAK_SEC;remaining=total; |
| 180 | + document.querySelectorAll('.mode-tabs button').forEach((b,i)=>{ |
| 181 | + b.classList.toggle('active',(i===0&&m==='focus')||(i===1&&m==='break')); |
| 182 | + }); |
| 183 | + document.getElementById('modeLabel').textContent=m==='focus'?'专注时间':'休息时间'; |
| 184 | + updateTheme();renderTimer();if(running)stopInterval(); |
| 185 | + document.getElementById('playBtn').textContent='▶';running=false; |
| 186 | +} |
| 187 | +function stopInterval(){if(interval){clearInterval(interval);interval=null}} |
| 188 | +function tick(){ |
| 189 | + remaining--;renderTimer(); |
| 190 | + if(remaining<=0){ |
| 191 | + stopInterval();running=false;document.getElementById('playBtn').textContent='▶'; |
| 192 | + playSound('complete'); |
| 193 | + if(mode==='focus'){ |
| 194 | + if(currentTaskIdx>=0&&tasks[currentTaskIdx])tasks[currentTaskIdx].pomos=(tasks[currentTaskIdx].pomos||0)+1; |
| 195 | + saveData();renderTasks(); |
| 196 | + if(Notification.permission==='granted')new Notification('🍅 番茄完成!',{body:'休息一下吧'}); |
| 197 | + setTimeout(()=>setMode('break'),800); |
| 198 | + }else{ |
| 199 | + if(Notification.permission==='granted')new Notification('☕ 休息结束!',{body:'继续专注吧'}); |
| 200 | + setTimeout(()=>setMode('focus'),800); |
| 201 | + } |
| 202 | + } |
| 203 | +} |
| 204 | +function toggleTimer(){ |
| 205 | + if(running){ |
| 206 | + stopInterval();running=false;document.getElementById('playBtn').textContent='▶';playSound('click'); |
| 207 | + }else{ |
| 208 | + if(remaining<=0){remaining=total;renderTimer()} |
| 209 | + running=true;document.getElementById('playBtn').textContent='⏸'; |
| 210 | + interval=setInterval(tick,1000);playSound('click'); |
| 211 | + } |
| 212 | +} |
| 213 | +function resetTimer(){ |
| 214 | + stopInterval();running=false;remaining=total;renderTimer(); |
| 215 | + document.getElementById('playBtn').textContent='▶';playSound('click'); |
| 216 | +} |
| 217 | +function skipTimer(){ |
| 218 | + remaining=0;tick(); |
| 219 | +} |
| 220 | + |
| 221 | +// --- Tasks --- |
| 222 | +function addTask(){ |
| 223 | + const inp=document.getElementById('taskInput'),name=inp.value.trim(); |
| 224 | + if(!name)return; |
| 225 | + tasks.push({name,pomos:0,done:false}); |
| 226 | + inp.value='';saveData();renderTasks(); |
| 227 | + playSound('click'); |
| 228 | +} |
| 229 | +function toggleTask(i){ |
| 230 | + tasks[i].done=!tasks[i].done; |
| 231 | + if(tasks[i].done&¤tTaskIdx===i)currentTaskIdx=-1; |
| 232 | + saveData();renderTasks();playSound('click'); |
| 233 | +} |
| 234 | +function deleteTask(i){ |
| 235 | + tasks.splice(i,1); |
| 236 | + if(currentTaskIdx===i)currentTaskIdx=-1; |
| 237 | + else if(currentTaskIdx>i)currentTaskIdx--; |
| 238 | + saveData();renderTasks(); |
| 239 | +} |
| 240 | +function selectTask(i){ |
| 241 | + currentTaskIdx=i;renderTasks(); |
| 242 | + document.querySelectorAll('.task-item').forEach((el,idx)=>el.style.background=idx===i?'rgba(255,255,255,0.75)':''); |
| 243 | +} |
| 244 | +function renderTasks(){ |
| 245 | + const c=document.getElementById('taskContainer'); |
| 246 | + if(!tasks.length){c.innerHTML='<div class="empty">暂无任务,输入任务开始专注 🎯</div>';return} |
| 247 | + c.innerHTML=tasks.map((t,i)=>` |
| 248 | + <div class="task-item" onclick="selectTask(${i})" style="${i===currentTaskIdx?'background:rgba(255,255,255,0.75)':''}"> |
| 249 | + <div class="dot${t.done?' done':''}" onclick="event.stopPropagation();toggleTask(${i})"></div> |
| 250 | + <div class="name" style="${t.done?'text-decoration:line-through;opacity:.5':''}">${esc(t.name)}</div> |
| 251 | + <div class="count">🍅${t.pomos||0}</div> |
| 252 | + <button class="del" onclick="event.stopPropagation();deleteTask(${i})">×</button> |
| 253 | + </div> |
| 254 | + `).join(''); |
| 255 | +} |
| 256 | +function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML} |
| 257 | +function updateStats(){ |
| 258 | + const totalPomos=tasks.reduce((s,t)=>s+(t.pomos||0),0); |
| 259 | + const doneTasks=tasks.filter(t=>t.done).length; |
| 260 | + document.getElementById('statPomos').textContent=totalPomos; |
| 261 | + document.getElementById('statMins').textContent=totalPomos*25; |
| 262 | + document.getElementById('statTasks').textContent=doneTasks; |
| 263 | +} |
| 264 | + |
| 265 | +// --- Init --- |
| 266 | +(function init(){ |
| 267 | + loadData();renderTasks();updateStats();renderTimer(); |
| 268 | + if('Notification' in window && Notification.permission==='default')Notification.requestPermission(); |
| 269 | +})(); |
| 270 | +</script> |
| 271 | +</body> |
| 272 | +</html> |
0 commit comments