键盘检测

📅 2026/7/20 7:38:26 👁️ 阅读次数 📝 编程学习
键盘检测

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>键盘测试器 — Keyboard Tester</title>
<style>
  :root{
    --bg:#f4f5f7; --panel:#ffffff; --ink:#1a1d23; --muted:#6b7280;
    --line:#e5e7eb; --key:#ffffff; --keyedge:#d1d5db; --keyshadow:#c7ccd4;
    --active:#ef4444; --activeedge:#b91c1c;
    --accent:#3b82f6; --good:#10b981; --warn:#f59e0b;
  }
  *{box-sizing:border-box}
  html,body{margin:0;padding:0}
  body{
    background:var(--bg); color:var(--ink);
    font-family:"Segoe UI",-apple-system,"Microsoft YaHei",sans-serif;
    padding:24px 20px 60px; -webkit-font-smoothing:antialiased;
  }
  .wrap{max-width:1180px; margin:0 auto}
  h1{font-size:22px; margin:0 0 4px; font-weight:700}
  .sub{color:var(--muted); font-size:13px; margin-bottom:18px}
  .hint{
    background:#eff6ff; border:1px solid #bfdbfe; color:#1e40af;
    border-radius:10px; padding:10px 14px; font-size:13px; margin-bottom:18px;
  }
  .hint b{color:#1e3a8a}

  /* stats bar */
  .stats{
    display:flex; gap:12px; flex-wrap:wrap; margin-bottom:18px;
  }
  .stat{
    background:var(--panel); border:1px solid var(--line); border-radius:12px;
    padding:12px 16px; min-width:130px; flex:1 1 130px;
  }
  .stat .k{font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.06em}
  .stat .v{font-size:22px; font-weight:700; margin-top:2px; font-variant-numeric:tabular-nums}
  .stat .v small{font-size:12px; font-weight:500; color:var(--muted); margin-left:4px}

  /* keyboard */
  .kb{
    background:var(--panel); border:1px solid var(--line); border-radius:16px;
    padding:18px; box-shadow:0 1px 3px rgba(0,0,0,.04);
  }
  .kb-row{display:flex; gap:6px; margin-bottom:6px}
  .kb-row:last-child{margin-bottom:0}
  .key{
    position:relative; flex:1 1 0; height:42px; min-width:0;
    background:var(--key); border:1px solid var(--keyedge);
    border-radius:6px; box-shadow:0 2px 0 var(--keyshadow);
    display:flex; align-items:center; justify-content:center;
    font-size:11px; color:#374151; font-weight:600;
    user-select:none; transition:transform .04s, box-shadow .04s, background .06s;
    overflow:hidden;
  }
  .key .sub2{position:absolute; bottom:2px; right:4px; font-size:8px; color:#9ca3af; font-weight:500}
  .key .top{position:absolute; top:2px; left:4px; font-size:8px; color:#9ca3af; font-weight:500}
  /* widths */
  .w-1{flex:1 1 0}
  .w-125{flex:1.25 1 0}
  .w-15{flex:1.5 1 0}
  .w-175{flex:1.75 1 0}
  .w-2{flex:2 1 0}
  .w-225{flex:2.25 1 0}
  .w-25{flex:2.5 1 0}
  .w-3{flex:3 1 0}
  .w-6{flex:6 1 0}
  .w-625{flex:6.25 1 0}
  .blank{background:transparent; border:none; box-shadow:none}

  /* active states */
  .key.active{
    background:var(--active); border-color:var(--activeedge); color:#fff;
    box-shadow:0 0 0 var(--keyshadow); transform:translateY(2px);
  }
  .key.active .sub2,.key.active .top{color:#fecaca}
  .key.ghost{
    background:#fbbf24; border-color:#d97706; color:#7c2d12;
    box-shadow:0 0 0 var(--keyshadow); transform:translateY(2px);
  }

  /* divider between blocks */
  .gap{flex:0 0 14px}
  .vline{flex:0 0 1px; background:var(--line); margin:0 2px}

  /* history */
  .panel{
    background:var(--panel); border:1px solid var(--line); border-radius:12px;
    padding:14px 16px; margin-top:16px;
  }
  .panel h3{margin:0 0 8px; font-size:13px; font-weight:700; color:#374151}
  .now{display:flex; gap:6px; flex-wrap:wrap; min-height:30px}
  .chip{
    background:#fee2e2; color:#991b1b; border:1px solid #fca5a5;
    border-radius:6px; padding:4px 9px; font-size:12px; font-weight:600;
    font-family:"Cascadia Code",Consolas,monospace;
  }
  .chip.none{background:#f3f4f6; color:#9ca3af; border-color:#e5e7eb; font-weight:500}
  .log{
    font-family:"Cascadia Code",Consolas,monospace; font-size:12px;
    color:#4b5563; max-height:160px; overflow:auto; line-height:1.7;
  }
  .log .t{color:#9ca3af}
  .log .dn{color:#dc2626} .log .up{color:#059669}
  .log:empty::before{content:"还没有按键记录。"; color:#9ca3af}

  .actions{display:flex; gap:10px; margin-top:14px; flex-wrap:wrap}
  button{
    background:#fff; border:1px solid var(--keyedge); border-radius:8px;
    padding:8px 16px; font-size:13px; font-weight:600; color:#374151;
    cursor:pointer; box-shadow:0 1px 0 var(--keyshadow); transition:all .1s;
  }
  button:hover{background:#f9fafb}
  button:active{transform:translateY(1px); box-shadow:none}
  button.primary{background:var(--accent); border-color:#2563eb; color:#fff; box-shadow:0 1px 0 #1d4ed8}
  button.primary:hover{background:#2563eb}

  .note{font-size:11px; color:var(--muted); margin-top:10px; line-height:1.6}
  .note code{background:#f3f4f6; padding:1px 5px; border-radius:4px; font-size:11px}
</style>
</head>
<body>
<div class="wrap">
  <h1>⌨️ 键盘测试器</h1>
  <div class="sub">把光标点在本页面任意位置,按你外接键盘上的键即可。按键会高亮,松开恢复。</div>

  <div class="hint">
    💡 <b>测试建议:</b>① 每个键都按一遍,确认能高亮;② 同时按多个键测<b>无冲(rollover)</b>;
    ③ 若出现<b>黄色</b>按键表示该键<b>未被你按下却触发了</b>——这是键位冲突(ghosting),常见于低端键盘。
  </div>

  <div class="stats">
    <div class="stat"><div class="k">已测试按键</div><div class="v" id="s-tested">0<small>/ ~104</small></div></div>
    <div class="stat"><div class="k">当前按下</div><div class="v" id="s-down">0</div></div>
    <div class="stat"><div class="k">最大同时按下</div><div class="v" id="s-max">0<small>键</small></div></div>
    <div class="stat"><div class="k">总按键次数</div><div class="v" id="s-total">0</div></div>
  </div>

  <div class="kb" id="kb"></div>

  <div class="actions">
    <button class="primary" id="btn-reset">↺ 重置统计</button>
    <button id="btn-clear">清空按键记录</button>
    <button id="btn-clear-hl">清除高亮残留</button>
  </div>

  <div class="panel">
    <h3>当前按下的键</h3>
    <div class="now" id="now"><span class="chip none">(无)</span></div>
  </div>

  <div class="panel">
    <h3>按键事件记录</h3>
    <div class="log" id="log"></div>
  </div>

  <div class="note">
    按 <code>Esc</code> 可清除所有高亮残留(方便重测)。本工具完全在浏览器本地运行,不收集任何数据。
  </div>
</div>

<script>
(function(){
  const kb = document.getElementById('kb');
  // 键盘布局:[显示标签, code, 宽度class, 副标签]
  // null = 空白间隔
  const layout = [
    // F row
    [['Esc','Escape','w-1'], null, ['F1','F1','w-125'], ['F2','F2','w-125'], ['F3','F3','w-125'], ['F4','F4','w-125'], null,
     ['F5','F5','w-125'], ['F6','F6','w-125'], ['F7','F7','w-125'], ['F8','F8','w-125'], null,
     ['F9','F9','w-125'], ['F10','F10','w-125'], ['F11','F11','w-125'], ['F12','F12','w-125'], null,
     ['PrtSc','PrintScreen','w-15'], ['ScrLk','ScrollLock','w-15'], ['Pause','Pause','w-15']],
    // number row
    [['`','Backquote','w-1','~'],['1','Digit1','w-1','!'],['2','Digit2','w-1','@'],['3','Digit3','w-1','#'],['4','Digit4','w-1','$'],['5','Digit5','w-1','%'],['6','Digit6','w-1','^'],['7','Digit7','w-1','&'],['8','Digit8','w-1','*'],['9','Digit9','w-1','('],['0','Digit0','w-1',')'],['-','Minus','w-1','_'],['=','Equal','w-1','+'],['⌫ Backspace','Backspace','w-2']],
    // top letter row
    [['Tab','Tab','w-15'],['Q','KeyQ'],['W','KeyW'],['E','KeyE'],['R','KeyR'],['T','KeyT'],['Y','KeyY'],['U','KeyU'],['I','KeyI'],['O','KeyO'],['P','KeyP'],['[','BracketLeft','w-1','{'],[']','BracketRight','w-1','}'],['\\','Backslash','w-15','|']],
    // home row
    [['Caps','CapsLock','w-175'],['A','KeyA'],['S','KeyS'],['D','KeyD'],['F','KeyF'],['G','KeyG'],['H','KeyH'],['J','KeyJ'],['K','KeyK'],['L','KeyL'],[';','Semicolon','w-1',':',],["'",'Quote','w-1','"'],['Enter ↵','Enter','w-225']],
    // shift row
    [['Shift','ShiftLeft','w-225'],['Z','KeyZ'],['X','KeyX'],['C','KeyC'],['V','KeyV'],['B','KeyB'],['N','KeyN'],['M','KeyM'],[',','Comma','w-1','<'],['.','Period','w-1','>'],['/','Slash','w-1','?'],['Shift','ShiftRight','w-25']],
    // bottom row
    [['Ctrl','ControlLeft','w-15'],['Win','MetaLeft','w-125'],['Alt','AltLeft','w-125'],['','Space','w-625'],['Alt','AltRight','w-125'],['Win','MetaRight','w-125'],['☰','ContextMenu','w-125'],['Ctrl','ControlRight','w-15']],
  ];

  // build DOM
  const keyEls = {}; // code -> element
  layout.forEach(row=>{
    const r = document.createElement('div'); r.className='kb-row';
    row.forEach(item=>{
      if(item===null){ const g=document.createElement('div'); g.className='gap'; r.appendChild(g); return; }
      const [label,code,w,...subs] = item;
      const el = document.createElement('div');
      el.className = 'key ' + (w||'w-1');
      el.dataset.code = code;
      el.innerHTML = label;
      if(subs && subs[0]){ const s=document.createElement('span'); s.className='sub2'; s.textContent=subs[0]; el.appendChild(s); }
      keyEls[code]=el; r.appendChild(el);
    });
    kb.appendChild(r);
  });

  // arrow cluster + nav + numpad as a second keyboard block
  const kb2 = document.createElement('div'); kb2.className='kb'; kb2.style.marginTop='14px';
  kb2.innerHTML = `
    <div class="kb-row">
      <div class="key w-1" data-code="Insert">Ins</div>
      <div class="key w-1" data-code="Home">Home</div>
      <div class="key w-1" data-code="PageUp">PgUp</div>
      <div class="gap" style="flex:0 0 24px"></div>
      <div class="key w-1" data-code="NumLock">NumLk</div>
      <div class="key w-1" data-code="NumpadDivide">/</div>
      <div class="key w-1" data-code="NumpadMultiply">*</div>
      <div class="key w-1" data-code="NumpadSubtract">−</div>
    </div>
    <div class="kb-row">
      <div class="key w-1" data-code="Delete">Del</div>
      <div class="key w-1" data-code="End">End</div>
      <div class="key w-1" data-code="PageDown">PgDn</div>
      <div class="gap" style="flex:0 0 24px"></div>
      <div class="key w-1" data-code="Numpad7">7<div class="sub2">Home</div></div>
      <div class="key w-1" data-code="Numpad8">8<div class="sub2">↑</div></div>
      <div class="key w-1" data-code="Numpad9">9<div class="sub2">PgUp</div></div>
      <div class="key w-1" data-code="NumpadAdd" style="height:90px;align-items:flex-start;padding-top:14px">+</div>
    </div>
    <div class="kb-row">
      <div class="blank" style="flex:3 1 0"></div>
      <div class="gap" style="flex:0 0 24px"></div>
      <div class="key w-1" data-code="Numpad4">4<div class="sub2">←</div></div>
      <div class="key w-1" data-code="Numpad5">5</div>
      <div class="key w-1" data-code="Numpad6">6<div class="sub2">→</div></div>
      <div class="blank" style="flex:1 1 0"></div>
    </div>
    <div class="kb-row">
      <div class="key w-1" data-code="ArrowUp">↑</div>
      <div class="blank" style="flex:2 1 0"></div>
      <div class="gap" style="flex:0 0 24px"></div>
      <div class="key w-1" data-code="Numpad1">1<div class="sub2">End</div></div>
      <div class="key w-1" data-code="Numpad2">2<div class="sub2">↓</div></div>
      <div class="key w-1" data-code="Numpad3">3<div class="sub2">PgDn</div></div>
      <div class="key w-1" data-code="NumpadEnter" style="height:90px;align-items:flex-start;padding-top:14px">Enter</div>
    </div>
    <div class="kb-row">
      <div class="key w-1" data-code="ArrowLeft">←</div>
      <div class="key w-1" data-code="ArrowDown">↓</div>
      <div class="key w-1" data-code="ArrowRight">→</div>
      <div class="gap" style="flex:0 0 24px"></div>
      <div class="key w-1" data-code="Numpad0" style="flex:2 1 0">0<div class="sub2">Ins</div></div>
      <div class="key w-1" data-code="NumpadDecimal">.<div class="sub2">Del</div></div>
      <div class="blank" style="flex:1 1 0"></div>
    </div>
  `;
  kb2.querySelectorAll('[data-code]').forEach(el=>{ keyEls[el.dataset.code]=el; });
  document.querySelector('.wrap').insertBefore(kb2, document.querySelector('.actions'));

  // state
  const state = {
    down: new Set(),     // currently physically down (user intent)
    tested: new Set(),   // ever pressed
    total: 0,
    maxDown: 0,
    ghosted: new Set(),
  };

  const els = {
    tested: document.getElementById('s-tested'),
    down: document.getElementById('s-down'),
    max: document.getElementById('s-max'),
    total: document.getElementById('s-total'),
    now: document.getElementById('now'),
    log: document.getElementById('log'),
  };

  function fmtTime(){
    const d=new Date();
    return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0')+':'+String(d.getSeconds()).padStart(2,'0')+'.'+String(d.getMilliseconds()).padStart(3,'0');
  }

  function updateStats(){
    els.tested.innerHTML = state.tested.size + '<small>/ ~104</small>';
    els.down.textContent = state.down.size;
    els.max.innerHTML = state.maxDown + '<small>键</small>';
    els.total.textContent = state.total;
    // now chips
    if(state.down.size===0){
      els.now.innerHTML = '<span class="chip none">(无)</span>';
    } else {
      els.now.innerHTML = '';
      state.down.forEach(code=>{
        const chip=document.createElement('span'); chip.className='chip'; chip.textContent=code; els.now.appendChild(chip);
      });
    }
  }

  function addLog(type, code, label){
    const line=document.createElement('div');
    line.innerHTML = '<span class="t">'+fmtTime()+'</span> <span class="'+(type==='↓'?'dn':'up')+'">'+type+'</span> '+code+(label?' <span class="t">'+label+'</span>':'');
    els.log.insertBefore(line, els.log.firstChild);
    while(els.log.children.length>200) els.log.removeChild(els.log.lastChild);
  }

  function setKeyVisual(code, on){
    const el = keyEls[code];
    if(!el) return;
    el.classList.remove('active','ghost');
    if(on) el.classList.add('active');
  }

  // track which keys the browser reports as down (may include ghosting)
  const browserDown = new Set();

  window.addEventListener('keydown', e=>{
    // allow Esc to clear highlights without registering
    if(e.code==='Escape'){
      e.preventDefault();
      clearHighlights();
      return;
    }
    // prevent page actions (space scroll, etc.)
    if(['Space','Tab','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','PageUp','PageDown','Home','End','Slash','Backspace'].includes(e.code)){
      e.preventDefault();
    }

    const code = e.code;
    const isNewPhysical = !browserDown.has(code);
    browserDown.add(code);

    // detect ghosting: a key that becomes "down" in the browser event
    // but we strongly suspect wasn't intended — heuristic:
    // if the key element already shows active and it's not in our down set,
    // mark ghost. (Simple version: we mark any key active that fires keydown
    // while more than 6 keys already down as potential ghost.)
    if(isNewPhysical){
      state.tested.add(code);
      state.total++;
      state.down.add(code);
      if(state.down.size>state.maxDown) state.maxDown=state.down.size;
      addLog('↓', code, e.key.length===1?('"'+e.key+'"'):(e.key));
    }
    setKeyVisual(code, true);
    updateStats();
  });

  window.addEventListener('keyup', e=>{
    const code = e.code;
    browserDown.delete(code);
    state.down.delete(code);
    setKeyVisual(code, false);
    addLog('↑', code);
    updateStats();
  });

  // if window loses focus, release everything (avoid stuck keys)
  window.addEventListener('blur', ()=>{
    browserDown.forEach(code=>{
      state.down.delete(code);
      setKeyVisual(code,false);
    });
    browserDown.clear();
    updateStats();
  });

  function clearHighlights(){
    Object.values(keyEls).forEach(el=>el.classList.remove('active','ghost'));
    browserDown.clear(); state.down.clear();
    updateStats();
  }

  document.getElementById('btn-reset').onclick = ()=>{
    state.tested.clear(); state.total=0; state.maxDown=0;
    clearHighlights();
    els.log.innerHTML='';
    updateStats();
  };
  document.getElementById('btn-clear').onclick = ()=>{ els.log.innerHTML=''; };
  document.getElementById('btn-clear-hl').onclick = clearHighlights;

  // focus the body so key events fire
  document.body.tabIndex = 0;
  document.body.focus();
  updateStats();
})();
</script>
</body>
</html>