科技感网页计时器.html

发布于:2025-08-31 ⋅ 阅读:(24) ⋅ 点赞:(0)

一、科技感计网页时器应用介绍

        这是一个功能丰富、界面精美的网页计时器应用,集成了秒表和倒计时两大核心功能,具有以下特点:

1. 界面特色

        现代科技感设计 :采用深色/浅色双主题自适应,搭配优雅的蓝色渐变和发光效果
        响应式布局 :完美适配从手机到桌面的各种设备尺寸
        环形进度指示 :直观展示计时进度,增强视觉体验
        精致UI元素 :包含精心设计的按钮、面板、标签和数字显示

2. 秒表功能

        高精度计时 :精确到厘秒(0.01秒)的计时显示
        圈数记录 :支持记录和显示多个计时点,适合运动训练
        会话保持 :可选择在页面刷新后保留计时状态
        声音反馈 :可开启按键音效增强使用体验
        震动反馈 :在支持的设备上提供触觉反馈

3. 倒计时功能

        自定义时间 :支持手动输入分钟和秒数
        快捷预设 :内置多个常用时长预设(1分钟、5分钟、25分钟等)
        一键调整 :可快速增减1分钟
        结束提醒 :倒计时结束时提供声音和/或震动提醒
        会话保持 :同样支持页面刷新后恢复倒计时状态

4. 实用特性

        键盘快捷键 :支持空格键(开始/暂停)、L键(记录圈数)、R键(重置)等快捷操作
        模式切换 :可通过标签或数字键1/2快速切换秒表和倒计时模式
        后台计时 :即使切换到其他标签页,计时器仍保持精确计时
        无外部依赖 :所有功能(包括音效)均通过原生JavaScript实现,无需外部库
        本地存储 :使用localStorage保存用户偏好设置和计时状态


