Skip to content

实现 硬件自检套件 工具

分类:general | 标签:硬件、自检、设备 键盘摄像头麦克风屏幕坏点检测

2.1 功能说明

键盘摄像头麦克风屏幕坏点检测

核心能力

  • 键盘检测

2.1.1 使用指南

硬件自检套件使用说明

把常用的设备自检项目集中到一个页面:键盘按键、摄像头、麦克风、屏幕坏点、屏幕常亮。 全部依赖浏览器原生能力,不安装任何插件,采集到的画面与声音只在本机处理,不上传、不留存。 适合验机、二手交易验货、远程会议前的设备检查。

键盘检测

点击检测区使其聚焦后按键,会显示 key(字符含义)、code(物理键位)、 keyCode(旧版兼容码)以及左右位置。左右 Shift、Ctrl、Alt 的 code 不同,可以据此分辨。 已按过的键会被记录成绿色标签,逐一按遍键盘即可发现失灵的键位。

少数按键网页无法捕获:Windows 徽标键单击、Alt+Tab、Ctrl+Alt+Del、部分笔记本的 Fn 组合键与多媒体键, 它们在到达页面前就被系统或浏览器截获,测不到不代表键坏了。检测时建议先退出输入法。

摄像头预览

选择设备后点击打开,可查看分辨率与实时画面,并支持保存一帧图片到本地用于对比。 若下拉框里的设备名称为空,是因为浏览器在未授权前会隐藏设备名,授权一次后重新进入即可看到。

麦克风电平

使用 Web Audio 的分析节点计算实时音量,电平条与下方波形同步跳动。 正常说话时电平应在 30 到 70 之间起伏;始终为 0 说明没有拾到音;始终顶格说明增益过高或存在啸叫。

屏幕坏点检测

依次铺满红、绿、蓝、白、黑、灰六种纯色。亮点指黑屏时仍发光的点,暗点指白屏时不发光的点, 坏点在任何颜色下都保持同一颜色。红绿蓝三色用于检查子像素缺失,白色查暗点与污渍,黑色查亮点与背光漏光。 请在暗环境下检查黑色画面,并把屏幕擦干净以免把灰尘误判为坏点。

浏览器全屏由用户手势触发,若系统拒绝进入全屏,工具会退化为铺满浏览器窗口的检测层,同样可用。

屏幕常亮

调用屏幕唤醒锁接口阻止设备息屏。该能力需要 https 或 localhost 环境, 目前 Chrome、Edge、Android Chrome 与 iOS 16.4 以上的 Safari 支持,其它浏览器会提示不支持。 系统电量过低或用户手动锁屏时,唤醒锁会被系统强制释放,回到页面后工具会自动重新申请。

隐私说明

摄像头与麦克风仅在你点击“打开”后启动,切换标签页或离开本页会立即停止采集并释放设备, 指示灯随之熄灭。所有数据都在浏览器内存中,页面关闭即消失。

2.2 代码实现

2.2.1 组件结构

本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-inputel-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。

2.2.2 核心逻辑概览

<script setup> 中定义的主要函数/方法:

  • keyDisplay()
  • onKeyDown()
  • onKeyUp()
  • clearKeys()
  • refreshDevices()
  • mediaErrorText()
  • startCamera()
  • stopCamera()
  • snapshot()
  • drawWave()
  • startMic()
  • stopMic()
  • paintColor()
  • nextColor()
  • prevColor()
  • onFsKey()
  • onResize()
  • enterScreenTest()
  • exitScreenTest()
  • requestWake()
  • releaseWake()
  • onVisibility()
  • onTabChange()

2.2.3 关键实现代码

以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。

vue
import { ref, onMounted, onBeforeUnmount } from 'vue'
import ToolPageShell from '@/components/ToolPageShell.vue'

const activeName = ref('keyboard')

/* ---------- 键盘检测 ---------- */
const lastKey = ref(null)
const heldKeys = ref([])
const testedKeys = ref([])

const locationNames = ['标准', '左侧', '右侧', '小键盘']

function keyDisplay(e) {
  const map = {
    ' ': '空格',
    Escape: 'Esc',
    ArrowUp: '上',
    ArrowDown: '下',
    ArrowLeft: '左',
    ArrowRight: '右',
    Enter: '回车',
    Backspace: '退格',
    Tab: 'Tab',
    Control: 'Ctrl',
    Meta: 'Meta',
    CapsLock: 'Caps',
    Delete: 'Del',
    Insert: 'Ins',
    PageUp: 'PgUp',
    PageDown: 'PgDn'
  }
  if (map[e.key]) return map[e.key]
  return Array.from(e.key).length === 1 ? e.key.toUpperCase() : e.key
}

