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