二、具体代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1" />
<title>科技感计时器 · 秒表 & 倒计时</title>
<meta name="color-scheme" content="dark light">
<style>
  :root{
    --bg: #0b0f1a;
    --panel: rgba(255,255,255,0.06);
    --panel-border: rgba(255,255,255,0.12);
    --text: #e8f0ff;
    --muted: #9fb3ff;
    --accent: #7aa2ff;
    --accent-2: #4df3ff;
    --good: #4ae3a0;
    --warn: #ffcf51;
    --bad:  #ff6b7a;
    --glow: 0 0 16px rgba(122,162,255,.55), 0 0 32px rgba(77,243,255,.35);
    --radius: 20px;
  }
  @media (prefers-color-scheme: light){
    :root{
      --bg: #f7fafc;
      --panel: rgba(10,20,60,0.06);
      --panel-border: rgba(10,20,60,0.1);
      --text: #142237;
      --muted: #3b5bcc;
      --accent: #385aff;
      --accent-2: #0abdc6;
      --glow: 0 0 16px rgba(56,90,255,.25), 0 0 32px rgba(10,189,198,.2);
    }
  }

  *{box-sizing:border-box}
  html,body{height:100%}
  body{
    margin:0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI",
    Roboto, "PingFang SC","Hiragino Sans GB","Microsoft YaHei", Arial, "Noto Sans", sans-serif;
    background:
      radial-gradient(1200px 800px at 10% -20%, rgba(122,162,255,.20), transparent 60%),
      radial-gradient(1200px 800px at 110% 0%, rgba(77,243,255,.15), transparent 60%),
      linear-gradient(180deg, var(--bg), #060913 70%);
    color: var(--text);
    display:grid; place-items:center; padding:24px;
  }

  .app{
    width:min(980px,100%); display:grid; gap:20px;
  }

  .brand{
    display:flex; align-items:center; gap:12px; user-select:none;
    letter-spacing: .5px;
  }
  .logo{
    width:40px; height:40px; border-radius:12px; position:relative; isolation:isolate;
    background: linear-gradient(135deg, var(--accent), var(--accent-2));
    box-shadow: var(--glow);
  }
  .logo::after{
    content:""; position:absolute; inset:3px; border-radius:10px;
    background: radial-gradient(120% 120% at 20% 20%, rgba(255,255,255,.55), rgba(255,255,255,.05) 45%, transparent 60%);
    mix-blend-mode: screen; pointer-events:none;
  }
  .brand h1{ font-size:20px; margin:0; font-weight:700 }
  .brand .sub{ opacity:.7; font-size:12px }

  .panel{
    background: var(--panel);
    border: 1px solid var(--panel-border);
    border-radius: var(--radius);
    backdrop-filter: blur(10px) saturate(140%);
    -webkit-backdrop-filter: blur(10px) saturate(140%);
    box-shadow: 0 10px 30px rgba(0,0,0,.25);
  }

  /* 确保 hidden 属性生效 */
  [hidden] { display:none !important; }

  .tabs{ display:flex; gap:8px; padding:8px; }
  .tab{
    flex:1; text-align:center; padding:10px 14px; cursor:pointer; user-select:none;
    border-radius:14px; border:1px solid transparent; transition:.2s ease all;
    background: linear-gradient(180deg, transparent, rgba(255,255,255,.04));
  }
  .tab[aria-selected="true"]{
    border-color: rgba(122,162,255,.5);
    box-shadow: var(--glow);
    background:
      linear-gradient(180deg, rgba(122,162,255,.12), rgba(77,243,255,.10));
  }

  .timer{
    display:grid; grid-template-columns: 1.1fr .9fr; gap:20px; padding:20px;
  }
  @media (max-width: 840px){ .timer{ grid-template-columns: 1fr; } }

  .display{
    min-height: 280px; display:grid; place-items:center; position:relative; overflow:hidden;
    border-radius: var(--radius);
  }

  /* digital time */
  .digits{
    font-variant-numeric: tabular-nums;
    font-size: clamp(36px, 8vw, 72px);
    line-height:1;
    letter-spacing: 2px;
    text-shadow: 0 2px 16px rgba(0,0,0,.25);
  }
  .subdigits{
    opacity:.8; margin-top:8px; font-size: clamp(12px, 2.6vw, 16px);
  }

  /* circular progress container */
  .ring{
    position:absolute; inset:0; display:grid; place-items:center; pointer-events:none;
  }
  .ring .circle{
    width:min(440px, 80vw); aspect-ratio:1/1; border-radius:50%;
    background:
      radial-gradient(60% 60% at 50% 50%, rgba(255,255,255,.08), transparent 60%),
      conic-gradient(var(--accent) var(--p,0%), rgba(255,255,255,.08) 0);
    mask: radial-gradient(circle at 50% 50%, transparent 63%, black 64%);
    box-shadow: var(--glow);
  }

  .controls{
    display:grid; gap:12px; padding:16px; align-content:start;
  }

  .row{ display:flex; flex-wrap:wrap; gap:10px }

  button, .chip, .toggle{
    appearance:none; border:1px solid var(--panel-border);
    background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02));
    color: var(--text);
    padding:10px 14px; border-radius:14px; cursor:pointer; transition:.15s ease all;
  }
  button:hover{ transform: translateY(-1px); box-shadow: 0 6px 20px rgba(0,0,0,.25), var(--glow) }
  button:active{ transform: translateY(0) scale(.99) }
  .primary{ border-color: color-mix(in oklab, var(--accent) 60%, transparent); }
  .good{ border-color: color-mix(in oklab, var(--good) 60%, transparent); }
  .warn{ border-color: color-mix(in oklab, var(--warn) 60%, transparent); }
  .bad{  border-color: color-mix(in oklab, var(--bad)  60%, transparent);  }

  .chip{ user-select:none }
  .chip[aria-pressed="true"]{
    outline: 1px solid var(--accent);
    box-shadow: var(--glow);
  }

  .grid{
    display:grid; grid-template-columns: repeat(3, 1fr); gap:10px;
  }
  @media (max-width: 520px){ .grid{ grid-template-columns: repeat(2, 1fr);} }

  .laps{
    padding:16px; max-height:260px; overflow:auto; border-top:1px dashed var(--panel-border);
    scrollbar-width: thin;
  }
  .laps .item{
    display:flex; justify-content:space-between; padding:8px 0; font-variant-numeric:tabular-nums;
    border-bottom:1px dashed rgba(255,255,255,.06);
  }
  .laps .item:last-child{ border-bottom:none }
  .hint{ opacity:.7; font-size:12px; padding-inline:16px; }

  .footer{
    display:flex; justify-content:space-between; align-items:center; padding:10px 16px;
    border-top:1px solid var(--panel-border); border-radius:0 0 var(--radius) var(--radius);
    font-size:12px; opacity:.8;
  }
  .kbd{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
        padding:2px 6px; border-radius:6px; border:1px solid var(--panel-border); opacity:.9 }

  .sr-only{
    position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
  }

  /* input for countdown */
  .time-inputs{
    display:flex; gap:8px; align-items:center; justify-content:center; margin-top:8px;
  }
  .time-inputs input{
    width:80px; padding:10px 12px; border-radius:12px;
    background:rgba(255,255,255,.04); border:1px solid var(--panel-border); color:var(--text);
    text-align:center; font-variant-numeric:tabular-nums;
  }
  .time-inputs label{ font-size:12px; opacity:.8 }
