评论

收藏

[Html/CSS] 用Html写了一个秒表计时器

开发技术 开发技术 发布于:2026-03-01 17:47 | 阅读数:19 | 评论:0


最近迷上了玩魔方无法自拔,玩两周后发现计时工具太重要了,却苦于没有趁手的计时工具,市面上的要么广告多要么功能杂于是让DeepSeek帮我写了一个专属魔方秒表界面清爽、操作丝滑。

Snipaste_2026-03-01_17-47-55.jpg
html代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <title>魔方计时器 · 智能自动计次</title>
  <style>
    * {
      box-sizing: border-box;
      user-select: none;
      -webkit-tap-highlight-color: transparent;
      margin: 0;
      padding: 0;
    }
    
    html, body {
      width: 100%;
      min-height: 100vh;
      height: auto;
      overflow-x: hidden;
      overflow-y: auto;
    }
    
    body {
      background: linear-gradient(145deg, #0b1a2e 0%, #1a2f3f 100%);
      display: flex;
      align-items: flex-start;
      justify-content: center;
      font-family: 'Segoe UI', Roboto, system-ui, -apple-system, sans-serif;
      padding: 16px;
      -webkit-overflow-scrolling: touch;
    }
    
    .stopwatch-card {
      background: rgba(18, 28, 40, 0.85);
      backdrop-filter: blur(12px);
      border-radius: clamp(32px, 8vw, 48px);
      padding: clamp(1.2rem, 5vw, 2.2rem) clamp(1.2rem, 5vw, 2.5rem);
      box-shadow: 0 35px 68px rgba(0, 0, 0, 0.55), inset 0 -2px 4px rgba(255, 255, 255, 0.1), inset 0 4px 8px rgba(255, 255, 255, 0.06);
      border: 1px solid rgba(120, 180, 255, 0.2);
      width: min(98vw, 900px);
      max-width: 100%;
      transition: box-shadow 0.2s;
      position: relative;
      margin: auto;
      z-index: 1;
    }
    
    .display-wrap {
      background: #0e1a24;
      border-radius: clamp(40px, 10vw, 60px);
      padding: 0.2rem clamp(1rem, 4vw, 2rem) 0.2rem clamp(1.5rem, 5vw, 3rem);
      box-shadow: inset 0 8px 12px rgba(0, 0, 0, 0.6), inset 0 -2px 4px rgba(255, 255, 255, 0.05);
      margin-bottom: clamp(0.8rem, 3vw, 1.5rem);
      border: 1px solid #2f4b5c;
      width: 100%;
      position: relative;
      z-index: 20;
    }
    
    .time-display {
      font-family: 'Fira Mono', 'JetBrains Mono', 'Cascadia Code', 'Segoe UI Mono', monospace;
      font-size: clamp(2.8rem, 15vw, 5.8rem);
      font-weight: 500;
      letter-spacing: 2px;
      text-align: center;
      color: #b9f2ff;
      text-shadow: 0 0 12px #3ec1ff, 0 0 30px #0077ff;
      line-height: 1.3;
      word-break: break-word;
    }
    
    .sub-labels {
      display: flex;
      justify-content: space-between;
      font-size: clamp(0.8rem, 3vw, 1rem);
      color: #9bbad0;
      padding: 0 0.8rem 0.2rem 0.8rem;
      letter-spacing: 1px;
      font-weight: 400;
      text-transform: uppercase;
      opacity: 0.7;
      width: 100%;
      position: relative;
      z-index: 20;
    }
    
    .indicator-panel {
      display: flex;
      align-items: center;
      justify-content: space-between;
      background: #0f1e28;
      border-radius: clamp(40px, 8vw, 60px);
      padding: clamp(0.4rem, 2vw, 0.6rem) clamp(1rem, 4vw, 2rem);
      margin: clamp(0.8rem, 3vw, 1.2rem) 0 clamp(1.2rem, 4vw, 1.8rem) 0;
      border: 1px solid #2d4a5a;
      box-shadow: inset 0 1px 4px #00000055, 0 10px 20px -10px black;
      flex-wrap: wrap;
      gap: 8px;
      position: relative;
      z-index: 20;
      width: 100%;
    }
    
    .status-badge {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    
    .led {
      width: clamp(14px, 4vw, 18px);
      height: clamp(14px, 4vw, 18px);
      border-radius: 50%;
      background: #4e4e4e;
      box-shadow: 0 0 8px #00000099;
      transition: background 0.15s, box-shadow 0.2s;
      flex-shrink: 0;
    }
    
    .led.active {
      background: #49ffa0;
      box-shadow: 0 0 20px #1eff8e, 0 0 40px #0aff60;
    }
    
    .led.paused {
      background: #ffe570;
      box-shadow: 0 0 18px #ffd966, 0 0 30px #ffb347;
    }
    
    .status-text {
      font-weight: 600;
      font-size: clamp(1.2rem, 5vw, 1.5rem);
      letter-spacing: 1px;
      color: #c2e2ff;
      text-shadow: 0 2px 5px black;
      min-width: 70px;
      white-space: nowrap;
    }
    
    .hint-badge {
      background: #1f3b4a;
      padding: 0.3rem clamp(0.8rem, 3vw, 1.4rem);
      border-radius: 40px;
      color: #bfe2ff;
      font-weight: 500;
      font-size: clamp(0.9rem, 3.5vw, 1.2rem);
      border: 1px solid #3487b0;
      box-shadow: inset 0 1px 3px #8fcbff33, 0 4px 8px black;
      display: flex;
      gap: clamp(6px, 2vw, 12px);
      flex-wrap: wrap;
      justify-content: center;
    }
    
    .hint-badge kbd {
      background: #2b4d5e;
      border-radius: 6px;
      padding: 0.1rem clamp(0.5rem, 2vw, 0.9rem);
      font-size: clamp(1rem, 4vw, 1.3rem);
      font-weight: 700;
      color: #ddf0ff;
      border: 1px solid #6fbbff;
      box-shadow: 0 2px 0 #0e1f2a;
      white-space: nowrap;
    }
    
    /* 按钮容器 - 现在只有两个按钮(移除了计次按钮) */
    .button-bar {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      justify-content: center;
      gap: clamp(12px, 4vw, 20px);
      margin: clamp(12px, 4vw, 20px) 0 clamp(16px, 5vw, 24px) 0;
      position: relative;
      z-index: 30;
      width: 100%;
    }
    
    .btn {
      background: #1d3643;
      border: none;
      border-bottom: 4px solid #0e1f2c;
      color: #d9f0ff;
      font-size: clamp(1.3rem, 5vw, 1.8rem);
      font-weight: 600;
      padding: clamp(0.7rem, 3vw, 1rem) clamp(1.5rem, 5vw, 2.2rem);
      min-width: clamp(130px, 35vw, 160px);
      border-radius: 50px;
      cursor: pointer;
      box-shadow: 0 12px 20px -12px black, inset 0 1px 3px #9ad4ff7a;
      transition: transform 0.07s, border-color 0.1s, background 0.1s, box-shadow 0.1s;
      letter-spacing: 1px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 8px;
      flex: 0 1 auto;
      touch-action: manipulation;
      -webkit-touch-callout: none;
      position: relative;
      z-index: 31;
      pointer-events: auto !important;
    }
    
    .btn:active {
      transform: translateY(3px);
      border-bottom-width: 2px;
      background: #17323f;
    }
    
    .btn.reset-btn {
      background: #2e2e42;
      border-bottom-color: #191927;
    }
    
    .btn.reset-btn:active {
      background: #40405c;
    }
    
    .btn:focus-visible {
      outline: 3px solid white;
      outline-offset: 4px;
    }
    
    .lap-panel {
      background: #0b1921e0;
      border-radius: clamp(24px, 6vw, 36px);
      padding: clamp(0.8rem, 3vw, 1.2rem) clamp(1rem, 4vw, 1.5rem);
      border: 1px solid #2e5568;
      box-shadow: inset 0 2px 6px #00000080, 0 8px 20px #0000004d;
      max-height: min(300px, 45vh);
      overflow-y: auto;
      scrollbar-width: thin;
      scrollbar-color: #3d7085 #10232e;
      -webkit-overflow-scrolling: touch;
      position: relative;
      z-index: 20;
      width: 100%;
      margin-bottom: 10px;
    }
    
    .lap-panel::-webkit-scrollbar {
      width: 6px;
    }
    
    .lap-panel::-webkit-scrollbar-track {
      background: #10232e;
      border-radius: 10px;
    }
    
    .lap-panel::-webkit-scrollbar-thumb {
      background: #3d7085;
      border-radius: 10px;
      border: 1px solid #173e4d;
    }
    
    .lap-title {
      display: flex;
      justify-content: space-between;
      color: #9fc9de;
      font-weight: 500;
      letter-spacing: 1px;
      border-bottom: 1px solid #2b4d5e;
      padding-bottom: 6px;
      margin-bottom: 4px;
      font-size: clamp(0.9rem, 3.5vw, 1.1rem);
    }
    
    .lap-list {
      list-style: none;
      margin: 0;
      padding: 0;
    }
    
    .lap-item {
      display: flex;
      justify-content: space-between;
      font-family: 'Fira Mono', 'JetBrains Mono', monospace;
      font-size: clamp(1rem, 4vw, 1.2rem);
      padding: clamp(4px, 2vw, 6px) clamp(8px, 3vw, 12px);
      border-bottom: 1px dashed #1f404f;
      color: #cde1ed;
    }
    
    .lap-item span:first-child {
      color: #8fcbff;
      font-weight: 400;
    }
    
    .lap-item span:last-child {
      font-weight: 500;
      letter-spacing: 1px;
      color: #b4edff;
    }
    
    .empty-lap-message {
      color: #607e8b;
      font-style: italic;
      text-align: center;
      padding: clamp(16px, 5vw, 24px) 0;
      font-size: clamp(1rem, 4vw, 1.2rem);
    }
    
    .footer-note {
      text-align: center;
      margin-top: clamp(12px, 4vw, 18px);
      color: #5f899b;
      font-size: clamp(0.9rem, 3.5vw, 1.1rem);
      border-top: 1px dashed #2f5568;
      padding-top: clamp(12px, 4vw, 16px);
      padding-bottom: clamp(10px, 3vw, 20px);
      line-height: 1.6;
      position: relative;
      z-index: 20;
      width: 100%;
    }
    
    .footer-note kbd {
      background: #1c3340;
      border-radius: 6px;
      padding: 0.1rem clamp(0.5rem, 2vw, 0.9rem);
      font-size: clamp(1rem, 3.8vw, 1.2rem);
      color: #c3e5ff;
      border: 1px solid #44758b;
      white-space: nowrap;
      margin: 0 2px;
    }
    
    /* 小屏幕优化 */
    @media (max-width: 600px) {
      body {
        padding: 12px;
        align-items: flex-start;
      }
      
      .stopwatch-card {
        margin: 0 auto;
        width: 100%;
      }
      
      .footer-note {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        gap: 6px;
      }
      
      .btn {
        min-width: 0;
        flex: 1 1 40%;
        font-size: clamp(1.1rem, 4.5vw, 1.4rem);
        padding: clamp(0.6rem, 2.5vw, 0.8rem) clamp(1.2rem, 4vw, 1.6rem);
      }
      
      .hint-badge {
        font-size: clamp(0.8rem, 3vw, 1rem);
      }
      
      .hint-badge kbd {
        font-size: clamp(0.9rem, 3.5vw, 1.1rem);
      }
      
      .lap-panel {
        max-height: min(250px, 40vh);
      }
    }
  </style>
</head>
<body>
  <div class="stopwatch-card" id="appContainer">
    <!-- 主计时器 -->
    <div class="display-wrap">
      <div class="time-display" id="timerDisplay">00:00.00</div>
    </div>
    <div class="sub-labels">
      <span>分钟</span>
      <span>秒</span>
      <span>厘秒</span>
    </div>

    <!-- LED状态 + 快捷键组合提示 -->
    <div class="indicator-panel">
      <div class="status-badge">
        <span class="led" id="ledIndicator"></span>
        <span class="status-text" id="statusLabel">暂停</span>
      </div>
      <div class="hint-badge">
        <kbd>👆 空白处触摸</kbd> 开始/暂停
      </div>
    </div>

    <!-- 两个核心按钮 - 只有启动/暂停和重置 -->
    <div class="button-bar">
      <button class="btn" id="startStopBtn">▶ 启动</button>
      <button class="btn reset-btn" id="resetBtn">⟲ 重置</button>
    </div>

    <!-- 历史成绩记录区 - 自动记录每次完成的时间 -->
    <div class="lap-panel" id="lapPanel">
      <div class="lap-title">
        <span>历史成绩 (开始新计时时自动记录)</span>
        <span>时间 (mm:ss.cc)</span>
      </div>
      <ul class="lap-list" id="lapList">
        <li class="empty-lap-message">暂无成绩,开始计时后会自动记录</li>
      </ul>
    </div>

    <div class="footer-note">
      <kbd>👆 空白处触摸</kbd> 开始/暂停 · 
      <kbd>Enter</kbd> 重置 · 
      <span style="opacity:0.8;">再次开始自动记录上次成绩</span>
    </div>
  </div>

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

      // ----- 核心变量 -----
      let isRunning = false;
      let startTime = 0;
      let accumulatedMs = 0;
      let timerAnimationFrame = null;

      // 历史成绩 - 存储每次完成的时间
      let history = [];
      
      // 记录上一次完成的时间(用于在开始新计时时自动记录)
      let lastCompletedTime = null;

      // 触摸状态
      let isTouching = false;
      let touchStartTime = 0;
      let touchTimer = null;
      let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);

      // DOM 元素
      const timerDisplay = document.getElementById('timerDisplay');
      const ledIndicator = document.getElementById('ledIndicator');
      const statusLabel = document.getElementById('statusLabel');
      const startStopBtn = document.getElementById('startStopBtn');
      const resetBtn = document.getElementById('resetBtn');
      const lapList = document.getElementById('lapList');

      // 防止触摸事件导致页面滚动
      let touchStartY = 0;
      let isScrolling = false;

      // ----- 辅助函数:格式化 mm:ss.cc -----
      function formatTime(msTotal) {
        if (msTotal < 0) msTotal = 0;
        const totalSeconds = Math.floor(msTotal / 1000);
        const minutes = Math.floor(totalSeconds / 60);
        const seconds = totalSeconds % 60;
        const centiseconds = Math.floor((msTotal % 1000) / 10);
        const minsStr = minutes.toString().padStart(2, '0');
        const secsStr = seconds.toString().padStart(2, '0');
        const csStr = centiseconds.toString().padStart(2, '0');
        return `${minsStr}:${secsStr}.${csStr}`;
      }

      // 获取当前精确计时值
      function getCurrentElapsedMs() {
        if (isRunning && startTime > 0) {
          return accumulatedMs + (performance.now() - startTime);
        } else {
          return accumulatedMs;
        }
      }

      // ----- 更新界面显示 -----
      function updateDisplay() {
        const currentMs = getCurrentElapsedMs();
        timerDisplay.textContent = formatTime(currentMs);
      }

      // ----- 刷新LED和状态文字 -----
      function refreshUiState() {
        if (isRunning) {
          ledIndicator.className = 'led active';
          statusLabel.textContent = '计时中';
          startStopBtn.innerHTML = '⏸︎ 暂停';
        } else {
          ledIndicator.className = 'led paused';
          statusLabel.textContent = '暂停';
          startStopBtn.innerHTML = '▶ 启动';
        }
      }

      // ----- 渲染历史成绩列表 -----
      function renderHistory() {
        lapList.innerHTML = '';
        if (!history.length) {
          lapList.innerHTML = '<li class="empty-lap-message">暂无成绩,开始计时后会自动记录</li>';
          return;
        }

        // 按倒序显示(最新的在上面)
        for (let i = history.length - 1; i >= 0; i--) {
          const recordMs = history[i];
          const li = document.createElement('li');
          li.className = 'lap-item';
          const recordNumber = history.length - i;
          li.innerHTML = `<span>#${recordNumber.toString().padStart(2, '0')}</span> <span>${formatTime(recordMs)}</span>`;
          lapList.appendChild(li);
        }
      }

      // ----- 动画循环 -----
      function updateTimerLoop() {
        if (!isRunning) {
          if (timerAnimationFrame) {
            cancelAnimationFrame(timerAnimationFrame);
            timerAnimationFrame = null;
          }
          return;
        }
        updateDisplay();
        timerAnimationFrame = requestAnimationFrame(updateTimerLoop);
      }

      // ----- 暂停秒表 (记录本次时间但不存入历史,等待下次开始时再存) -----
      function pauseStopwatch() {
        if (!isRunning) return;

        // 累加当前分段,得到最终时间
        if (startTime > 0) {
          accumulatedMs += (performance.now() - startTime);
        }
        
        // 保存本次完成的时间(但先不存入历史)
        lastCompletedTime = accumulatedMs;
        
        startTime = 0;
        isRunning = false;

        if (timerAnimationFrame) {
          cancelAnimationFrame(timerAnimationFrame);
          timerAnimationFrame = null;
        }
        
        updateDisplay();
        refreshUiState();
        // 注意:这里不立即渲染历史,等下次开始时才存入
      }

      // ----- 启动秒表 (如果上次有完成的时间,自动记录到历史) -----
      function startStopwatch() {
        if (isRunning) return;
        
        // 如果有上次完成的时间,先存入历史
        if (lastCompletedTime !== null) {
          history.push(lastCompletedTime);
          lastCompletedTime = null;
          renderHistory();
        }
        
        // 清零,开始新的计时
        accumulatedMs = 0;
        startTime = performance.now();
        isRunning = true;
        refreshUiState();

        if (timerAnimationFrame) {
          cancelAnimationFrame(timerAnimationFrame);
        }
        timerAnimationFrame = requestAnimationFrame(updateTimerLoop);
        updateDisplay(); // 立即显示00:00.00
      }

      // ----- 切换开始/暂停 -----
      function toggleStartPause() {
        if (isRunning) {
          pauseStopwatch();  // 暂停,保存本次时间
        } else {
          startStopwatch();  // 开始,自动记录上次时间并清零
        }
      }

      // ----- 重置所有 (清零并停止, 同时清空所有历史和上次记录) -----
      function resetAll() {
        if (isRunning) {
          isRunning = false;
          startTime = 0;
          if (timerAnimationFrame) {
            cancelAnimationFrame(timerAnimationFrame);
            timerAnimationFrame = null;
          }
        }
        accumulatedMs = 0;
        history = [];
        lastCompletedTime = null;
        renderHistory();
        updateDisplay();
        refreshUiState();
      }

      // ----- 键盘事件 -----
      function onKeyDown(e) {
        if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) {
          e.preventDefault();
          e.stopPropagation();
        }
        else if (e.key === 'Enter' || e.code === 'Enter' || e.keyCode === 13) {
          e.preventDefault();
          e.stopPropagation();
          resetAll();
        }
      }

      function onKeyUp(e) {
        if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) {
          e.preventDefault();
          e.stopPropagation();
          toggleStartPause();
        }
      }

      // ----- 判断触摸目标是否是按钮 -----
      function isButtonTarget(target) {
        return target.tagName === 'BUTTON' || 
             target.closest('button') !== null ||
             target.classList.contains('btn') ||
             target.closest('.btn') !== null;
      }

      // ----- 移动端触摸事件 (只响应空白区域) -----
      function onTouchStart(e) {
        const target = e.target;
        
        if (isButtonTarget(target)) {
          return;
        }
        
        touchStartY = e.touches[0].clientY;
        isScrolling = false;
        isTouching = true;
        touchStartTime = Date.now();
        
        if (touchTimer) {
          clearTimeout(touchTimer);
          touchTimer = null;
        }
      }

      function onTouchMove(e) {
        if (!isTouching) return;
        
        const touchY = e.touches[0].clientY;
        const deltaY = Math.abs(touchY - touchStartY);
        
        if (deltaY > 10) {
          isScrolling = true;
        }
      }

      function onTouchEnd(e) {
        const target = e.target;
        
        if (isButtonTarget(target)) {
          isTouching = false;
          return;
        }
        
        if (!isTouching) return;
        
        const touchDuration = Date.now() - touchStartTime;
        
        if (!isScrolling && touchDuration < 500) {
          if (touchTimer) {
            clearTimeout(touchTimer);
          }
          touchTimer = setTimeout(() => {
            toggleStartPause();
            touchTimer = null;
          }, 10);
        }
        
        isTouching = false;
      }

      function onTouchCancel(e) {
        isTouching = false;
        if (touchTimer) {
          clearTimeout(touchTimer);
          touchTimer = null;
        }
      }

      // ----- 按钮事件绑定 -----
      function bindEvents() {
        // 按钮事件
        startStopBtn.addEventListener('click', function(ev) {
          ev.stopPropagation();
          toggleStartPause();
        });

        resetBtn.addEventListener('click', function(ev) {
          ev.stopPropagation();
          resetAll();
        });

        // 键盘事件
        window.addEventListener('keydown', onKeyDown);
        window.addEventListener('keyup', onKeyUp);
        
        // 防止空格和回车导致页面滚动
        window.addEventListener('keydown', function(e) {
          if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32 || 
            e.key === 'Enter' || e.code === 'Enter' || e.keyCode === 13) {
            e.preventDefault();
          }
        }, false);

        // 移动端触摸事件
        if (isMobile) {
          console.log('移动端模式激活,自动记录模式');
          
          document.addEventListener('touchstart', onTouchStart, { passive: true });
          document.addEventListener('touchmove', onTouchMove, { passive: true });
          document.addEventListener('touchend', onTouchEnd, { passive: true });
          document.addEventListener('touchcancel', onTouchCancel, { passive: true });
          
          [startStopBtn, resetBtn].forEach(btn => {
            btn.addEventListener('touchstart', (e) => {
              e.stopPropagation();
            }, { passive: true });
          });
        }
      }

      // ----- 初始化 -----
      function init() {
        accumulatedMs = 0;
        isRunning = false;
        startTime = 0;
        history = [];
        lastCompletedTime = null;
        isTouching = false;
        if (timerAnimationFrame) {
          cancelAnimationFrame(timerAnimationFrame);
          timerAnimationFrame = null;
        }
        updateDisplay();
        refreshUiState();
        renderHistory();
      }

      bindEvents();
      init();

      // 清理动画
      window.addEventListener('beforeunload', function() {
        if (timerAnimationFrame) {
          cancelAnimationFrame(timerAnimationFrame);
        }
        if (touchTimer) {
          clearTimeout(touchTimer);
        }
      });

    })();
  </script>
</body>
</html>
核心逻辑说明
1、第一次开始计时:0开始,不记录任何成绩
2、暂停计时:保存当前时间到l
astCompletedTime(但还不存入历史列表),计时器显示暂停时的时间
3、再次开始计时:
  • 自动将上次暂停时的时间(lastCompletedTime)存入历史成绩列表
  • 清零计时器,从0开始新的计时
  • 界面立即显示00:00.00

4、重置按钮:清空所有历史成绩和上次记录,计时器归零

优点:
  • 不需要手动计次按钮,操作更简洁
  • 每次开始新计时自动记录上次成绩,符合魔方练习的习惯
  • 历史成绩按倒序显示(最新的在上面),方便查看最近成绩
  • 暂停时可以看到本次时间,确认后再开始新的一轮