<!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 { --work-color: #ff6b4a; --work-color-glow: rgba(255,107,74,0.45); --break-color: #4ecdc4; --break-color-glow: rgba(78,205,196,0.45); --bg-deep: #1a1d30; --bg-card: #1e2138; --text-primary: #f0f0f5; --text-secondary: rgba(220,220,235,0.55); --border-subtle: rgba(140,155,190,0.18); --btn-bg: rgba(255,255,255,0.07); --btn-hover: rgba(255,255,255,0.14); --btn-active: rgba(255,255,255,0.04);}
*,*::before,*::after { box-sizing: border-box;}
html,body { margin: 0; width: 100%; height: 100%; overflow: hidden; background: transparent; font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}
.card { position: relative; width: 100%; height: 100%; box-sizing: border-box; border-radius: 18px; background: linear-gradient(145deg, #1f223a 0%, #1b1e33 40%, #1a1d30 100%); border: 1px solid var(--border-subtle); box-shadow: inset 0 1px 0 rgba(255,255,255,0.035), inset 0 0 0 1px rgba(255,255,255,0.02), 0 4px 24px rgba(0,0,0,0.35); display: flex; flex-direction: column; overflow: hidden; user-select: none; -webkit-user-select: none;}
/* 拖动标题栏 */.drag-handle { display: flex; align-items: center; justify-content: center; height: 30px; min-height: 30px; padding: 0 16px; cursor: default; flex-shrink: 0;}.drag-handle[data-yanm-drag="true"] { cursor: grab;}.drag-handle[data-yanm-drag="true"]:active { cursor: grabbing;}.drag-title { font-size: 12px; font-weight: 500; color: rgba(220,220,240,0.45); letter-spacing: 0.6px; pointer-events: none;}.drag-dots { display: flex; gap: 4px; margin-right: 8px; pointer-events: none;}.drag-dots span { width: 3px; height: 3px; border-radius: 50%; background: rgba(200,200,220,0.3);}
/* 主内容区 */.content { flex: 1; display: flex; align-items: center; padding: 6px 18px 14px 16px; gap: 18px; min-height: 0;}
/* 左侧圆环区域 */.ring-area { flex-shrink: 0; position: relative; width: clamp(120px, 38%, 170px); aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center;}.ring-svg { width: 100%; height: 100%; filter: drop-shadow(0 0 10px var(--glow-color, rgba(255,107,74,0.35))); transition: filter 0.6s ease;}.ring-svg .bg-ring { fill: none; stroke: rgba(255,255,255,0.08); stroke-width: 7;}.ring-svg .progress-ring { fill: none; stroke: var(--accent-color, #ff6b4a); stroke-width: 7; stroke-linecap: round; transition: stroke-dashoffset 0.45s ease, stroke 0.6s ease; transform: rotate(-90deg); transform-origin: 50% 50%;}.ring-svg .time-text { fill: var(--text-primary); font-size: 28px; font-weight: 700; font-family: "Microsoft YaHei", "PingFang SC", "Helvetica Neue", sans-serif; text-anchor: middle; dominant-baseline: central; letter-spacing: 1px; transition: fill 0.4s ease;}
/* 周期结束脉冲动画 */.ring-area.pulse .ring-svg { animation: ringPulse 0.55s ease-in-out 3;}@keyframes ringPulse { 0%, 100% { filter: drop-shadow(0 0 10px var(--glow-color, rgba(255,107,74,0.35))); } 50% { filter: drop-shadow(0 0 22px var(--glow-color, rgba(255,107,74,0.75))) brightness(1.25); }}
/* 右侧信息区 */.info-area { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 10px; min-width: 0;}.mode-badge { display: inline-flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 600; color: var(--accent-color, #ff6b4a); letter-spacing: 0.4px; transition: color 0.6s ease;}.mode-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent-color, #ff6b4a); transition: background 0.6s ease; box-shadow: 0 0 6px var(--glow-color, rgba(255,107,74,0.5));}.stats-row { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-secondary); letter-spacing: 0.3px;}.stats-count { font-weight: 700; font-size: 18px; color: var(--text-primary); line-height: 1;}.button-group { display: flex; gap: 10px; align-items: center; flex-wrap: wrap;}.btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 9px 18px; border-radius: 22px; border: 1px solid rgba(255,255,255,0.12); background: var(--btn-bg); color: var(--text-primary); font-size: 13px; font-weight: 500; font-family: inherit; cursor: pointer; letter-spacing: 0.3px; transition: background 0.18s ease, border-color 0.18s ease, transform 0.12s ease, box-shadow 0.18s ease; outline: none; white-space: nowrap; -webkit-tap-highlight-color: transparent;}.btn:hover { background: var(--btn-hover); border-color: rgba(255,255,255,0.22);}.btn:active { background: var(--btn-active); transform: scale(0.96); border-color: rgba(255,255,255,0.1);}.btn-primary { background: var(--accent-color, #ff6b4a); border-color: transparent; color: #fff; font-weight: 600; box-shadow: 0 2px 10px var(--glow-color, rgba(255,107,74,0.3)); transition: background 0.6s ease, border-color 0.6s ease, box-shadow 0.6s ease, transform 0.12s ease;}.btn-primary:hover { background: var(--accent-color, #ff6b4a); filter: brightness(1.12); border-color: transparent; box-shadow: 0 4px 16px var(--glow-color, rgba(255,107,74,0.45));}.btn-primary:active { filter: brightness(0.92); transform: scale(0.96);}.btn-sm { padding: 7px 13px; font-size: 11px; border-radius: 16px; letter-spacing: 0.2px;}
/* 缩放手柄 */.resize-handle { position: absolute; bottom: 4px; right: 4px; width: 18px; height: 18px; opacity: 0.35; pointer-events: auto; cursor: nwse-resize; display: flex; align-items: flex-end; justify-content: flex-end; padding: 0 2px 2px 0;}.resize-handle::after { content: ''; width: 10px; height: 10px; border-right: 2px solid rgba(200,210,230,0.5); border-bottom: 2px solid rgba(200,210,230,0.5); border-radius: 0 0 3px 0;}
/* 自定义滚动条(内部容器如需滚动) */.info-area::-webkit-scrollbar { width: 4px;}.info-area::-webkit-scrollbar-track { background: transparent; border-radius: 8px;}.info-area::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 8px;}.info-area::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22);}
/* 响应式:高度较小时缩小间距 */@media (max-height: 200px) { .content { padding: 2px 12px 6px 10px; gap: 10px; } .ring-area { width: clamp(80px, 28%, 110px); } .ring-svg .time-text { font-size: 20px; } .ring-svg .bg-ring, .ring-svg .progress-ring { stroke-width: 5; } .button-group { gap: 6px; } .btn { padding: 6px 12px; font-size: 11px; } .mode-badge { font-size: 12px; } .info-area { gap: 5px; }}@media (max-width: 280px) { .content { flex-direction: column; padding: 8px 12px; gap: 8px; } .ring-area { width: clamp(70px, 50%, 100px); } .info-area { align-items: center; text-align: center; } .button-group { justify-content: center; }}</style></head><body><div class="card" id="card"> <!-- 拖动标题栏 --> <div class="drag-handle" data-yanm-drag="true"> <div class="drag-dots"><span></span><span></span><span></span></div> <span class="drag-title">番茄时钟</span> </div>
<!-- 主内容 --> <div class="content"> <!-- 圆形进度环 --> <div class="ring-area" id="ringArea"> <svg class="ring-svg" viewBox="0 0 160 160" xmlns="http://www.w3.org/2000/svg"> <circle class="bg-ring" cx="80" cy="80" r="68"/> <circle class="progress-ring" id="progressRing" cx="80" cy="80" r="68" stroke-dasharray="427.2566" stroke-dashoffset="0"/> <text class="time-text" id="timeText" x="80" y="80">25:00</text> </svg> </div>
<!-- 信息与控制 --> <div class="info-area" id="infoArea"> <div class="mode-badge" id="modeBadge"> <span class="dot"></span> <span id="modeLabel">专注工作</span> </div> <div class="stats-row"> <span>已完成</span> <span class="stats-count" id="tomatoCount">0</span> <span>个番茄</span> </div> <div class="button-group"> <button class="btn btn-primary" id="btnToggle" title="开始/暂停">▶ 开始</button> <button class="btn btn-sm" id="btnReset" title="重置当前周期">↺ 重置</button> <button class="btn btn-sm" id="btnSkip" title="跳过当前周期">⏭ 跳过</button> </div> </div> </div>
<!-- 缩放手柄 --> <div class="resize-handle" data-yanm-resize="true" title="缩放"></div></div>
<script>(function() { 'use strict';
// ============ 常量 ============ const WORK_DURATION = 25 * 60; // 25分钟 const BREAK_DURATION = 5 * 60; // 5分钟 const CIRCUMFERENCE = 2 * Math.PI * 68; // ≈427.2566 const STORAGE_KEY = 'yanm_pomodoro_state'; const STATE_KEY = 'pomodoro';
// ============ DOM 引用 ============ const card = document.getElementById('card'); const ringArea = document.getElementById('ringArea'); const progressRing = document.getElementById('progressRing'); const timeText = document.getElementById('timeText'); const modeBadge = document.getElementById('modeBadge'); const modeLabel = document.getElementById('modeLabel'); const tomatoCount = document.getElementById('tomatoCount'); const btnToggle = document.getElementById('btnToggle'); const btnReset = document.getElementById('btnReset'); const btnSkip = document.getElementById('btnSkip');
// ============ 状态 ============ let state = { mode: 'work', // 'work' | 'break' remainingSeconds: WORK_DURATION, isRunning: false, startedAt: null, // 开始时间戳(ms),暂停或停止时为null workDuration: WORK_DURATION, breakDuration: BREAK_DURATION, completedTomatoes: 0 };
let timerInterval = null; let yanmReady = false; let yanmRetryCount = 0; const YANM_MAX_RETRIES = 20;
// ============ 辅助函数 ============ function getTotalSeconds() { return state.mode === 'work' ? state.workDuration : state.breakDuration; }
function getAccentColor() { return state.mode === 'work' ? '#ff6b4a' : '#4ecdc4'; }
function getGlowColor() { return state.mode === 'work' ? 'rgba(255,107,74,0.4)' : 'rgba(78,205,196,0.4)'; }
function formatTime(seconds) { const s = Math.max(0, Math.floor(seconds)); const m = Math.floor(s / 60); const sec = s % 60; return String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0'); }
// ============ 界面更新 ============ function updateUI() { const total = getTotalSeconds(); const progress = total > 0 ? state.remainingSeconds / total : 1; const offset = CIRCUMFERENCE * (1 - progress);
progressRing.style.strokeDashoffset = String(offset); progressRing.style.stroke = getAccentColor(); timeText.textContent = formatTime(state.remainingSeconds);
// 更新模式标签 if (state.mode === 'work') { modeLabel.textContent = '专注工作'; } else { modeLabel.textContent = '休息一下'; } modeBadge.style.color = getAccentColor(); const dot = modeBadge.querySelector('.dot'); if (dot) { dot.style.background = getAccentColor(); dot.style.boxShadow = '0 0 6px ' + getGlowColor(); }
// 更新按钮 if (state.isRunning) { btnToggle.innerHTML = '⏸ 暂停'; } else { btnToggle.innerHTML = state.remainingSeconds < getTotalSeconds() ? '▶ 继续' : '▶ 开始'; } btnToggle.style.background = getAccentColor(); btnToggle.style.boxShadow = '0 2px 10px ' + getGlowColor();
// 番茄计数 tomatoCount.textContent = String(state.completedTomatoes);
// 更新圆环光晕 ringArea.style.setProperty('--glow-color', getGlowColor()); card.style.setProperty('--accent-color', getAccentColor()); card.style.setProperty('--glow-color', getGlowColor());
// SVG滤镜颜色 const ringSvg = ringArea.querySelector('.ring-svg'); if (ringSvg) { ringSvg.style.setProperty('--glow-color', getGlowColor()); } }
// ============ 周期结束处理 ============ function onCycleComplete() { // 脉冲动画 ringArea.classList.add('pulse'); setTimeout(function() { ringArea.classList.remove('pulse'); }, 1800);
if (state.mode === 'work') { // 完成一个番茄 state.completedTomatoes += 1; state.mode = 'break'; state.remainingSeconds = state.breakDuration; } else { // 休息结束 state.mode = 'work'; state.remainingSeconds = state.workDuration; } state.startedAt = state.isRunning ? Date.now() : null; updateUI(); saveState();
// 如果仍在运行,继续计时 if (state.isRunning) { startTimerInterval(); } }
// ============ 计时器 ============ function startTimerInterval() { stopTimerInterval(); timerInterval = setInterval(function() { if (!state.isRunning) return;
if (state.remainingSeconds <= 0) { // 周期结束 stopTimerInterval(); onCycleComplete(); return; }
state.remainingSeconds -= 1; updateUI();
// 每秒不保存到持久存储(性能考虑),仅在关键操作时保存 // 但更新内存状态 if (state.remainingSeconds <= 0) { stopTimerInterval(); onCycleComplete(); } }, 1000); }
function stopTimerInterval() { if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } }
// ============ 状态持久化 ============ function saveStateToLocal() { try { var data = { mode: state.mode, remainingSeconds: state.remainingSeconds, isRunning: state.isRunning, startedAt: state.startedAt, workDuration: state.workDuration, breakDuration: state.breakDuration, completedTomatoes: state.completedTomatoes }; localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); } catch (e) { // localStorage不可用 } }
function saveStateToHost() { if (!yanmReady || !window.yanm || typeof window.yanm.invoke !== 'function') return; try { var payload = JSON.stringify({ mode: state.mode, remainingSeconds: state.remainingSeconds, isRunning: state.isRunning, startedAt: state.startedAt, workDuration: state.workDuration, breakDuration: state.breakDuration, completedTomatoes: state.completedTomatoes }); window.yanm.invoke('state.set', { key: STATE_KEY, value: payload }).catch(function() { // 宿主保存失败,静默处理 }); } catch (e) { // 忽略 } }
function saveState() { // 1. 内存已更新 // 2. localStorage兜底 saveStateToLocal(); // 3. 宿主保存 saveStateToHost(); }
function loadStateFromLocal() { try { var raw = localStorage.getItem(STORAGE_KEY); if (raw) { return JSON.parse(raw); } } catch (e) { // ignore } return null; }
function applyState(loadedState) { if (!loadedState || typeof loadedState !== 'object') return; state.mode = loadedState.mode === 'break' ? 'break' : 'work'; state.remainingSeconds = typeof loadedState.remainingSeconds === 'number' ? loadedState.remainingSeconds : WORK_DURATION; state.isRunning = !!loadedState.isRunning; state.startedAt = typeof loadedState.startedAt === 'number' ? loadedState.startedAt : null; state.workDuration = typeof loadedState.workDuration === 'number' ? loadedState.workDuration : WORK_DURATION; state.breakDuration = typeof loadedState.breakDuration === 'number' ? loadedState.breakDuration : BREAK_DURATION; state.completedTomatoes = typeof loadedState.completedTomatoes === 'number' ? loadedState.completedTomatoes : 0;
// 如果之前正在运行,根据时间戳计算实际剩余时间 if (state.isRunning && state.startedAt) { var elapsed = Math.floor((Date.now() - state.startedAt) / 1000); var actualRemaining = state.remainingSeconds - elapsed; if (actualRemaining <= 0) { // 周期已过期,切换到下一模式 state.remainingSeconds = 0; // 不在这里直接调用onCycleComplete,先完成初始化 state._needsCycleComplete = true; } else { state.remainingSeconds = actualRemaining; state._needsCycleComplete = false; } state.startedAt = Date.now(); } else { state.isRunning = false; state.startedAt = null; state._needsCycleComplete = false; } }
// ============ 操作 ============ function toggleTimer() { if (state.isRunning) { // 暂停 state.isRunning = false; state.startedAt = null; stopTimerInterval(); } else { // 开始/继续 state.isRunning = true; state.startedAt = Date.now(); startTimerInterval(); } updateUI(); saveState(); }
function resetTimer() { state.isRunning = false; state.startedAt = null; state.remainingSeconds = getTotalSeconds(); stopTimerInterval(); updateUI(); saveState(); }
function skipCycle() { state.isRunning = false; state.startedAt = null; stopTimerInterval(); // 直接触发周期结束 onCycleComplete(); // onCycleComplete内部会设置新状态,但如果isRunning为false,不会自动开始 state.isRunning = false; state.startedAt = null; stopTimerInterval(); updateUI(); saveState(); }
// ============ 宿主初始化 ============ function tryInitYanm() { if (window.yanm && typeof window.yanm.invoke === 'function') { yanmReady = true; // 从宿主读取状态 window.yanm.invoke('state.get', { key: STATE_KEY }).then(function(res) { if (res && typeof res === 'string') { try { var hostState = JSON.parse(res); if (hostState && typeof hostState === 'object') { applyState(hostState); // 处理周期过期 if (state._needsCycleComplete) { state._needsCycleComplete = false; if (state.mode === 'work') { state.completedTomatoes += 1; state.mode = 'break'; } else { state.mode = 'work'; } state.remainingSeconds = getTotalSeconds(); state.startedAt = state.isRunning ? Date.now() : null; } updateUI(); if (state.isRunning) { startTimerInterval(); } saveStateToLocal(); return; } } catch (e) { // 解析失败,使用本地状态 } } }).catch(function() { // 宿主读取失败,使用本地状态 }); } else { yanmRetryCount++; if (yanmRetryCount < YANM_MAX_RETRIES) { setTimeout(tryInitYanm, 400); } } }
// ============ 初始化 ============ function init() { // 1. 先从localStorage加载兜底数据 var localState = loadStateFromLocal(); if (localState) { applyState(localState); if (state._needsCycleComplete) { state._needsCycleComplete = false; if (state.mode === 'work') { state.completedTomatoes += 1; state.mode = 'break'; } else { state.mode = 'work'; } state.remainingSeconds = getTotalSeconds(); state.startedAt = null; state.isRunning = false; } } // 2. 渲染界面 updateUI(); if (state.isRunning) { startTimerInterval(); } // 3. 异步尝试从宿主读取 setTimeout(function() { tryInitYanm(); }, 200);
// 4. 绑定事件 btnToggle.addEventListener('click', function(e) { e.preventDefault(); toggleTimer(); }); btnReset.addEventListener('click', function(e) { e.preventDefault(); resetTimer(); }); btnSkip.addEventListener('click', function(e) { e.preventDefault(); skipCycle(); });
// 键盘快捷键:空格键切换开始/暂停 document.addEventListener('keydown', function(e) { if (e.code === 'Space' && e.target === document.body) { e.preventDefault(); toggleTimer(); } }); }
// ============ 页面卸载时保存 ============ window.addEventListener('beforeunload', function() { if (state.isRunning) { state.startedAt = Date.now(); } saveStateToLocal(); saveStateToHost(); stopTimerInterval(); });
// ============ 启动 ============ init();})();</script></body></html>