</style>
</head>
<body>
  <div class="app">
    <div class="brand">
      <div class="logo" aria-hidden="true"></div>
      <div>
        <h1>计时器 · Timer</h1>
        <div class="sub">秒表 & 倒计时 · 科技感 · 响应式 · 键盘快捷键</div>
      </div>
    </div>

    <!-- Tabs -->
    <div class="panel tabs" role="tablist" aria-label="Mode">
      <button id="tabStopwatch" class="tab" role="tab" aria-selected="true" aria-controls="panelStopwatch">⏱ 秒表</button>
      <button id="tabCountdown" class="tab" role="tab" aria-selected="false" aria-controls="panelCountdown">⏳ 倒计时</button>
    </div>

    <!-- Stopwatch -->
    <section id="panelStopwatch" class="panel timer" role="tabpanel" aria-labelledby="tabStopwatch">
      <div class="display">
        <div class="ring"><div class="circle" id="swRing"></div></div>
        <div style="text-align:center">
          <div class="digits" id="swDigits">00:00.00</div>
          <div class="subdigits" id="swSub">累计 00:00.00</div>
        </div>
      </div>
      <div class="controls">
        <div class="row">
          <button class="primary" id="swStart">▶ 开始</button>
          <button id="swLap" class="good" disabled>⏺ 记录圈 (L)</button>
          <button id="swReset" class="bad" disabled>↻ 重置</button>
        </div>
        <div class="row">
          <span class="chip" aria-pressed="true" id="persistToggle">🧠 会话保持</span>
          <span class="chip" aria-pressed="true" id="soundToggle">🔔 结束提示</span>
          <span class="chip" aria-pressed="false" id="vibrateToggle">📳 震动</span>
        </div>
        <div class="hint">快捷键:<span class="kbd">Space</span> 开始/暂停 · <span class="kbd">L</span> 记录圈 · <span class="kbd">R</span> 重置 · <span class="kbd">1</span>/<span class="kbd">2</span> 切换模式</div>
        <div class="laps" id="laps" aria-live="polite" aria-label="Lap list"></div>
      </div>
    </section>

    <!-- Countdown -->
    <section id="panelCountdown" class="panel timer" role="tabpanel" aria-labelledby="tabCountdown" hidden>
      <div class="display">
        <div class="ring"><div class="circle" id="cdRing" style="--p:0%"></div></div>
        <div style="text-align:center">
          <div class="digits" id="cdDigits">00:00</div>
          <div class="subdigits" id="cdSub">准备就绪</div>
          <div class="time-inputs" aria-label="设定时间">
            <label for="minInput">分</label>
            <input id="minInput" type="number" min="0" max="999" value="0" inputmode="numeric">
            <label for="secInput">秒</label>
            <input id="secInput" type="number" min="0" max="59" value="30" inputmode="numeric">
          </div>
        </div>
      </div>
      <div class="controls">
        <div class="row">
          <button class="primary" id="cdStart">▶ 开始</button>
          <button id="cdAdd" class="good">+1 分</button>
          <button id="cdSubMin" class="warn">-1 分</button>
          <button id="cdReset" class="bad" disabled>↻ 重置</button>
        </div>
        <div class="grid">
          <button class="chip" data-preset="60">🍵 1 分钟</button>
          <button class="chip" data-preset="300">☕ 5 分钟</button>
          <button class="chip" data-preset="600">🧘 10 分钟</button>
          <button class="chip" data-preset="1500">🍅 25 分钟</button>
          <button class="chip" data-preset="1800">📚 30 分钟</button>
          <button class="chip" data-preset="3600">⏲️ 60 分钟</button>
        </div>
        <div class="row">
          <span class="chip" aria-pressed="true" id="cdSoundToggle">🔔 结束提示</span>
          <span class="chip" aria-pressed="false" id="cdVibrateToggle">📳 震动</span>
          <span class="chip" aria-pressed="true" id="cdPersistToggle">🧠 会话保持</span>
        </div>
        <div class="hint">快捷键:<span class="kbd">Space</span> 开始/暂停 · <span class="kbd">↑/↓</span> ±1 分 · <span class="kbd">R</span> 重置 · <span class="kbd">1</span>/<span class="kbd">2</span> 切换模式</div>
      </div>
    </section>

    <div class="panel footer">
      <div>💡 提示:切换到其它标签页也会保持计时精度(基于高精度时间戳计算)。</div>
      <div>© <span id="year"></span> Timer UI</div>
    </div>
  </div>

  <!-- 简单的提示音(WebAudio 动态合成,无需外链音频) -->
  <audio id="beep" class="sr-only"></audio>

