番茄时钟

经验创意 · 17 次浏览
困困君 创建于 6小时51分钟前

<!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>


回复内容
暂无回复
回复主贴