function onKeyDown(e) {
  e.preventDefault()
  const mods = []
  if (e.ctrlKey) mods.push('Ctrl')
  if (e.shiftKey) mods.push('Shift')
  if (e.altKey) mods.push('Alt')
  if (e.metaKey) mods.push('Meta')
  lastKey.value = {
    display: keyDisplay(e),
    key: e.key === ' ' ? 'Space' : e.key,
    code: e.code || '未知',
    keyCode: e.keyCode,
    location: locationNames[e.location] || '标准',
    repeat: e.repeat,
    modifiers: mods
  }
  const id = e.code || e.key
  if (!heldKeys.value.includes(id)) heldKeys.value.push(id)
  if (!testedKeys.value.includes(id)) testedKeys.value.push(id)
}

function onKeyUp(e) {
  const id = e.code || e.key
  heldKeys.value = heldKeys.value.filter((k) => k !== id)
}

function clearKeys() {
  testedKeys.value = []
  heldKeys.value = []
  lastKey.value = null
}

/* ---------- 设备列表 ---------- */
const cameraList = ref([])
const micList = ref([])

async function refreshDevices() {
  if (!navigator.mediaDevices || !navigator.mediaDevices.enumerateDevices) return
  try {
    const list = await navigator.mediaDevices.enumerateDevices()
    cameraList.value = list
      .filter((d) => d.kind === 'videoinput')
      .map((d, i) => ({ deviceId: d.deviceId, label: d.label || '摄像头 ' + (i + 1) }))
    micList.value = list
      .filter((d) => d.kind === 'audioinput')
      .map((d, i) => ({ deviceId: d.deviceId, label: d.label || '麦克风 ' + (i + 1) }))
  } catch (e) {
    // 枚举失败不影响主流程,使用默认设备即可
  }
}

function mediaErrorText(e, what) {
  const name = e && e.name ? e.name : ''
  if (name === 'NotAllowedError') return what + '权限被拒绝,请点击地址栏权限图标重新允许后重试。'
  if (name === 'NotFoundError') return '没有找到可用的' + what + '设备。'
  if (name === 'NotReadableError') return what + '被其它程序占用,请关闭正在使用它的软件后重试。'
  if (name === 'OverconstrainedError') return '所选' + what + '不支持请求的参数,请换一个设备。'
  if (name === 'SecurityError') return what + '只能在 https 或 localhost 环境下使用。'
  if (!navigator.mediaDevices) return '当前浏览器不支持媒体设备访问,请更换浏览器。'
  return '打开' + what + '失败:' + (e && e.message ? e.message : String(e))
}

/* ---------- 摄像头 ---------- */
const cameraEl = ref(null)
const cameraOn = ref(false)
const cameraError = ref('')
const cameraId = ref('')
const cameraInfo = ref('')
let cameraStream = null

async function startCamera() {
  cameraError.value = ''
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    cameraError.value = '当前浏览器不支持摄像头访问,或页面不在 https / localhost 环境下。'
    return
  }
  try {
    const constraints = cameraId.value ? { video: { deviceId: { exact: cameraId.value } } } : { video: true }
    cameraStream = await navigator.mediaDevices.getUserMedia(constraints)
    if (cameraEl.value) {
      cameraEl.value.srcObject = cameraStream
      await cameraEl.value.play().catch(() => {})
    }
    cameraOn.value = true
    const track = cameraStream.getVideoTracks()[0]
    const s = track && track.getSettings ? track.getSettings() : {}
    cameraInfo.value = (s.width || '?') + ' x ' + (s.height || '?') + ',' + Math.round(s.frameRate || 0) + ' fps'
    await refreshDevices()
  } catch (e) {
    cameraError.value = mediaErrorText(e, '摄像头')
    stopCamera()
  }
}

function stopCamera() {
  if (cameraStream) {
    try {
      cameraStream.getTracks().forEach((t) => t.stop())
    } catch (e) {
      // 忽略停止异常
    }
    cameraStream = null
  }
  if (cameraEl.value) cameraEl.value.srcObject = null
  cameraOn.value = false
  cameraInfo.value = ''
}

function snapshot() {
  const v = cameraEl.value
  if (!v || !v.videoWidth) return
  const canvas = document.createElement('canvas')
  canvas.width = v.videoWidth
  canvas.height = v.videoHeight
  canvas.getContext('2d').drawImage(v, 0, 0)
  canvas.toBlob((blob) => {
    if (!blob) return
    const href = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = href
    a.download = 'camera-snapshot.png'
    a.click()
    window.setTimeout(() => URL.revokeObjectURL(href), 1000)
  }, 'image/png')
}

