一、科技感计网页时器应用介绍
这是一个功能丰富、界面精美的网页计时器应用,集成了秒表和倒计时两大核心功能,具有以下特点:
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>