<script>
(function(){
  "use strict";

  // ========= 工具函数 =========
  const $ = sel => document.querySelector(sel);
  const $$ = sel => Array.from(document.querySelectorAll(sel));
  const fmt2 = n => n.toString().padStart(2,'0');
  const storage = {
    get(k, def){ try{ return JSON.parse(localStorage.getItem(k)) ?? def }catch{ return def } },
    set(k, v){ localStorage.setItem(k, JSON.stringify(v)); }
  };
  const now = () => performance.now();

  // ========= 年份 =========
  $('#year').textContent = new Date().getFullYear();

  // ========= 标签切换 =========
  const tabStopwatch = $('#tabStopwatch');
  const tabCountdown = $('#tabCountdown');
  const panelStopwatch = $('#panelStopwatch');
  const panelCountdown = $('#panelCountdown');

  function selectTab(which){
    const isSW = which === 'sw';
    tabStopwatch.setAttribute('aria-selected', String(isSW));
    tabCountdown.setAttribute('aria-selected', String(!isSW));
    panelStopwatch.hidden = !isSW;
    panelCountdown.hidden = isSW;
    // 记忆上次模式
    storage.set('timer:lastTab', which);
  }

  // 恢复上次模式
  selectTab(storage.get('timer:lastTab','sw'));
  tabStopwatch.addEventListener('click', () => selectTab('sw'));
  tabCountdown.addEventListener('click', () => selectTab('cd'));

  // ========= 提示音(动态合成一段叮铃) =========
  const audioCtx = new (window.AudioContext || window.webkitAudioContext || function(){})();
  function playBeep(){
    if(!audioCtx || audioCtx.state === 'suspended'){ try{ audioCtx.resume(); }catch{} }
    const o = audioCtx.createOscillator();
    const g = audioCtx.createGain();
    o.type = 'sine';
    const t0 = audioCtx.currentTime;
    o.frequency.setValueAtTime(880, t0);
    o.frequency.exponentialRampToValueAtTime(1760, t0 + 0.15);
    g.gain.setValueAtTime(0.001, t0);
    g.gain.exponentialRampToValueAtTime(0.3, t0 + 0.02);
    g.gain.exponentialRampToValueAtTime(0.001, t0 + 0.5);
    o.connect(g).connect(audioCtx.destination);
    o.start(); o.stop(t0 + 0.5);
  }
  function vibrate(ms=200){ if(navigator.vibrate) navigator.vibrate(ms); }

  // ========= 秒表 =========
  const swDigits = $('#swDigits');
  const swSub = $('#swSub');
  const swStart = $('#swStart');
  const swLap = $('#swLap');
  const swReset = $('#swReset');
  const swRing = $('#swRing');
  const lapsEl = $('#laps');

  let swRunning = false;
  let swStartTime = 0;    // 本轮开始时刻
  let swElapsed = 0;      // 已累计毫秒
  let swRAF = 0;
  let swLastLapAt = 0;
  let swPersist = storage.get('sw:persist', true);
  let swSoundOn = storage.get('sw:sound', true);
  let swVibrateOn = storage.get('sw:vibrate', false);

  const persistToggle = $('#persistToggle');
  const soundToggle = $('#soundToggle');
  const vibrateToggle = $('#vibrateToggle');
  function syncChip(el, on){ el.setAttribute('aria-pressed', String(on)); }
  syncChip(persistToggle, swPersist);
  syncChip(soundToggle, swSoundOn);
  syncChip(vibrateToggle, swVibrateOn);

  persistToggle.addEventListener('click', ()=>{ swPersist = !JSON.parse(persistToggle.getAttribute('aria-pressed')); syncChip(persistToggle, swPersist); storage.set('sw:persist', swPersist); });
  soundToggle.addEventListener('click', ()=>{ swSoundOn = !JSON.parse(soundToggle.getAttribute('aria-pressed')); syncChip(soundToggle, swSoundOn); storage.set('sw:sound', swSoundOn); });
  vibrateToggle.addEventListener('click', ()=>{ swVibrateOn = !JSON.parse(vibrateToggle.getAttribute('aria-pressed')); syncChip(vibrateToggle, swVibrateOn); storage.set('sw:vibrate', swVibrateOn); });

  function formatSW(ms){
    const total = Math.floor(ms);
    const mm = Math.floor(total/60000);
    const ss = Math.floor((total%60000)/1000);
    const cs = Math.floor((total%1000)/10); // 厘秒
    return `${fmt2(mm)}:${fmt2(ss)}.${fmt2(cs)}`;
  }
  function updateSWUI(){
    const elapsed = swRunning ? swElapsed + (now() - swStartTime) : swElapsed;
    swDigits.textContent = formatSW(elapsed);
    swSub.textContent = `累计 ${formatSW(elapsed)}`;
    // 秒表环按 3 分钟一圈示例
    const p = Math.min(100, (elapsed % (3*60*1000)) / (3*60*10));
    swRing.style.setProperty('--p', p + '%');
  }
  function swTick(){
    updateSWUI();
    swRAF = requestAnimationFrame(swTick);
  }
  function swStartPause(){
    if(!swRunning){
      swRunning = true;
      swStartTime = now();
      swLastLapAt = swLastLapAt || swElapsed;
      swStart.textContent = '⏸ 暂停';
      swLap.disabled = false;
      swReset.disabled = false;
      swRAF = requestAnimationFrame(swTick);
    }else{
      swRunning = false;
      swElapsed += now() - swStartTime;
      swStart.textContent = '▶ 继续';
      cancelAnimationFrame(swRAF);
      updateSWUI();
    }
    if(swPersist) storage.set('sw:state', {swRunning, swStartTime: swStartTime ? Date.now() - (now() - swStartTime) : 0, swElapsed, laps: readLaps()});
  }
  function swDoReset(){
    swRunning = false;
    swElapsed = 0;
    swStartTime = 0;
    swStart.textContent = '▶ 开始';
    swLap.disabled = true;
    swReset.disabled = true;
    cancelAnimationFrame(swRAF);
    lapsEl.innerHTML = '';
    swLastLapAt = 0;
    updateSWUI();
    if(swPersist) storage.set('sw:state', {swRunning, swStartTime:0, swElapsed:0, laps:[]});
  }
  function addLap(){
    const t = swRunning ? swElapsed + (now() - swStartTime) : swElapsed;
    const lapDur = t - swLastLapAt;
    swLastLapAt = t;
    const item = document.createElement('div');
    item.className = 'item';
    const idx = lapsEl.children.length + 1;
    item.innerHTML = `<span>#${idx}</span><span>${formatSW(lapDur)}</span><span>${formatSW(t)}</span>`;
    lapsEl.prepend(item);
    if(swPersist) storage.set('sw:state', {swRunning, swStartTime: swStartTime ? Date.now() - (now() - swStartTime) : 0, swElapsed, laps: readLaps()});
    // 节奏反馈
    if(swSoundOn) playBeep();
    if(swVibrateOn) vibrate(30);
  }
  function readLaps(){
    return Array.from(lapsEl.children).reverse().map(node=>{
      const spans = node.querySelectorAll('span');
      return { idx: spans[0].textContent, lap: spans[1].textContent, total: spans[2].textContent };
    });
  }
  function restoreSW(){
    const s = storage.get('sw:state', null);
    if(!s) return;
    swElapsed = s.swElapsed || 0;
    lapsEl.innerHTML = '';
    (s.laps || []).forEach(rec=>{
      const item = document.createElement('div'); item.className='item';
      item.innerHTML = `<span>${rec.idx}</span><span>${rec.lap}</span><span>${rec.total}</span>`;
      lapsEl.appendChild(item);
      const [m,sec,cs] = rec.total.split(/[:.]/).map(Number);
      swLastLapAt = Math.max(swLastLapAt, (m*60+sec)*1000 + cs*10);
    });
    if(s.swRunning){
      // 依据保存时的 wall clock 恢复
      const startedAtWall = s.swStartTime || 0;
      if(startedAtWall){
        const delta = Date.now() - startedAtWall;
        swStartTime = now() - delta;
        swRunning = true;
        swStart.textContent = '⏸ 暂停';
        swLap.disabled = false; swReset.disabled = false;
        swRAF = requestAnimationFrame(swTick);
      }
    }else{
      updateSWUI();
      if(swElapsed>0){ swReset.disabled = false; }
    }
  }
  restoreSW();

  swStart.addEventListener('click', swStartPause);
  swReset.addEventListener('click', swDoReset);
  swLap.addEventListener('click', ()=>{ if(!swRunning) return; addLap(); });

  // ========= 倒计时 =========
  const cdDigits = $('#cdDigits');
  const cdSub    = $('#cdSub');
  const cdRing   = $('#cdRing');
  const minInput = $('#minInput');
  const secInput = $('#secInput');
  const cdStart  = $('#cdStart');
  const cdAdd    = $('#cdAdd');
  const cdSubMin = $('#cdSubMin');
  const cdReset  = $('#cdReset');
  let cdTotal = 30_000; // ms
  let cdRemain = cdTotal;
  let cdRunning = false;
  let cdStartWall = 0;
  let cdRAF = 0;

  let cdPersist = storage.get('cd:persist', true);
  let cdSoundOn = storage.get('cd:sound', true);
  let cdVibrateOn = storage.get('cd:vibrate', false);

  const cdPersistToggle = $('#cdPersistToggle');
  const cdSoundToggle = $('#cdSoundToggle');
  const cdVibrateToggle = $('#cdVibrateToggle');
  syncChip(cdPersistToggle, cdPersist);
  syncChip(cdSoundToggle, cdSoundOn);
  syncChip(cdVibrateToggle, cdVibrateOn);

  cdPersistToggle.addEventListener('click', ()=>{ cdPersist = !JSON.parse(cdPersistToggle.getAttribute('aria-pressed')); syncChip(cdPersistToggle, cdPersist); storage.set('cd:persist', cdPersist); });
  cdSoundToggle.addEventListener('click', ()=>{ cdSoundOn = !JSON.parse(cdSoundToggle.getAttribute('aria-pressed')); syncChip(cdSoundToggle, cdSoundOn); storage.set('cd:sound', cdSoundOn); });
  cdVibrateToggle.addEventListener('click', ()=>{ cdVibrateOn = !JSON.parse(cdVibrateToggle.getAttribute('aria-pressed')); syncChip(cdVibrateToggle, cdVibrateOn); storage.set('cd:vibrate', cdVibrateOn); });

  function setFromInputs(){
    const m = Math.max(0, Math.min(999, parseInt(minInput.value||'0',10)));
    const s = Math.max(0, Math.min(59, parseInt(secInput.value||'0',10)));
    cdTotal = (m*60 + s) * 1000;
    cdRemain = cdTotal;
    updateCDUI();
  }
  minInput.addEventListener('change', setFromInputs);
  secInput.addEventListener('change', setFromInputs);

  function formatCD(ms){
    ms = Math.max(0, Math.floor(ms));
    const mm = Math.floor(ms/60000);
    const ss = Math.floor((ms%60000)/1000);
    return `${fmt2(mm)}:${fmt2(ss)}`;
  }
  function updateCDUI(){
    cdDigits.textContent = formatCD(cdRemain);
    cdSub.textContent = cdRunning ? '计时中…' : (cdRemain===0 ? '已结束' : '准备就绪');
    const p = cdTotal === 0 ? 0 : (100 - (cdRemain / cdTotal) * 100);
    cdRing.style.setProperty('--p', `${p}%`);
    cdReset.disabled = cdRemain === cdTotal && !cdRunning;
  }
  function cdTick(){
    const elapsed = Date.now() - cdStartWall;
    cdRemain = Math.max(0, cdTotal - elapsed);
    updateCDUI();
    if(cdRemain === 0){
      cdRunning = false;
      cdStart.textContent = '▶ 重新开始';
      cancelAnimationFrame(cdRAF);
      if(cdSoundOn) playBeep();
      if(cdVibrateOn) vibrate(400);
      return;
    }
    cdRAF = requestAnimationFrame(cdTick);
  }
  function cdStartPause(){
    if(cdTotal === 0 && !cdRunning){ return; }
    if(!cdRunning){
      // 如果是暂停后继续,cdTotal 应改为 remain
      if(cdRemain !== cdTotal){ cdTotal = cdRemain; }
      cdRunning = true;
      cdStartWall = Date.now();
      cdStart.textContent = '⏸ 暂停';
      cdRAF = requestAnimationFrame(cdTick);
    }else{
      // 暂停:固化剩余
      cdRunning = false;
      cdRemain = Math.max(0, cdTotal - (Date.now() - cdStartWall));
      cdStart.textContent = '▶ 继续';
      cancelAnimationFrame(cdRAF);
      updateCDUI();
    }
    if(cdPersist) storage.set('cd:state', {cdRunning, cdTotal, cdRemain, startedAt: cdStartWall});
  }
  function cdDoReset(){
    cdRunning = false; cancelAnimationFrame(cdRAF);
    cdRemain = cdTotal = Math.max(0, cdTotal); // 保持设定值
    cdStart.textContent = '▶ 开始';
    updateCDUI();
    if(cdPersist) storage.set('cd:state', {cdRunning:false, cdTotal, cdRemain:cdTotal, startedAt:0});
  }
  function setPreset(sec){
    cdRunning = false; cancelAnimationFrame(cdRAF);
    cdRemain = cdTotal = sec*1000;
    const mm = Math.floor(sec/60), ss = sec%60;
    minInput.value = mm; secInput.value = ss;
    cdStart.textContent = '▶ 开始';
    updateCDUI();
    if(cdPersist) storage.set('cd:state', {cdRunning:false, cdTotal, cdRemain:cdTotal, startedAt:0});
  }
  // 按钮事件
  cdStart.addEventListener('click', cdStartPause);
  cdReset.addEventListener('click', cdDoReset);
  cdAdd.addEventListener('click', ()=> setPreset(Math.floor(cdTotal/1000) + 60));
  cdSubMin.addEventListener('click', ()=> setPreset(Math.max(0, Math.floor(cdTotal/1000) - 60)));
  $$('#panelCountdown .grid .chip').forEach(btn=>{
    btn.addEventListener('click', ()=> setPreset(parseInt(btn.dataset.preset,10)));
  });
  // 初始同步 UI
  (function initCD(){
    const saved = storage.get('cd:state', null);
    if(saved){
      cdTotal = saved.cdTotal || 0;
      cdRemain = saved.cdRemain ?? cdTotal;
      if(saved.cdRunning){
        // 恢复运行中:根据 wall clock 计算
        const elapsed = Date.now() - (saved.startedAt || Date.now());
        cdRemain = Math.max(0, cdTotal - elapsed);
        cdRunning = cdRemain > 0;
        if(cdRunning){
          cdStart.textContent = '⏸ 暂停';
          cdStartWall = Date.now() - Math.min(cdTotal, elapsed);
          cdRAF = requestAnimationFrame(cdTick);
        }else{
          cdStart.textContent = '▶ 重新开始';
        }
      }
      minInput.value = Math.floor(cdTotal/60000);
      secInput.value = Math.floor((cdTotal%60000)/1000);
    }else{
      minInput.value = 0; secInput.value = 30;
      setFromInputs();
    }
    updateCDUI();
  })();

  // ========= 全局键盘快捷键 =========
  window.addEventListener('keydown', (e)=>{
    if (['INPUT','TEXTAREA'].includes(document.activeElement.tagName)) return;
    if(e.code === 'Space'){ e.preventDefault();
      if(!panelStopwatch.hidden) swStartPause(); else cdStartPause();
    }else if(e.key==='l' || e.key==='L'){
      if(!panelStopwatch.hidden && swRunning) addLap();
    }else if(e.key==='r' || e.key==='R'){
      if(!panelStopwatch.hidden) swDoReset(); else cdDoReset();
    }else if(e.key==='1'){ selectTab('sw'); }
    else if(e.key==='2'){ selectTab('cd'); }
    else if(e.key==='ArrowUp' && !panelCountdown.hidden){ setPreset(Math.floor(cdTotal/1000)+60); }
    else if(e.key==='ArrowDown' && !panelCountdown.hidden){ setPreset(Math.max(0, Math.floor(cdTotal/1000)-60)); }
  });

  // ========= 页面可见性处理:保持精度 =========
  document.addEventListener('visibilitychange', ()=>{
    // 秒表:切后台不做特殊处理,因基于 high-res 时间戳
    // 倒计时:切前台时强制刷新剩余
    if(!panelCountdown.hidden && cdRunning){
      cdRemain = Math.max(0, cdTotal - (Date.now() - cdStartWall));
      updateCDUI();
    }
  });
})();
</script>
</body>
</html>


网站公告

今日签到

点亮在社区的每一天
去签到