/* ---------- 麦克风 ---------- */
const micOn = ref(false)
const micError = ref('')
const micId = ref('')
const micLevel = ref(0)
const micPeak = ref(0)
const waveEl = ref(null)
let micStream = null
let micCtx = null
let analyser = null
let micRaf = null
let dataArray = null

function drawWave() {
  if (!analyser) return
  analyser.getByteTimeDomainData(dataArray)
  let sum = 0
  let peak = 0
  for (let i = 0; i < dataArray.length; i += 1) {
    const v = (dataArray[i] - 128) / 128
    sum += v * v
    const abs = Math.abs(v)
    if (abs > peak) peak = abs
  }
  const rms = Math.sqrt(sum / dataArray.length)
  const level = Math.min(100, Math.round(rms * 300))
  micLevel.value = level
  if (level > micPeak.value) micPeak.value = level

  const canvas = waveEl.value
  if (canvas) {
    const ctx = canvas.getContext('2d')
    const w = canvas.width
    const h = canvas.height
    ctx.clearRect(0, 0, w, h)
    ctx.fillStyle = '#f5f7fa'
    ctx.fillRect(0, 0, w, h)
    ctx.strokeStyle = '#dcdfe6'
    ctx.beginPath()
    ctx.moveTo(0, h / 2)
    ctx.lineTo(w, h / 2)
    ctx.stroke()
    ctx.lineWidth = 2
    ctx.strokeStyle = '#409eff'
    ctx.beginPath()
    const step = w / dataArray.length
    for (let i = 0; i < dataArray.length; i += 1) {
      const y = h / 2 + ((dataArray[i] - 128) / 128) * (h / 2) * 0.9
      if (i === 0) ctx.moveTo(0, y)
      else ctx.lineTo(i * step, y)
    }
    ctx.stroke()
  }
  micRaf = window.requestAnimationFrame(drawWave)
}

async function startMic() {
  micError.value = ''
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    micError.value = '当前浏览器不支持麦克风访问,或页面不在 https / localhost 环境下。'
    return
  }
  try {
    const constraints = micId.value ? { audio: { deviceId: { exact: micId.value } } } : { audio: true }
    micStream = await navigator.mediaDevices.getUserMedia(constraints)
    const Ctx = window.AudioContext || window.webkitAudioContext
    if (!Ctx) {
      micError.value = '当前浏览器不支持 Web Audio,无法计算电平。'
      stopMic()
      return
    }
    micCtx = new Ctx()
    if (micCtx.state === 'suspended') await micCtx.resume().catch(() => {})
    analyser = micCtx.createAnalyser()
    analyser.fftSize = 2048
    dataArray = new Uint8Array(analyser.fftSize)
    micCtx.createMediaStreamSource(micStream).connect(analyser)
    micOn.value = true
    micPeak.value = 0
    drawWave()
    await refreshDevices()
  } catch (e) {
    micError.value = mediaErrorText(e, '麦克风')
    stopMic()
  }
}

function stopMic() {
  if (micRaf) {
    window.cancelAnimationFrame(micRaf)
    micRaf = null
  }
  if (micStream) {
    try {
      micStream.getTracks().forEach((t) => t.stop())
    } catch (e) {
      // 忽略停止异常
    }
    micStream = null
  }
  if (micCtx) {
    try {
      micCtx.close()
    } catch (e) {
      // 忽略关闭异常
    }
    micCtx = null
  }
  analyser = null
  dataArray = null
  micOn.value = false
  micLevel.value = 0
}

/* ---------- 屏幕坏点检测 ---------- */
const colors = [
  { name: '红色', value: '#ff0000', usage: '检查红色子像素是否缺失' },
  { name: '绿色', value: '#00ff00', usage: '检查绿色子像素是否缺失' },
  { name: '蓝色', value: '#0000ff', usage: '检查蓝色子像素是否缺失' },
  { name: '白色', value: '#ffffff', usage: '检查暗点、污渍与色斑' },
  { name: '黑色', value: '#000000', usage: '检查亮点与边缘漏光' },
  { name: '灰色', value: '#808080', usage: '检查均匀性与偏色' }
]
const screenTesting = ref(false)
const colorIndex = ref(0)
const autoCycle = ref(false)
const cycleSeconds = ref(3)
const fsLayer = ref(null)
const colorCanvas = ref(null)
let cycleTimer = null

function paintColor() {
  const canvas = colorCanvas.value
  if (!canvas) return
  canvas.width = window.innerWidth
  canvas.height = window.innerHeight
  const ctx = canvas.getContext('2d')
  ctx.fillStyle = colors[colorIndex.value].value
  ctx.fillRect(0, 0, canvas.width, canvas.height)
}

function nextColor() {
  colorIndex.value = (colorIndex.value + 1) % colors.length
  paintColor()
}

function prevColor() {
  colorIndex.value = (colorIndex.value - 1 + colors.length) % colors.length
  paintColor()
}

function onFsKey(e) {
  if (e.key === 'Escape') exitScreenTest()
  else if (e.key === 'ArrowRight' || e.key === ' ') nextColor()
  else if (e.key === 'ArrowLeft') prevColor()
}

function onResize() {
  if (screenTesting.value) paintColor()
}

async function enterScreenTest() {
  screenTesting.value = true
  colorIndex.value = 0
  await new Promise((resolve) => window.setTimeout(resolve, 0))
  paintColor()
  try {
    const el = fsLayer.value
    if (el && el.requestFullscreen) await el.requestFullscreen()
    else if (el && el.webkitRequestFullscreen) el.webkitRequestFullscreen()
  } catch (e) {
    // 进入全屏失败时退化为铺满窗口的浮层,功能不受影响
  }
  window.addEventListener('keydown', onFsKey)
  window.addEventListener('resize', onResize)
  if (autoCycle.value) {
    cycleTimer = window.setInterval(nextColor, Math.max(1, cycleSeconds.value) * 1000)
  }
}

function exitScreenTest() {
  if (cycleTimer) {
    window.clearInterval(cycleTimer)
    cycleTimer = null
  }
  window.removeEventListener('keydown', onFsKey)
  window.removeEventListener('resize', onResize)
  try {
    if (document.fullscreenElement && document.exitFullscreen) document.exitFullscreen()
  } catch (e) {
    // 忽略退出全屏异常
  }
  screenTesting.value = false
}

/* ---------- 屏幕常亮 ---------- */
const wakeSupported = ref(false)
const wakeUnsupportedReason = ref('')
const wakeOn = ref(false)
const wakeError = ref('')
let wakeLock = null

async function requestWake() {
  wakeError.value = ''
  if (!wakeSupported.value) return
  try {
    wakeLock = await navigator.wakeLock.request('screen')
    wakeOn.value = true
    wakeLock.addEventListener('release', () => {
      wakeOn.value = false
      wakeLock = null
    })
  } catch (e) {
    wakeOn.value = false
    wakeLock = null
    wakeError.value = '开启失败:' + (e && e.message ? e.message : String(e)) + '。设备电量过低时系统会拒绝该请求。'
  }
}

async function releaseWake() {
  if (!wakeLock) {
    wakeOn.value = false
    return
  }
  try {
    await wakeLock.release()
  } catch (e) {
    // 忽略释放异常
  }
  wakeLock = null
  wakeOn.value = false
}

async function onVisibility() {
  if (document.visibilityState === 'visible') {
    // 回到页面时若之前是开启状态则重新申请
    if (wakeOn.value && !wakeLock) await requestWake()
  } else {
    stopCamera()
    stopMic()
  }
}

/* ---------- tab 切换时释放资源 ---------- */
function onTabChange(name) {
  if (name !== 'camera') stopCamera()
  if (name !== 'mic') stopMic()
}

onMounted(() => {
  refreshDevices()
  if (typeof navigator !== 'undefined' && navigator.wakeLock && navigator.wakeLock.request) {
    wakeSupported.value = true
  } else {
    wakeSupported.value = false
    const insecure =
      typeof window !== 'undefined' &&
      window.location.protocol !== 'https:' &&
      window.location.hostname !== 'localhost' &&
      window.location.hostname !== '127.0.0.1'
    wakeUnsupportedReason.value = insecure
      ? '屏幕唤醒锁只能在 https 或 localhost 下使用,当前页面是明文访问。'
      : '未检测到 navigator.wakeLock。Firefox 与较旧版本的 Safari 暂不支持该能力,可改用 Chrome 或 Edge。'
  }
  document.addEventListener('visibilitychange', onVisibility)
})

onBeforeUnmount(() => {
  stopCamera()
  stopMic()
  exitScreenTest()
  releaseWake()
  document.removeEventListener('visibilitychange', onVisibility)
})

2.3 效果截图

硬件自检套件 效果截图

工具访问地址:https://www.i91tools.com/tools/device-test