Files
RemoteMapper-ESP32/src/web/web_ui.h
T

2164 lines
123 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
#include <Arduino.h>
const char INDEX_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RemoteMapper - 小米蓝牙遥控器硬件桥接器</title>
<style>
:root {
--bg-primary: #0b0f17;
--bg-card: #151d2a;
--bg-hover: #1e293b;
--accent-cyan: #06b6d4;
--accent-blue: #3b82f6;
--accent-green: #10b981;
--accent-orange: #f59e0b;
--accent-red: #ef4444;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--border-color: #243247;
--radius-card: 16px;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
body { background-color: var(--bg-primary); color: var(--text-main); line-height: 1.5; padding-bottom: 60px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { display: flex; justify-content: space-between; align-items: center; padding: 16px 0; border-bottom: 1px solid var(--border-color); margin-bottom: 24px; }
.logo { font-size: 24px; font-weight: 700; background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
.badge { background: rgba(6, 182, 212, 0.15); color: var(--accent-cyan); padding: 4px 10px; border-radius: 9999px; font-size: 12px; font-weight: 600; border: 1px solid rgba(6, 182, 212, 0.3); }
.tabs { display: flex; gap: 8px; margin-bottom: 20px; overflow-x: auto; padding-bottom: 4px; }
.tab-btn { background: var(--bg-card); border: 1px solid var(--border-color); color: var(--text-muted); padding: 10px 18px; border-radius: 10px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.2s; white-space: nowrap; }
.tab-btn:hover { background: var(--bg-hover); color: var(--text-main); }
.tab-btn.active { background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: #fff; border-color: transparent; box-shadow: 0 4px 12px rgba(6, 182, 212, 0.3); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-card); padding: 20px; margin-bottom: 20px; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; font-size: 16px; font-weight: 600; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-4 { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 20px; }
@media (max-width: 768px) { .grid-2 { grid-template-columns: 1fr; } }
.stat-card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 14px 18px; }
.stat-title { font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
.stat-val { font-size: 18px; font-weight: 700; color: #fff; }
/* Real Xiaomi Silver Metallic Remote Visualizer */
.remote-tester-container { display: flex; gap: 36px; align-items: flex-start; justify-content: center; flex-wrap: wrap; padding: 10px 0; }
.real-remote-body {
width: 220px;
background: linear-gradient(180deg, #d4d4d8 0%, #e4e4e7 40%, #d4d4d8 70%, #a1a1aa 100%);
border: 2px solid #e4e4e7;
border-radius: 36px;
padding: 26px 18px 20px 18px;
box-shadow: 0 20px 40px rgba(0,0,0,0.8), inset 0 2px 4px rgba(255,255,255,0.8);
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.remote-top-row { display: flex; width: 100%; justify-content: space-between; margin-bottom: 20px; }
.r-circle-btn {
width: 44px; height: 44px;
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: #d4d4d8; cursor: pointer;
transition: all 0.12s; font-size: 16px; user-select: none;
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.r-circle-btn:hover { background: #3f3f46; color: #fff; transform: translateY(-1px); }
.r-circle-btn.pressed { background: #06b6d4 !important; color: #000 !important; transform: scale(0.92) !important; box-shadow: 0 0 20px #06b6d4 !important; }
.r-circle-btn.power { background: #27272a; color: #f87171; }
.r-circle-btn.voice { background: #27272a; color: #60a5fa; }
/* D-Pad Section */
.real-dpad-ring {
width: 154px; height: 154px;
border-radius: 50%;
background: #27272a;
border: 1px solid #3f3f46;
position: relative;
margin-bottom: 22px;
display: flex; align-items: center; justify-content: center;
box-shadow: 0 6px 12px rgba(0,0,0,0.35);
}
.dpad-part { position: absolute; background: transparent; border: none; color: #71717a; cursor: pointer; font-size: 14px; transition: all 0.12s; display: flex; align-items: center; justify-content: center; }
.dpad-part:hover { color: #fff; }
.dpad-part.pressed { color: #06b6d4 !important; transform: scale(0.9); text-shadow: 0 0 12px #06b6d4; }
.d-up { top: 6px; width: 60px; height: 38px; }
.d-down { bottom: 6px; width: 60px; height: 38px; }
.d-left { left: 6px; width: 38px; height: 60px; }
.d-right { right: 6px; width: 38px; height: 60px; }
.d-center {
width: 66px; height: 66px; border-radius: 50%;
background: #18181b; border: 1px solid #3f3f46;
z-index: 2; font-size: 13px; font-weight: bold; color: #d4d4d8;
}
.d-center.pressed { background: #06b6d4 !important; color: #000 !important; box-shadow: 0 0 20px #06b6d4 !important; }
/* 2-Column Lower Controls */
.remote-controls-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; width: 100%; margin-bottom: 24px; align-items: center; }
.ctrl-col-left { display: flex; flex-direction: column; gap: 14px; align-items: center; }
.ctrl-col-right { display: flex; flex-direction: column; gap: 14px; align-items: center; }
/* Integrated Volume Rocker */
.vol-pill {
width: 48px; height: 102px;
background: #27272a;
border: 1px solid #3f3f46;
border-radius: 24px;
display: flex; flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
.vol-half {
flex: 1; border: none; background: transparent;
color: #d4d4d8; cursor: pointer;
font-size: 18px; font-weight: bold;
display: flex; align-items: center; justify-content: center;
transition: all 0.12s;
}
.vol-half:hover { background: #3f3f46; color: #fff; }
.vol-half.pressed { background: #06b6d4 !important; color: #000 !important; }
.vol-half:first-child { border-bottom: 1px solid #3f3f46; }
.btn-tv-box { width: 48px; height: 48px; border-radius: 50%; font-size: 12px; font-weight: bold; }
.remote-footer { margin-top: 10px; display: flex; flex-direction: column; align-items: center; color: #71717a; font-size: 11px; }
.remote-footer .nfc-icon { width: 14px; height: 14px; border: 1px solid #71717a; border-radius: 2px; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: bold; margin-bottom: 14px; }
/* Key Event Monitor Panel */
.event-box { flex: 1; min-width: 320px; background: #090d16; border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; }
.stat-badge { font-size: 26px; font-weight: 700; color: var(--accent-cyan); margin: 6px 0 14px 0; }
.event-field { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 14px; }
.event-field span:first-child { color: var(--text-muted); }
.event-field span:last-child { font-weight: 600; font-family: monospace; color: #fff; }
/* Ultra-Simple Interactive Remap Modal */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(6px); display: none; align-items: center; justify-content: center; z-index: 100; padding: 20px; }
.modal { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: var(--radius-card); max-width: 520px; width: 100%; padding: 24px; box-shadow: 0 25px 50px rgba(0,0,0,0.6); }
.key-recorder-box {
border: 2px dashed var(--accent-blue);
background: rgba(59, 130, 246, 0.08);
border-radius: 12px;
padding: 24px 16px;
text-align: center;
cursor: pointer;
outline: none;
transition: all 0.2s;
margin-bottom: 18px;
}
.key-recorder-box:focus, .key-recorder-box.recording {
border-color: var(--accent-cyan);
background: rgba(6, 182, 212, 0.15);
box-shadow: 0 0 20px rgba(6, 182, 212, 0.3);
}
.key-badge-display { font-size: 24px; font-weight: 800; color: #fff; margin-top: 8px; min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap; }
.kbd-chip { background: #0f172a; border: 1px solid var(--accent-cyan); color: var(--accent-cyan); padding: 4px 12px; border-radius: 6px; font-size: 18px; box-shadow: 0 2px 6px rgba(0,0,0,0.5); }
.mode-card {
cursor: pointer;
background: #0f172a;
border: 2px solid var(--border-color);
border-radius: 10px;
padding: 10px 6px;
text-align: center;
transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
}
.mode-card:hover {
border-color: #3b82f6;
background: #1e293b;
transform: translateY(-1px);
}
.mode-card.selected {
border-color: var(--accent-cyan) !important;
background: rgba(6, 182, 212, 0.18) !important;
box-shadow: 0 0 14px rgba(6, 182, 212, 0.3) !important;
}
.mode-card.selected .mode-title {
color: var(--accent-cyan) !important;
}
.trigger-tab-btn {
flex: 1;
background: transparent;
border: none;
color: var(--text-muted);
padding: 8px 10px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: all 0.15s;
}
.trigger-tab-btn:hover {
color: #fff;
}
.trigger-tab-btn.active {
background: var(--bg-card);
color: var(--accent-cyan);
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.switch-toggle {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
}
.switch-toggle input { opacity: 0; width: 0; height: 0; }
.switch-toggle .slider {
position: absolute; cursor: pointer; inset: 0;
background-color: #27272a; transition: .2s; border-radius: 24px; border: 1px solid #3f3f46;
}
.switch-toggle .slider:before {
position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px;
background-color: #d4d4d8; transition: .2s; border-radius: 50%;
}
.switch-toggle input:checked + .slider {
background-color: var(--accent-cyan); border-color: var(--accent-cyan);
}
.switch-toggle input:checked + .slider:before {
transform: translateX(20px); background-color: #000;
}
.btn { background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: #fff; border: none; padding: 10px 18px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity 0.2s; }
.btn:hover { opacity: 0.9; }
.btn-outline { background: transparent; border: 1px solid var(--border-color); color: var(--text-main); }
.btn-outline:hover { background: var(--bg-hover); }
.btn-danger { background: var(--accent-red); }
.log-terminal { background: #000; border: 1px solid #1f2937; border-radius: 8px; padding: 12px; font-family: "SFMono-Regular", Consolas, Menlo, monospace; font-size: 12px; height: 380px; overflow-y: auto; color: #34d399; line-height: 1.6; }
/* Layer Navigation & Configuration */
.layer-nav-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 16px; }
.layer-tab-card {
background: #0f172a;
border: 2px solid var(--border-color);
border-radius: 12px;
padding: 10px 14px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
}
.layer-tab-card:hover { border-color: #3b82f6; background: #151d2a; transform: translateY(-1px); }
.layer-tab-card.active { border-color: var(--accent-cyan) !important; background: rgba(6, 182, 212, 0.12) !important; box-shadow: 0 4px 16px rgba(6, 182, 212, 0.25); }
.layer-tab-card.hw-active { outline: 2px solid var(--accent-green); outline-offset: 1px; }
.layer-tab-header { display: flex; justify-content: space-between; align-items: center; }
.layer-tab-title { font-size: 14px; font-weight: 700; color: #fff; display: flex; align-items: center; gap: 6px; }
.layer-color-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; box-shadow: 0 0 6px currentColor; }
.layer-live-badge { font-size: 10px; padding: 1px 6px; border-radius: 999px; background: rgba(16, 185, 129, 0.25); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.5); font-weight: 700; display: none; }
.layer-tab-card.hw-active .layer-live-badge { display: inline-block; }
.layer-tab-desc { font-size: 11px; color: var(--text-muted); }
.layer-config-box {
background: #090d16;
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 16px 20px;
margin-bottom: 20px;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.layer-config-item { display: flex; flex-direction: column; gap: 4px; }
.layer-config-label { font-size: 11px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; }
.layer-color-swatch { width: 22px; height: 22px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: transform 0.15s; }
.layer-color-swatch:hover { transform: scale(1.15); border-color: #fff; }
/* Remote Button Layer Override Highlight */
.layer-override-highlight {
box-shadow: 0 0 12px rgba(168, 85, 247, 0.9), inset 0 0 6px rgba(168, 85, 247, 0.7) !important;
border: 2px solid #c084fc !important;
}
</style>
</head>
<body>
<div class="container">
<header>
<div style="display: flex; align-items: center; gap: 12px;">
<div class="logo">RemoteMapper</div>
<span class="badge">ESP32-S3 Hardware Bridge</span>
</div>
<div id="top-status" style="font-size: 13px; color: var(--text-muted);">正在连接硬件...</div>
</header>
<!-- System Overview Cards -->
<div class="grid-4">
<div class="stat-card">
<div class="stat-title">蓝牙遥控器连接状态</div>
<div class="stat-val" id="stat-ble-state" style="color: var(--accent-green);">已连接</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;" id="stat-ble-name">小米蓝牙语音遥控器</div>
</div>
<div class="stat-card">
<div class="stat-title">Wi-Fi 局域网 IP</div>
<div class="stat-val" id="stat-sta-ip">192.168.2.179</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">热点: 192.168.4.1</div>
</div>
<div class="stat-card">
<div class="stat-title">音频流水线状态</div>
<div class="stat-val" id="stat-audio-frames">0 帧</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;">16kHz 16-Bit Mono UAC 1.0</div>
</div>
<div class="stat-card">
<div class="stat-title">系统内存 / 运行时间</div>
<div class="stat-val" id="stat-uptime">0s</div>
<div style="font-size: 12px; color: var(--text-muted); margin-top: 2px;" id="stat-mem">SRAM: 200KB | PSRAM: 8MB</div>
</div>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('tab-tester')">遥控器与改键测试</button>
<button class="tab-btn" onclick="switchTab('tab-ble')">蓝牙配对管理</button>
<button class="tab-btn" onclick="switchTab('tab-logs')">运行日志</button>
<button class="tab-btn" onclick="switchTab('tab-nvs')">NVS 配置管理</button>
<button class="tab-btn" onclick="switchTab('tab-wifi')">Wi-Fi 与系统配置</button>
</div>
<!-- TAB 1: Key Tester & Remapper Visualizer -->
<div id="tab-tester" class="tab-content active">
<div class="card">
<div class="card-header">
<span>按键映射和层级设置</span>
<button class="btn btn-outline" style="font-size: 12px;" onclick="resetAllKeymaps()">恢复出厂默认层级</button>
</div>
<!-- 5 Layer Navigation Tabs -->
<div class="layer-nav-container" id="layer-nav-container">
<!-- Injected by renderLayerTabs() -->
</div>
<!-- Layer Properties Card -->
<div class="layer-config-box" id="layer-config-box">
<div class="layer-config-item" style="flex:1.2; min-width:160px;">
<span class="layer-config-label">层级别名</span>
<input type="text" id="layer-name-input" maxlength="20" placeholder="例如:层1"
style="background:#0b0f17; border:1px solid var(--border-color); border-radius:8px; padding:8px 12px; color:#fff; font-size:13px; outline:none;"
onchange="onLayerNameChange(this.value)">
</div>
<div class="layer-config-item" style="flex:1.4; min-width:190px;">
<span class="layer-config-label">层级生命周期类型</span>
<select id="layer-type-select"
style="background:#0b0f17; border:1px solid var(--border-color); border-radius:8px; padding:8px 12px; color:#fff; font-size:13px; outline:none;"
onchange="onLayerTypeChange(this.value)">
<option value="0">永久停留层</option>
<option value="1">一次性瞬态层</option>
<option value="2">超时自动返回层</option>
</select>
</div>
<div class="layer-config-item" id="layer-timeout-wrapper" style="display:none; flex:1; min-width:150px;">
<span class="layer-config-label">闲置回退超时(秒)</span>
<div style="display:flex; align-items:center; gap:8px;">
<input type="number" id="layer-timeout-input" min="3" max="300" value="15"
style="width:70px; background:#0b0f17; border:1px solid var(--border-color); border-radius:8px; padding:8px 10px; color:#fff; font-size:13px; outline:none;"
onchange="onLayerTimeoutChange(this.value)">
<span style="font-size:12px; color:var(--text-muted);">秒后自动返回</span>
</div>
</div>
<div class="layer-config-item" style="min-width:180px;">
<span class="layer-config-label">板载指示灯颜色</span>
<div style="display:flex; align-items:center; gap:8px;">
<input type="color" id="layer-color-picker" value="#00ff00"
style="width:36px; height:32px; border:none; border-radius:6px; cursor:pointer; background:transparent;"
onchange="onLayerColorChange(this.value)">
<div style="display:flex; gap:5px; align-items:center;">
<div class="layer-color-swatch" style="background:#00FF00;" title="绿色(默认层)" onclick="onLayerColorChange('#00FF00')"></div>
<div class="layer-color-swatch" style="background:#06B6D4;" title="青色(层1)" onclick="onLayerColorChange('#06B6D4')"></div>
<div class="layer-color-swatch" style="background:#A855F7;" title="紫色(层2)" onclick="onLayerColorChange('#A855F7')"></div>
<div class="layer-color-swatch" style="background:#EAB308;" title="黄色(层3)" onclick="onLayerColorChange('#EAB308')"></div>
<div class="layer-color-swatch" style="background:#FFFFFF;" title="白色(层4)" onclick="onLayerColorChange('#FFFFFF')"></div>
<div class="layer-color-swatch" style="background:#EF4444;" title="红色" onclick="onLayerColorChange('#EF4444')"></div>
</div>
</div>
</div>
<div style="display:flex; gap:8px; align-items:flex-end;">
<button class="btn btn-outline" id="btn-clear-layer" style="font-size:12px; display:none; color:#f87171; border-color:rgba(239,68,68,0.4);" onclick="clearLayerOverrides(currentEditingLayer)">
清空本层覆盖
</button>
<button class="btn" style="font-size:12px;" onclick="saveAllLayers()">保存层级配置</button>
</div>
</div>
<div id="layer-status-tip" style="margin-bottom:16px; padding:10px 14px; background:rgba(6,182,212,0.08); border:1px dashed rgba(6,182,212,0.3); border-radius:8px; font-size:12px; color:var(--text-muted); line-height:1.5;">
<b>默认层</b>:其他层未单独配置的按键会自动穿透继承默认层的映射。
</div>
<div class="remote-tester-container">
<!-- Real Xiaomi Silver Metallic Remote DOM -->
<div class="real-remote-body">
<!-- Top Row: Power & Voice -->
<div class="remote-top-row">
<div class="r-circle-btn power" id="btn-0x66" onclick="openRemapModal(0x66, '电源键')">⏻</div>
<div class="r-circle-btn voice" id="btn-0x04" onclick="openRemapModal(0x04, '语音键')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:middle;"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm5.3-3c0 3-2.54 5.1-5.3 5.1S6.7 14 6.7 11H5c0 3.41 2.72 6.23 6 6.72V21h2v-3.28c3.28-.48 6-3.3 6-6.72h-1.7z"/></svg></div>
</div>
<!-- Middle: D-Pad -->
<div class="real-dpad-ring">
<button class="dpad-part d-up" id="btn-0x52" onclick="openRemapModal(0x52, '方向上')">●</button>
<button class="dpad-part d-down" id="btn-0x51" onclick="openRemapModal(0x51, '方向下')">●</button>
<button class="dpad-part d-left" id="btn-0x50" onclick="openRemapModal(0x50, '方向左')">●</button>
<button class="dpad-part d-right" id="btn-0x4F" onclick="openRemapModal(0x4F, '方向右')">●</button>
<button class="dpad-part d-center" id="btn-0x28" onclick="openRemapModal(0x28, '确定键')">OK</button>
</div>
<!-- Lower: 2 Columns Matching Real Remote -->
<div class="remote-controls-grid">
<!-- Left Column: Back, Home, Menu -->
<div class="ctrl-col-left">
<div class="r-circle-btn" id="btn-0xF1" onclick="openRemapModal(0xF1, '返回键')">&lt;</div>
<div class="r-circle-btn" id="btn-0x24" onclick="openRemapModal(0x24, '主页键')">⌂</div>
<div class="r-circle-btn" id="btn-0x5D" onclick="openRemapModal(0x5D, '菜单键')">≡</div>
</div>
<!-- Right Column: Vol Rocker (+/-) & TV -->
<div class="ctrl-col-right">
<div class="vol-pill">
<button class="vol-half" id="btn-0x80" onclick="openRemapModal(0x80, '音量+')">+</button>
<button class="vol-half" id="btn-0x81" onclick="openRemapModal(0x81, '音量-')">−</button>
</div>
<div class="r-circle-btn btn-tv-box" id="btn-0xC0" onclick="openRemapModal(0xC0, '电视键')">TV</div>
</div>
</div>
<!-- Bottom Branding -->
<div class="remote-footer">
<div class="nfc-icon">N</div>
<span style="font-weight: 700; letter-spacing: 1px; font-size: 13px;">xiaomi</span>
</div>
</div>
<!-- Live Key Event Telemetry -->
<div class="event-box">
<h3 style="margin-bottom: 12px; font-size: 15px; color: var(--accent-cyan);">实时按键状态</h3>
<div class="stat-badge" id="live-key-name">等待按键...</div>
<div class="event-field">
<span>物理键码</span>
<span id="live-key-code">0x00</span>
</div>
<div class="event-field">
<span>按键状态</span>
<span id="live-key-state" style="color: var(--text-muted);">IDLE</span>
</div>
<div class="event-field">
<span>按下持续时间</span>
<span id="live-key-dur">0 ms</span>
</div>
<div class="event-field">
<span>触发动作</span>
<span id="live-act-type">ACTION_NONE</span>
</div>
<div class="event-field">
<span>注入键值</span>
<span id="live-act-val">None</span>
</div>
<div class="event-field">
<span>当前运行层级</span>
<span id="live-active-layer" style="color: var(--accent-cyan); font-weight: bold;">默认层</span>
</div>
<div style="margin-top: 20px; padding: 12px; background: rgba(6,182,212,0.1); border-radius: 8px; border: 1px dashed rgba(6,182,212,0.3); font-size: 13px;">
点击左侧按键进行按键映射调整
</div>
</div>
</div>
</div>
</div>
<!-- TAB 2: BLE Device Radar -->
<div id="tab-ble" class="tab-content">
<div class="card">
<div class="card-header">
<span>蓝牙设备配对</span>
<button class="btn" onclick="scanBleDevices()">扫描蓝牙设备</button>
</div>
<div id="ble-dev-list" style="margin-top: 14px;">点击上方按钮扫描附近的蓝牙遥控器...</div>
</div>
</div>
<!-- TAB 3: Runtime Logs -->
<div id="tab-logs" class="tab-content">
<div class="card">
<div class="card-header">
<span>实时运行日志</span>
<div style="display: flex; gap: 8px;">
<button class="btn btn-outline" style="font-size: 12px;" onclick="refreshLogs()">刷新</button>
<button class="btn btn-outline" style="font-size: 12px;" onclick="clearLogs()">清空</button>
</div>
</div>
<div class="log-terminal" id="log-terminal">正在加载运行日志...</div>
</div>
</div>
<!-- TAB 4: NVS Config Manager -->
<div id="tab-nvs" class="tab-content">
<div class="card">
<div class="card-header" style="flex-wrap: wrap; gap: 10px;">
<div>
<span style="font-size: 16px;">NVS 配置管理</span>
<div style="font-size: 12px; color: var(--text-muted); font-weight: normal; margin-top: 4px;">
以 JSON 形式统一管理存储在 Flash NVS 中的所有持久化参数。
</div>
</div>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<button class="btn btn-outline" style="font-size: 12px;" onclick="loadNvsConfig()">重新读取</button>
<button class="btn btn-outline" style="font-size: 12px;" onclick="formatNvsJson()">格式化 JSON</button>
<button class="btn btn-outline" style="font-size: 12px;" onclick="copyNvsJson()">复制文本</button>
<button class="btn btn-outline" style="font-size: 12px;" onclick="exportNvsJson()">导出备份</button>
<button class="btn btn-outline" style="font-size: 12px;" onclick="document.getElementById('nvs-file-input').click()">导入文件</button>
<input type="file" id="nvs-file-input" accept=".json" style="display:none;" onchange="importNvsJson(event)">
<button class="btn" style="font-size: 12px; background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));" onclick="saveNvsConfig()">保存写入 NVS</button>
</div>
</div>
<div style="position: relative; margin-bottom: 16px;">
<div id="nvs-json-status" style="position: absolute; top: 12px; right: 16px; font-size: 12px; z-index: 2; padding: 3px 8px; border-radius: 6px; background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.4);">
JSON 格式有效
</div>
<textarea id="nvs-editor" spellcheck="false" placeholder="正在读取 ESP32 NVS 配置..."
style="width: 100%; height: 460px; background: #070a10; color: #38bdf8; font-family: 'Fira Code', 'Consolas', 'Courier New', monospace; font-size: 13px; line-height: 1.6; padding: 16px; border: 1px solid var(--border-color); border-radius: 12px; resize: vertical; outline: none; box-shadow: inset 0 2px 8px rgba(0,0,0,0.6);"
oninput="validateNvsJson()" onkeydown="handleNvsEditorKey(event)"></textarea>
</div>
<div class="grid-2">
<div style="background: #090d16; border: 1px solid var(--border-color); border-radius: 12px; padding: 14px 18px; font-size: 13px; color: var(--text-muted); line-height: 1.6;">
<b style="color: var(--text-main); display:block; margin-bottom:6px;">NVS 命名空间说明</b>
• <code style="color:var(--accent-cyan)">wifi_conf</code>: Wi-Fi 与热点连接参数<br>
• <code style="color:var(--accent-cyan)">ble_conf</code>: 绑定的蓝牙遥控器参数<br>
• <code style="color:var(--accent-cyan)">keymap_conf</code>: 各层级按键映射规则字典<br>
• 保存后将持久化写入 Flash 中。
</div>
<div style="background: #090d16; border: 1px solid rgba(239, 68, 68, 0.3); border-radius: 12px; padding: 14px 18px; font-size: 13px; display:flex; flex-direction:column; justify-content:space-between;">
<div>
<b style="color: #f87171; display:block; margin-bottom:6px;">清空 NVS / 恢复出厂设置</b>
<span style="color: var(--text-muted);">擦除 Flash NVS 分区中的所有存储数据(清除所有 Wi-Fi、蓝牙配对和改键设置),并自动重启。</span>
</div>
<button class="btn btn-danger" style="margin-top: 12px; align-self: flex-start;" onclick="resetNvsFactory()">清空全部 NVS 并恢复出厂</button>
</div>
</div>
</div>
</div>
<!-- TAB 5: Wi-Fi & System -->
<div id="tab-wifi" class="tab-content">
<div class="grid-2">
<div class="card">
<div class="card-header">
<span>Wi-Fi 网络配置</span>
<button class="btn btn-outline" style="font-size: 12px;" onclick="scanWifiNetworks()">搜索 Wi-Fi</button>
</div>
<div id="wifi-scan-list" style="margin-bottom: 16px; font-size: 13px; color: var(--text-muted);">
点击右上角“搜索 Wi-Fi”可扫描附近无线网络,点击即可自动填入 SSID。
</div>
<div class="form-group">
<label style="display:block; font-size:13px; color:var(--text-muted); margin-bottom:6px;">Wi-Fi 名称</label>
<input type="text" id="wifi-ssid" placeholder="输入或选择 Wi-Fi 名称" style="width:100%; background:#0b0f17; border:1px solid var(--border-color); border-radius:8px; padding:10px 14px; color:#fff; font-size:14px; outline:none;">
</div>
<div class="form-group" style="margin-top:14px;">
<label style="display:block; font-size:13px; color:var(--text-muted); margin-bottom:6px;">Wi-Fi 密码</label>
<input type="password" id="wifi-pass" placeholder="输入 Wi-Fi 密码" style="width:100%; background:#0b0f17; border:1px solid var(--border-color); border-radius:8px; padding:10px 14px; color:#fff; font-size:14px; outline:none;">
</div>
<button class="btn" style="width: 100%; margin-top:16px;" onclick="saveWifi()">保存并连接 Wi-Fi</button>
</div>
<div class="card">
<div class="card-header"><span>系统控制</span></div>
<p style="font-size: 14px; color: var(--text-muted); margin-bottom: 20px;">
当前固件支持 UAC 1.0 USB 麦克风录音设备与标准 HID 键盘/多媒体复合注入。
</p>
<button class="btn btn-danger" style="width: 100%;" onclick="restartDevice()">重启设备</button>
</div>
</div>
</div>
</div>
<!-- Ultra-Simple Interactive Remap Modal -->
<div class="modal-overlay" id="remap-modal" onclick="if(event.target === this) closeRemapModal()">
<div class="modal">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px;">
<h3 style="font-size: 18px;" id="modal-title">设置按键映射</h3>
<span id="modal-vk-badge" style="font-size: 12px; color: var(--accent-cyan); font-family: monospace;">0x00</span>
</div>
<!-- Trigger Sub-Tabs (Click / Long Press / Double Click) -->
<div id="trigger-tab-bar" style="display: flex; gap: 6px; margin-bottom: 14px; background: #070a10; padding: 4px; border-radius: 10px; border: 1px solid var(--border-color);">
<button type="button" class="trigger-tab-btn active" id="tab-btn-click" onclick="switchTriggerTab('click')">单击</button>
<button type="button" class="trigger-tab-btn" id="tab-btn-long" onclick="switchTriggerTab('long')">长按</button>
<button type="button" class="trigger-tab-btn" id="tab-btn-double" onclick="switchTriggerTab('double')">双击</button>
</div>
<!-- Voice Mode Locked Card (Only for Voice Key 0x04) -->
<div id="voice-mode-locked-card" style="display:none; background:rgba(59,130,246,0.15); border:2px solid #3b82f6; border-radius:10px; padding:12px 16px; margin-bottom:14px;">
<div>
<div style="font-size:14px; font-weight:700; color:#93c5fd;">语音对讲专属模式</div>
<div style="font-size:12px; color:var(--text-muted); margin-top:2px;">按住时开启硬件麦克风录音并发送输入法热键,松开时停止录音并释放热键。</div>
</div>
</div>
<!-- Long Press Enable / Timing Header -->
<div id="long-press-header" style="display:none; background:#070a10; border:1px solid var(--border-color); border-radius:10px; padding:12px 14px; margin-bottom:14px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<b style="font-size:13px; color:#fff;">启用长按功能</b>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">按住时间达到阈值后触发长按动作</div>
</div>
<label class="switch-toggle">
<input type="checkbox" id="toggle-enable-long" onchange="onToggleTriggerEnable('long')">
<span class="slider"></span>
</label>
</div>
<div id="long-press-timing-row" style="margin-top:10px; display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:var(--text-muted); white-space:nowrap;">长按时间:</span>
<input type="range" id="slider-long-ms" min="200" max="2000" step="50" value="600" oninput="onTimingSliderChange('long', this.value)" style="flex:1;">
<span id="label-long-ms" style="font-size:12px; font-weight:bold; color:var(--accent-cyan); width:55px;">600ms</span>
</div>
</div>
<!-- Double Click Enable / Timing Header -->
<div id="double-click-header" style="display:none; background:#070a10; border:1px solid var(--border-color); border-radius:10px; padding:12px 14px; margin-bottom:14px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<b style="font-size:13px; color:#fff;">启用双击功能</b>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">在判定时间窗口内快速按两次触发双击动作</div>
</div>
<label class="switch-toggle">
<input type="checkbox" id="toggle-enable-double" onchange="onToggleTriggerEnable('double')">
<span class="slider"></span>
</label>
</div>
<div id="double-click-timing-row" style="margin-top:10px; display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:var(--text-muted); white-space:nowrap;">双击窗口:</span>
<input type="range" id="slider-double-ms" min="100" max="600" step="25" value="250" oninput="onTimingSliderChange('double', this.value)" style="flex:1;">
<span id="label-double-ms" style="font-size:12px; font-weight:bold; color:var(--accent-cyan); width:55px;">250ms</span>
</div>
</div>
<!-- Mode Selector -->
<div style="margin-bottom: 16px;">
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:6px; font-weight:600;" id="action-mode-label">触发动作模式</label>
<!-- Grid for Normal Keys (up to 5 modes) -->
<div id="mode-selector-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(88px, 1fr)); gap: 8px;">
<div class="mode-card" id="mode-card-2" onclick="selectActionMode(2)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">键盘直通</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">支持按住连发</div>
</div>
<div class="mode-card" id="mode-card-1" onclick="selectActionMode(1)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">单次点按</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">组合快捷键</div>
</div>
<div class="mode-card" id="mode-card-4" onclick="selectActionMode(4)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">多媒体控制</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">音量/播放/休眠</div>
</div>
<div class="mode-card" id="mode-card-9" onclick="selectActionMode(9)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">切换层级</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">切换目标层</div>
</div>
<div class="mode-card" id="mode-card-10" onclick="selectActionMode(10)">
<div class="mode-title" style="font-size:13px; font-weight:700; color:#fff;">穿透继承</div>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">继承默认层配置</div>
</div>
</div>
</div>
<!-- Layer Switch Target Selector Box (Mode 9) -->
<div id="layer-switch-config-box" style="display:none; background: #090d16; border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; margin-bottom: 18px;">
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:8px; font-weight:600;">选择目标层级</label>
<div id="layer-target-options" style="display:grid; grid-template-columns: repeat(auto-fit, minmax(90px, 1fr)); gap: 8px;">
<!-- Rendered dynamically -->
</div>
<div style="margin-top:12px; padding:10px 14px; background:rgba(6,182,212,0.1); border-radius:8px; border:1px dashed rgba(6,182,212,0.3); font-size:12px; color:var(--text-muted); line-height:1.5;">
若当前已在目标层,再次触发将自动返回默认层。
</div>
</div>
<!-- Layer Transparent Mode Box (Mode 10) -->
<div id="layer-trans-config-box" style="display:none; background: rgba(59,130,246,0.1); border: 1px dashed #3b82f6; border-radius: 12px; padding: 16px; margin-bottom: 18px; text-align: center;">
<div style="font-size:14px; font-weight:700; color:#93c5fd;">穿透继承模式</div>
<div style="font-size:12px; color:var(--text-muted); margin-top:4px;">
本层不单独覆盖此按键触发动作,按下时自动穿透继承默认层的对应配置。
</div>
</div>
<!-- Keyboard Direct Capture Box -->
<div class="key-recorder-box" id="key-recorder-box" tabindex="0" onclick="startKeyboardRecording()">
<div style="font-size: 13px; color: var(--text-muted);" id="recorder-instruction">
在键盘上按下任意按键或快捷键(支持 Ctrl/Alt/Win/Shift 组合键)
</div>
<div class="key-badge-display" id="recorded-badge-display">
<span style="color: var(--text-muted); font-size: 14px; font-weight: normal;">点击此处开始按键录制</span>
</div>
</div>
<!-- Advanced Manual Key Code & Quick Select Area (Always Expanded) -->
<div id="adv-config-container" style="background: #090d16; border: 1px solid var(--border-color); border-radius: 12px; padding: 16px; margin-bottom: 18px;">
<div style="font-size: 13px; font-weight: 600; color: var(--text-main); margin-bottom: 12px; display: flex; justify-content: space-between; align-items: center;">
<span>快捷选择与键码微调</span>
</div>
<!-- Quick Key Dropdown -->
<div style="margin-bottom: 12px;">
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px;">快速选择按键 / 多媒体功能</label>
<select id="quick-key-select" onchange="onQuickKeySelect(this.value)" style="width:100%; padding:8px 10px; background:#151d2a; border:1px solid #243247; color:#fff; border-radius:8px; font-size:13px; outline:none;">
<option value="">-- 选择常用按键 / 组合键 / 多媒体 --</option>
<optgroup label="常用控制键">
<option value="k:0x00:0x28">回车</option>
<option value="k:0x00:0x29">Esc</option>
<option value="k:0x00:0x2C">空格</option>
<option value="k:0x00:0x2B">Tab</option>
<option value="k:0x00:0x2A">退格</option>
<option value="k:0x00:0x4C">删除</option>
<option value="k:0x00:0x39">大写锁定</option>
<option value="k:0x00:0x46">屏幕截图</option>
</optgroup>
<optgroup label="单修饰键">
<option value="m:0x08:0x00">Win 键</option>
<option value="m:0x01:0x00">Ctrl 键</option>
<option value="m:0x04:0x00">Alt 键</option>
<option value="m:0x02:0x00">Shift 键</option>
</optgroup>
<optgroup label="方向与翻页导航">
<option value="k:0x00:0x52">方向上</option>
<option value="k:0x00:0x51">方向下</option>
<option value="k:0x00:0x50">方向左</option>
<option value="k:0x00:0x4F">方向右</option>
<option value="k:0x00:0x4B">上一页</option>
<option value="k:0x00:0x4E">下一页</option>
<option value="k:0x00:0x4A">行首</option>
<option value="k:0x00:0x4D">行尾</option>
</optgroup>
<optgroup label="功能键 F1 ~ F12">
<option value="k:0x00:0x3A">F1</option>
<option value="k:0x00:0x3B">F2</option>
<option value="k:0x00:0x3C">F3</option>
<option value="k:0x00:0x3D">F4</option>
<option value="k:0x00:0x3E">F5</option>
<option value="k:0x00:0x3F">F6</option>
<option value="k:0x00:0x40">F7</option>
<option value="k:0x00:0x41">F8</option>
<option value="k:0x00:0x42">F9</option>
<option value="k:0x00:0x43">F10</option>
<option value="k:0x00:0x44">F11</option>
<option value="k:0x00:0x45">F12</option>
</optgroup>
<optgroup label="自定义脚本专用键 F13 ~ F24">
<option value="k:0x00:0x68">F13</option>
<option value="k:0x00:0x69">F14</option>
<option value="k:0x00:0x6A">F15</option>
<option value="k:0x00:0x6B">F16</option>
<option value="k:0x00:0x6C">F17</option>
<option value="k:0x00:0x6D">F18</option>
<option value="k:0x00:0x6E">F19</option>
<option value="k:0x00:0x6F">F20</option>
<option value="k:0x00:0x70">F21</option>
<option value="k:0x00:0x71">F22</option>
<option value="k:0x00:0x72">F23</option>
<option value="k:0x00:0x73">F24</option>
</optgroup>
<optgroup label="常用快捷组合键">
<option value="k:0x04:0x36">Alt + ,</option>
<option value="k:0x08:0x0B">Win + H</option>
<option value="k:0x08:0x07">Win + D</option>
<option value="k:0x04:0x2B">Alt + Tab</option>
<option value="k:0x04:0x3D">Alt + F4</option>
<option value="k:0x01:0x06">Ctrl + C</option>
<option value="k:0x01:0x19">Ctrl + V</option>
<option value="k:0x01:0x1D">Ctrl + Z</option>
</optgroup>
<optgroup label="多媒体与系统控制" id="quick-optgroup-media">
<option value="c:0x00:0x00E9">音量增加</option>
<option value="c:0x00:0x00EA">音量减少</option>
<option value="c:0x00:0x00E2">静音</option>
<option value="c:0x00:0x00CD">播放 / 暂停</option>
<option value="c:0x00:0x00B5">下一曲</option>
<option value="c:0x00:0x00B6">上一曲</option>
<option value="c:0x00:0x00B7">停止播放</option>
<option value="c:0x00:0x0032">系统休眠</option>
<option value="c:0x00:0x0224">网页返回</option>
<option value="c:0x00:0x0223">网页主页</option>
<option value="c:0x00:0x0225">网页前进</option>
<option value="c:0x00:0x0192">打开计算器</option>
</optgroup>
</select>
</div>
<!-- Numerical Inputs -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px;">修饰键(0x01=Ctrl, 0x02=Shift, 0x04=Alt, 0x08=Win)</label>
<input type="text" id="adv-mod" value="0x00" oninput="onAdvInputChanged()" placeholder="0x00" style="width:100%; padding:8px 10px; background:#151d2a; border:1px solid #243247; color:#fff; border-radius:8px; font-size:13px; outline:none; font-family:monospace;">
</div>
<div>
<label style="display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px;">按键码(如 0x2C 或多媒体 0x00E9)</label>
<input type="text" id="adv-code" value="0x00" oninput="onAdvInputChanged()" placeholder="0x00" style="width:100%; padding:8px 10px; background:#151d2a; border:1px solid #243247; color:#fff; border-radius:8px; font-size:13px; outline:none; font-family:monospace;">
</div>
</div>
</div>
<!-- Action Buttons -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 20px; border-top: 1px solid var(--border-color); padding-top: 16px;">
<button class="btn btn-outline" style="font-size: 13px; color: var(--accent-red); border-color: rgba(239,68,68,0.3);" onclick="clearCurrentKeyBinding()">清空映射</button>
<div style="display: flex; gap: 10px;">
<button class="btn btn-outline" onclick="closeRemapModal()">取消</button>
<button class="btn" onclick="saveRemapConfig()">保存映射</button>
</div>
</div>
</div>
</div>
<script>
let currentKeymap = { active_layer: 0, layers: [] };
let currentEditingLayer = 0;
let activeHardwareLayer = 0;
let selectedTargetLayer = 1;
let editingKey = 0;
let currentTriggerTab = 'click'; // 'click' | 'long' | 'double'
let currentSelectedMode = 2; // 1: Tap, 2: Hold, 4: Consumer, 7: Voice, 9: Switch Layer, 10: Transparent
let editingBinding = null;
let clearHighlightTimer = null;
const KEY_NAMES = {
0x66: '电源键',
0xFF: '电源键',
0x04: '语音键',
0x52: '方向上',
0x51: '方向下',
0x50: '方向左',
0x4F: '方向右',
0x28: '确定键',
0xF1: '返回键',
0x24: '主页键',
0x4A: '主页键',
0x5D: '菜单键',
0x65: '菜单键',
0x80: '音量+',
0x81: '音量-',
0xC0: '电视键',
0x35: '电视键'
};
// DOM Key -> USB HID Keyboard Code Map
const DOM_TO_HID = {
'KeyA': 0x04, 'KeyB': 0x05, 'KeyC': 0x06, 'KeyD': 0x07, 'KeyE': 0x08,
'KeyF': 0x09, 'KeyG': 0x0A, 'KeyH': 0x0B, 'KeyI': 0x0C, 'KeyJ': 0x0D,
'KeyK': 0x0E, 'KeyL': 0x0F, 'KeyM': 0x10, 'KeyN': 0x11, 'KeyO': 0x12,
'KeyP': 0x13, 'KeyQ': 0x14, 'KeyR': 0x15, 'KeyS': 0x16, 'KeyT': 0x17,
'KeyU': 0x18, 'KeyV': 0x19, 'KeyW': 0x1A, 'KeyX': 0x1B, 'KeyY': 0x1C, 'KeyZ': 0x1D,
'Digit1': 0x1E, 'Digit2': 0x1F, 'Digit3': 0x20, 'Digit4': 0x21, 'Digit5': 0x22,
'Digit6': 0x23, 'Digit7': 0x24, 'Digit8': 0x25, 'Digit9': 0x26, 'Digit0': 0x27,
'Enter': 0x28, 'Escape': 0x29, 'Backspace': 0x2A, 'Tab': 0x2B, 'Space': 0x2C,
'Minus': 0x2D, 'Equal': 0x2E, 'BracketLeft': 0x2F, 'BracketRight': 0x30,
'Backslash': 0x31, 'Semicolon': 0x33, 'Quote': 0x34, 'Backquote': 0x35,
'Comma': 0x36, 'Period': 0x37, 'Slash': 0x38, 'CapsLock': 0x39,
'F1': 0x3A, 'F2': 0x3B, 'F3': 0x3C, 'F4': 0x3D, 'F5': 0x3E, 'F6': 0x3F,
'F7': 0x40, 'F8': 0x41, 'F9': 0x42, 'F10': 0x43, 'F11': 0x44, 'F12': 0x45,
'F13': 0x68, 'F14': 0x69, 'F15': 0x6A, 'F16': 0x6B, 'F17': 0x6C, 'F18': 0x6D,
'F19': 0x6E, 'F20': 0x6F, 'F21': 0x70, 'F22': 0x71, 'F23': 0x72, 'F24': 0x73,
'PrintScreen': 0x46, 'ScrollLock': 0x47, 'Pause': 0x48, 'Insert': 0x49,
'Home': 0x4A, 'PageUp': 0x4B, 'Delete': 0x4C, 'End': 0x4D, 'PageDown': 0x4E,
'ArrowRight': 0x4F, 'ArrowLeft': 0x50, 'ArrowDown': 0x51, 'ArrowUp': 0x52
};
function hexToHtmlColor(hexStr) {
if (!hexStr) return '#00ff00';
if (hexStr.startsWith('#')) return hexStr;
if (hexStr.startsWith('0x') || hexStr.startsWith('0X')) {
let h = hexStr.slice(2).padStart(6, '0');
return '#' + h;
}
let num = parseInt(hexStr);
if (!isNaN(num)) {
return '#' + (num & 0xFFFFFF).toString(16).padStart(6, '0');
}
return '#00ff00';
}
function htmlColorToHex(htmlColor) {
if (!htmlColor) return '0x00FF00';
let clean = htmlColor.replace('#', '');
return '0x' + clean.toUpperCase().padStart(6, '0');
}
function normalizeKeymapConfig() {
if (!currentKeymap) currentKeymap = {};
if (!currentKeymap.layers || !Array.isArray(currentKeymap.layers) || currentKeymap.layers.length === 0) {
const legacyBindings = currentKeymap.bindings || [];
currentKeymap.layers = [
{ id: 0, name: '默认层', type: 0, timeout: 0, color: '0x00FF00', bindings: legacyBindings },
{ id: 1, name: '层1', type: 2, timeout: 15, color: '0x06B6D4', bindings: [] },
{ id: 2, name: '层2', type: 0, timeout: 0, color: '0xA855F7', bindings: [] },
{ id: 3, name: '层3', type: 1, timeout: 0, color: '0xEAB308', bindings: [] },
{ id: 4, name: '层4', type: 0, timeout: 0, color: '0xFFFFFF', bindings: [] }
];
}
while (currentKeymap.layers.length < 5) {
const id = currentKeymap.layers.length;
const defaultNames = ['默认层', '层1', '层2', '层3', '层4'];
const defaultColors = ['0x00FF00', '0x06B6D4', '0xA855F7', '0xEAB308', '0xFFFFFF'];
currentKeymap.layers.push({
id: id,
name: defaultNames[id] || (`层${id}`),
type: (id === 1 ? 2 : (id === 3 ? 1 : 0)),
timeout: (id === 1 ? 15 : 0),
color: defaultColors[id] || '0x00FF00',
bindings: []
});
}
}
function switchTab(id) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(id).classList.add('active');
if (id === 'tab-nvs') {
loadNvsConfig();
}
}
async function fetchStatus() {
try {
const res = await fetch('/api/status');
const d = await res.json();
document.getElementById('top-status').innerHTML = `固件: ${d.version} | 运行: ${d.uptime_sec}s | IP: ${d.sta_ip}`;
document.getElementById('stat-sta-ip').innerText = d.sta_ip;
document.getElementById('stat-uptime').innerText = `${d.uptime_sec}s`;
document.getElementById('stat-audio-frames').innerText = `${d.frames_decoded} 帧`;
document.getElementById('stat-mem').innerText = `Heap: ${Math.round(d.free_heap/1024)}KB | PSRAM: ${Math.round(d.free_psram/1024/1024)}MB`;
const bleInfoRes = await fetch('/api/ble/info');
const bleInfo = await bleInfoRes.json();
if (bleInfo.connected) {
document.getElementById('stat-ble-state').innerText = '已连接';
document.getElementById('stat-ble-state').style.color = 'var(--accent-green)';
document.getElementById('stat-ble-name').innerText = bleInfo.name || '小米蓝牙语音遥控器';
} else {
document.getElementById('stat-ble-state').innerText = '扫描重连中...';
document.getElementById('stat-ble-state').style.color = 'var(--accent-orange)';
document.getElementById('stat-ble-name').innerText = bleInfo.bound_mac ? `已绑定: ${bleInfo.bound_mac}` : '未绑定遥控器';
}
} catch(e){}
}
async function fetchKeyTelemetry() {
try {
const res = await fetch('/api/keymap/telemetry');
const t = await res.json();
if (t.active_layer !== undefined && t.active_layer !== activeHardwareLayer) {
activeHardwareLayer = t.active_layer;
renderLayerTabs();
}
const curLayerObj = (currentKeymap && currentKeymap.layers) ? currentKeymap.layers[activeHardwareLayer] : null;
const layerName = curLayerObj ? curLayerObj.name : (activeHardwareLayer === 0 ? '默认层' : `层${activeHardwareLayer}`);
const layerColor = curLayerObj ? hexToHtmlColor(curLayerObj.color) : 'var(--accent-cyan)';
const activeEl = document.getElementById('live-active-layer');
if (activeEl) {
activeEl.innerText = layerName;
activeEl.style.color = layerColor;
}
if (t.source_vk && t.source_vk !== 0) {
const vk = t.source_vk;
const hexCode = '0x' + vk.toString(16).toUpperCase().padStart(2, '0');
const btnName = KEY_NAMES[vk] || `按键 ${hexCode}`;
document.getElementById('live-key-code').innerText = hexCode;
document.getElementById('live-key-state').innerText = t.is_pressed ? '按下' : '松开';
document.getElementById('live-key-state').style.color = t.is_pressed ? 'var(--accent-cyan)' : 'var(--accent-green)';
document.getElementById('live-key-dur').innerText = `${t.duration_ms || 0} ms`;
document.getElementById('live-act-type').innerText = `TYPE_${t.action_type || 0}`;
document.getElementById('live-act-val').innerText = `Key: 0x${(t.key_code||0).toString(16)} Cons: 0x${(t.consumer_code||0).toString(16)}`;
let targetHex = hexCode;
if (vk === 0xFF) targetHex = '0x66';
if (vk === 0x4A) targetHex = '0x24';
if (vk === 0x65) targetHex = '0x5D';
if (vk === 0x35) targetHex = '0xC0';
const btnEl = document.getElementById(`btn-${targetHex}`);
if (btnEl) {
document.getElementById('live-key-name').innerText = btnName;
btnEl.classList.add('pressed');
if (clearHighlightTimer) clearTimeout(clearHighlightTimer);
clearHighlightTimer = setTimeout(() => {
btnEl.classList.remove('pressed');
}, 250);
}
}
} catch(e){}
}
const FACTORY_KEYMAP = {
0x66: { source_vk: 0x66, has_click: true, click_type: 1, click_mod: 4, click_key: 43, click_cons: 0, has_long: true, long_ms: 600, long_type: 4, long_mod: 0, long_key: 0, long_cons: 0x0032, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x04: { source_vk: 0x04, has_click: true, click_type: 7, click_mod: 64, click_key: 54, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x52: { source_vk: 0x52, has_click: true, click_type: 2, click_mod: 0, click_key: 82, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x51: { source_vk: 0x51, has_click: true, click_type: 2, click_mod: 0, click_key: 81, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x50: { source_vk: 0x50, has_click: true, click_type: 2, click_mod: 0, click_key: 80, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x4F: { source_vk: 0x4F, has_click: true, click_type: 2, click_mod: 0, click_key: 79, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x28: { source_vk: 0x28, has_click: true, click_type: 2, click_mod: 0, click_key: 40, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0xF1: { source_vk: 0xF1, has_click: true, click_type: 4, click_mod: 0, click_key: 0, click_cons: 0x0224, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x24: { source_vk: 0x24, has_click: true, click_type: 1, click_mod: 8, click_key: 7, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x5D: { source_vk: 0x5D, has_click: true, click_type: 1, click_mod: 0, click_key: 44, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x80: { source_vk: 0x80, has_click: true, click_type: 4, click_mod: 0, click_key: 0, click_cons: 0x00E9, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0x81: { source_vk: 0x81, has_click: true, click_type: 4, click_mod: 0, click_key: 0, click_cons: 0x00EA, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 },
0xC0: { source_vk: 0xC0, has_click: true, click_type: 1, click_mod: 0, click_key: 65, click_cons: 0, has_long: false, long_ms: 600, long_type: 0, long_mod: 0, long_key: 0, long_cons: 0, has_double: false, double_ms: 250, double_type: 0, double_mod: 0, double_key: 0, double_cons: 0 }
};
function getActionSummaryText(type, mod, key, cons, target_layer) {
if (type === 10) return '[继承默认层]';
if (type === 9) {
const tgt = target_layer || 0;
let tgtName = tgt === 0 ? '默认层' : `层${tgt}`;
if (currentKeymap && currentKeymap.layers && currentKeymap.layers[tgt]) {
tgtName = currentKeymap.layers[tgt].name || tgtName;
}
return `[切入: ${tgtName}]`;
}
if (type === 7) return '[语音对讲录音]';
if (type === 0 || (!key && !cons && !mod)) return '未映射';
let parts = [];
if (mod & 0x01) parts.push('Ctrl');
if (mod & 0x04) parts.push('Alt');
if (mod & 0x02) parts.push('Shift');
if (mod & 0x08) parts.push('Win');
if (cons > 0) {
const consMap = {
0x00E9: '音量+', 233: '音量+', 545: '音量+',
0x00EA: '音量-', 234: '音量-', 546: '音量-',
0x00E2: '静音', 226: '静音', 547: '静音',
0x00CD: '播放/暂停', 205: '播放/暂停', 516: '播放/暂停',
0x00B5: '下一曲', 181: '下一曲', 537: '下一曲',
0x00B6: '上一曲', 182: '上一曲', 538: '上一曲',
0x00B7: '停止', 183: '停止',
0x0032: '休眠', 50: '休眠', 530: '休眠',
0x0030: '电源', 48: '电源',
0x0224: '返回', 548: '返回', 558: '返回',
0x0223: '主页', 547: '主页', 557: '主页',
0x0225: '前进', 549: '前进',
0x0192: '计算器', 402: '计算器'
};
parts.push(consMap[cons] || `多媒体 0x${cons.toString(16).toUpperCase()}`);
} else if (key > 0) {
let name = `Key(0x${key.toString(16).toUpperCase()})`;
for (let k in DOM_TO_HID) {
if (DOM_TO_HID[k] === key) {
name = k.replace('Key', '').replace('Digit', '').replace('Arrow', '');
break;
}
}
parts.push(name);
}
return parts.join('+');
}
function renderLayerTabs() {
normalizeKeymapConfig();
const container = document.getElementById('layer-nav-container');
if (!container) return;
const typeNames = ['永久层', '一次性层', '超时返回'];
let html = '';
currentKeymap.layers.forEach((l, idx) => {
const isActive = (idx === currentEditingLayer);
const isHwActive = (idx === activeHardwareLayer);
const htmlColor = hexToHtmlColor(l.color);
const typeText = (idx === 0) ? '默认层' : (l.type === 2 ? `${l.timeout || 15}s 超时` : (typeNames[l.type] || ''));
const overrideCount = (idx === 0) ? l.bindings.length : (l.bindings ? l.bindings.filter(b => (b.has_click && b.click_type !== 10) || (b.has_long && b.long_type !== 10) || (b.has_double && b.double_type !== 10)).length : 0);
const countBadge = (idx === 0) ? `${overrideCount} 基础键` : (overrideCount > 0 ? `${overrideCount} 键覆盖` : `全穿透`);
html += `
<div class="layer-tab-card ${isActive ? 'active' : ''} ${isHwActive ? 'hw-active' : ''}" onclick="switchEditingLayer(${idx})">
<div class="layer-tab-header">
<span class="layer-tab-title">
<span class="layer-color-dot" style="background-color:${htmlColor}; color:${htmlColor};"></span>
<span>${idx === 0 ? '默认层' : '层 ' + idx}</span>
</span>
<span class="layer-live-badge">运行中</span>
</div>
<div style="font-size:13px; font-weight:700; color:#fff; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">
${l.name || (idx === 0 ? '默认层' : '层' + idx)}
</div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:2px;">
<span class="layer-tab-desc">${typeText}</span>
<span style="font-size:10px; color:${overrideCount > 0 ? 'var(--accent-cyan)' : 'var(--text-muted)'}; font-weight:600;">${countBadge}</span>
</div>
</div>`;
});
container.innerHTML = html;
}
function renderLayerCard() {
normalizeKeymapConfig();
const layer = currentKeymap.layers[currentEditingLayer];
if (!layer) return;
const nameInput = document.getElementById('layer-name-input');
const typeSelect = document.getElementById('layer-type-select');
const timeoutWrapper = document.getElementById('layer-timeout-wrapper');
const timeoutInput = document.getElementById('layer-timeout-input');
const colorPicker = document.getElementById('layer-color-picker');
const clearBtn = document.getElementById('btn-clear-layer');
const tipEl = document.getElementById('layer-status-tip');
if (nameInput) {
nameInput.value = layer.name || (currentEditingLayer === 0 ? '默认层' : `层${currentEditingLayer}`);
}
if (typeSelect) {
typeSelect.value = layer.type || 0;
typeSelect.disabled = (currentEditingLayer === 0);
}
if (timeoutWrapper && timeoutInput) {
timeoutInput.value = layer.timeout || 15;
timeoutWrapper.style.display = (currentEditingLayer !== 0 && layer.type === 2) ? 'flex' : 'none';
}
if (colorPicker) {
colorPicker.value = hexToHtmlColor(layer.color);
}
if (clearBtn) {
clearBtn.style.display = (currentEditingLayer === 0) ? 'none' : 'inline-block';
}
if (tipEl) {
if (currentEditingLayer === 0) {
tipEl.innerHTML = '<b>默认层</b>:其他层未单独配置的按键会自动穿透继承默认层的映射。';
} else {
tipEl.innerHTML = `<b>当前编辑:${layer.name || ('层' + currentEditingLayer)}</b>。高亮按键为本层独立覆盖,其余按键自动穿透继承默认层。`;
}
}
}
function switchEditingLayer(idx) {
if (idx < 0 || idx >= 5) return;
currentEditingLayer = idx;
renderLayerTabs();
renderLayerCard();
updateRemoteVisualTooltips();
}
function onLayerNameChange(val) {
normalizeKeymapConfig();
const layer = currentKeymap.layers[currentEditingLayer];
if (!layer) return;
layer.name = val.trim() || (currentEditingLayer === 0 ? '默认层' : `层${currentEditingLayer}`);
renderLayerTabs();
}
function onLayerTypeChange(val) {
normalizeKeymapConfig();
const layer = currentKeymap.layers[currentEditingLayer];
if (!layer) return;
layer.type = parseInt(val);
const timeoutWrapper = document.getElementById('layer-timeout-wrapper');
if (timeoutWrapper) {
timeoutWrapper.style.display = (layer.type === 2 && currentEditingLayer !== 0) ? 'flex' : 'none';
}
renderLayerTabs();
}
function onLayerTimeoutChange(val) {
normalizeKeymapConfig();
const layer = currentKeymap.layers[currentEditingLayer];
if (!layer) return;
let sec = parseInt(val) || 15;
if (sec < 3) sec = 3;
if (sec > 300) sec = 300;
layer.timeout = sec;
const input = document.getElementById('layer-timeout-input');
if (input) input.value = sec;
renderLayerTabs();
}
function onLayerColorChange(hexColor) {
normalizeKeymapConfig();
const layer = currentKeymap.layers[currentEditingLayer];
if (!layer) return;
layer.color = htmlColorToHex(hexColor);
const picker = document.getElementById('layer-color-picker');
if (picker) picker.value = hexColor;
renderLayerTabs();
}
async function saveAllLayers() {
const ok = await saveKeymapToServer();
if (ok) {
showToast('层级配置已成功保存写入 Flash');
renderLayerTabs();
renderLayerCard();
updateRemoteVisualTooltips();
}
}
async function clearLayerOverrides(layerIdx) {
if (layerIdx === 0) return;
normalizeKeymapConfig();
const layer = currentKeymap.layers[layerIdx];
if (!layer) return;
if (!confirm(`确定要清空【${layer.name}】的所有按键覆盖吗?\n清空后该层所有按键将穿透继承默认层。`)) return;
layer.bindings = [];
const ok = await saveKeymapToServer();
if (ok) {
showToast(`【${layer.name}】已恢复穿透继承默认层`);
renderLayerTabs();
renderLayerCard();
updateRemoteVisualTooltips();
}
}
async function saveKeymapToServer() {
try {
const res = await fetch('/api/keymap/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentKeymap)
});
if (res.ok) {
return true;
} else {
showToast('保存到硬件失败', true);
return false;
}
} catch(e) {
showToast('保存请求出错: ' + e.message, true);
return false;
}
}
function updateRemoteVisualTooltips() {
normalizeKeymapConfig();
const vkList = [0x66, 0x04, 0x52, 0x51, 0x50, 0x4F, 0x28, 0xF1, 0x24, 0x5D, 0x80, 0x81, 0xC0];
const curLayer = currentKeymap.layers[currentEditingLayer] || currentKeymap.layers[0];
const layer0 = currentKeymap.layers[0];
vkList.forEach(vk => {
const hex = '0x' + vk.toString(16).toUpperCase().padStart(2, '0');
const el = document.getElementById(`btn-${hex}`);
if (!el) return;
const b0 = (layer0.bindings && layer0.bindings.find(x => x.source_vk === vk)) || FACTORY_KEYMAP[vk];
let bCur = (curLayer.bindings && curLayer.bindings.find(x => x.source_vk === vk)) || null;
const isOverride = (currentEditingLayer > 0 && bCur && (
(bCur.has_click && bCur.click_type !== 10) ||
(bCur.has_long && bCur.long_type !== 10) ||
(bCur.has_double && bCur.double_type !== 10)
));
if (isOverride) {
el.classList.add('layer-override-highlight');
} else {
el.classList.remove('layer-override-highlight');
}
const effClickType = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_type : (b0 ? b0.click_type : 0);
const effClickMod = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_mod : (b0 ? b0.click_mod : 0);
const effClickKey = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_key : (b0 ? b0.click_key : 0);
const effClickCons = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_cons : (b0 ? b0.click_cons : 0);
const effClickLayer = (bCur && bCur.has_click && bCur.click_type !== 10) ? bCur.click_layer : (b0 ? b0.click_layer : 0);
let desc = `【${KEY_NAMES[vk] || hex}】`;
if (currentEditingLayer > 0) {
desc += isOverride ? ` (本层覆盖)` : ` (继承默认层)`;
}
desc += `\n单击: ${getActionSummaryText(effClickType, effClickMod, effClickKey, effClickCons, effClickLayer)}`;
const hasLong = (bCur && bCur.has_long && bCur.long_type !== 10) ? true : (b0 ? b0.has_long : false);
if (hasLong) {
const lType = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_type : b0.long_type;
const lMod = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_mod : b0.long_mod;
const lKey = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_key : b0.long_key;
const lCons = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_cons : b0.long_cons;
const lLayer = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_layer : b0.long_layer;
const lMs = (bCur && bCur.has_long && bCur.long_type !== 10) ? bCur.long_ms : b0.long_ms;
desc += `\n长按(${lMs || 600}ms): ${getActionSummaryText(lType, lMod, lKey, lCons, lLayer)}`;
}
const hasDouble = (bCur && bCur.has_double && bCur.double_type !== 10) ? true : (b0 ? b0.has_double : false);
if (hasDouble) {
const dType = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_type : b0.double_type;
const dMod = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_mod : b0.double_mod;
const dKey = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_key : b0.double_key;
const dCons = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_cons : b0.double_cons;
const dLayer = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_layer : b0.double_layer;
const dMs = (bCur && bCur.has_double && bCur.double_type !== 10) ? bCur.double_ms : b0.double_ms;
desc += `\n双击(${dMs || 250}ms): ${getActionSummaryText(dType, dMod, dKey, dCons, dLayer)}`;
}
el.title = desc;
});
}
async function loadKeymap() {
try {
const res = await fetch('/api/keymap');
currentKeymap = await res.json();
normalizeKeymapConfig();
renderLayerTabs();
renderLayerCard();
updateRemoteVisualTooltips();
} catch(e){}
}
function parseHexOrDec(val) {
if (typeof val === 'number') return val;
if (!val) return 0;
val = String(val).trim();
if (val.startsWith('0x') || val.startsWith('0X')) {
return parseInt(val, 16) || 0;
}
if (/^[0-9a-fA-F]+$/.test(val) && /[a-fA-F]/.test(val)) {
return parseInt(val, 16) || 0;
}
return parseInt(val, 10) || 0;
}
function renderTargetLayerButtons() {
const container = document.getElementById('layer-target-options');
if (!container) return;
normalizeKeymapConfig();
let html = '';
currentKeymap.layers.forEach((l, idx) => {
const isSel = (idx === selectedTargetLayer);
html += `
<button type="button" class="target-layer-btn" data-layer="${idx}"
style="padding:8px 6px; border-radius:10px; cursor:pointer; font-weight:700; font-size:12px; transition:all 0.15s; background:${isSel ? 'rgba(6,182,212,0.2)' : '#151d2a'}; border:2px solid ${isSel ? 'var(--accent-cyan)' : 'var(--border-color)'}; color:${isSel ? 'var(--accent-cyan)' : '#fff'}; display:flex; flex-direction:column; align-items:center; gap:2px;"
onclick="selectTargetLayer(${idx})">
<span>${idx === 0 ? '默认层' : '层 ' + idx}</span>
<span style="font-size:10px; color:var(--text-muted); font-weight:normal; max-width:70px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${l.name || ''}</span>
</button>`;
});
container.innerHTML = html;
}
function selectTargetLayer(layerIdx) {
selectedTargetLayer = layerIdx;
renderTargetLayerButtons();
renderTriggerView(9, 0, 0, 0);
}
function saveActiveTabToBinding() {
if (!editingBinding) return;
const mod = parseHexOrDec(document.getElementById('adv-mod').value);
const code = parseHexOrDec(document.getElementById('adv-code').value);
const mode = currentSelectedMode;
const longToggle = document.getElementById('toggle-enable-long');
if (longToggle) editingBinding.has_long = longToggle.checked;
const longSlider = document.getElementById('slider-long-ms');
if (longSlider) editingBinding.long_ms = parseInt(longSlider.value) || 600;
const doubleToggle = document.getElementById('toggle-enable-double');
if (doubleToggle) editingBinding.has_double = doubleToggle.checked;
const doubleSlider = document.getElementById('slider-double-ms');
if (doubleSlider) editingBinding.double_ms = parseInt(doubleSlider.value) || 250;
if (currentTriggerTab === 'click') {
editingBinding.click_type = mode;
if (mode === 9) {
editingBinding.click_layer = selectedTargetLayer;
editingBinding.click_key = 0; editingBinding.click_mod = 0; editingBinding.click_cons = 0;
editingBinding.has_click = true;
} else if (mode === 10) {
editingBinding.click_key = 0; editingBinding.click_mod = 0; editingBinding.click_cons = 0;
editingBinding.has_click = true;
} else if (mode === 4) {
editingBinding.click_cons = code;
editingBinding.click_key = 0; editingBinding.click_mod = 0;
editingBinding.has_click = (code > 0);
} else {
editingBinding.click_key = code;
editingBinding.click_mod = mod;
editingBinding.click_cons = 0;
editingBinding.has_click = (mode !== 0 && (code > 0 || mod > 0));
}
} else if (currentTriggerTab === 'long') {
editingBinding.long_type = mode;
if (mode === 9) {
editingBinding.long_layer = selectedTargetLayer;
editingBinding.long_key = 0; editingBinding.long_mod = 0; editingBinding.long_cons = 0;
} else if (mode === 10) {
editingBinding.long_key = 0; editingBinding.long_mod = 0; editingBinding.long_cons = 0;
} else if (mode === 4) {
editingBinding.long_cons = code;
editingBinding.long_key = 0; editingBinding.long_mod = 0;
} else {
editingBinding.long_key = code;
editingBinding.long_mod = mod;
editingBinding.long_cons = 0;
}
} else if (currentTriggerTab === 'double') {
editingBinding.double_type = mode;
if (mode === 9) {
editingBinding.double_layer = selectedTargetLayer;
editingBinding.double_key = 0; editingBinding.double_mod = 0; editingBinding.double_cons = 0;
} else if (mode === 10) {
editingBinding.double_key = 0; editingBinding.double_mod = 0; editingBinding.double_cons = 0;
} else if (mode === 4) {
editingBinding.double_cons = code;
editingBinding.double_key = 0; editingBinding.double_mod = 0;
} else {
editingBinding.double_key = code;
editingBinding.double_mod = mod;
editingBinding.double_cons = 0;
}
}
}
function loadTriggerDataToUI(tab) {
document.querySelectorAll('.trigger-tab-btn').forEach(b => b.classList.remove('active'));
const activeBtn = document.getElementById(`tab-btn-${tab}`);
if (activeBtn) activeBtn.classList.add('active');
const longHeader = document.getElementById('long-press-header');
const doubleHeader = document.getElementById('double-click-header');
const actionLabel = document.getElementById('action-mode-label');
let mode = 2, mod = 0, key = 0, cons = 0, target_layer = 1;
if (tab === 'click') {
longHeader.style.display = 'none';
doubleHeader.style.display = 'none';
actionLabel.innerText = '单击触发动作模式';
mode = editingBinding.click_type !== undefined ? editingBinding.click_type : 2;
mod = editingBinding.click_mod || 0;
key = editingBinding.click_key || 0;
cons = editingBinding.click_cons || 0;
target_layer = editingBinding.click_layer !== undefined ? editingBinding.click_layer : (currentEditingLayer === 1 ? 0 : 1);
} else if (tab === 'long') {
longHeader.style.display = 'block';
doubleHeader.style.display = 'none';
actionLabel.innerText = '长按触发动作模式';
document.getElementById('toggle-enable-long').checked = !!editingBinding.has_long;
document.getElementById('slider-long-ms').value = editingBinding.long_ms || 600;
document.getElementById('label-long-ms').innerText = `${editingBinding.long_ms || 600}ms`;
mode = editingBinding.long_type !== undefined ? editingBinding.long_type : 1;
mod = editingBinding.long_mod || 0;
key = editingBinding.long_key || 0;
cons = editingBinding.long_cons || 0;
target_layer = editingBinding.long_layer !== undefined ? editingBinding.long_layer : (currentEditingLayer === 1 ? 0 : 1);
} else if (tab === 'double') {
longHeader.style.display = 'none';
doubleHeader.style.display = 'block';
actionLabel.innerText = '双击触发动作模式';
document.getElementById('toggle-enable-double').checked = !!editingBinding.has_double;
document.getElementById('slider-double-ms').value = editingBinding.double_ms || 250;
document.getElementById('label-double-ms').innerText = `${editingBinding.double_ms || 250}ms`;
mode = editingBinding.double_type !== undefined ? editingBinding.double_type : 1;
mod = editingBinding.double_mod || 0;
key = editingBinding.double_key || 0;
cons = editingBinding.double_cons || 0;
target_layer = editingBinding.double_layer !== undefined ? editingBinding.double_layer : (currentEditingLayer === 1 ? 0 : 1);
}
selectedTargetLayer = target_layer;
selectActionMode(mode);
renderTriggerView(mode, mod, key, cons);
renderTargetLayerButtons();
document.getElementById('quick-key-select').value = '';
if (mode === 1 || mode === 2 || mode === 7) {
startKeyboardRecording();
}
}
function switchTriggerTab(tab) {
saveActiveTabToBinding();
currentTriggerTab = tab;
loadTriggerDataToUI(tab);
}
function selectActionMode(mode) {
currentSelectedMode = mode;
const inst = document.getElementById('recorder-instruction');
const quickSelect = document.getElementById('quick-key-select');
const recorderBox = document.getElementById('key-recorder-box');
const advContainer = document.getElementById('adv-config-container');
const layerBox = document.getElementById('layer-switch-config-box');
const transBox = document.getElementById('layer-trans-config-box');
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('selected'));
const card = document.getElementById(`mode-card-${mode}`);
if (card) card.classList.add('selected');
if (mode === 9) {
if (recorderBox) recorderBox.style.display = 'none';
if (advContainer) advContainer.style.display = 'none';
if (layerBox) layerBox.style.display = 'block';
if (transBox) transBox.style.display = 'none';
renderTargetLayerButtons();
} else if (mode === 10) {
if (recorderBox) recorderBox.style.display = 'none';
if (advContainer) advContainer.style.display = 'none';
if (layerBox) layerBox.style.display = 'none';
if (transBox) transBox.style.display = 'block';
} else {
if (recorderBox) recorderBox.style.display = 'block';
if (advContainer) advContainer.style.display = 'block';
if (layerBox) layerBox.style.display = 'none';
if (transBox) transBox.style.display = 'none';
let curMod = parseInt(document.getElementById('adv-mod').value) || 0;
let curCode = parseInt(document.getElementById('adv-code').value) || 0;
if (mode === 4) {
if (inst) inst.innerHTML = '<b>多媒体控制模式</b>:在下方下拉框中选择具体的控制功能';
let curCons = curCode >= 500 ? curCode : 545;
renderTriggerView(4, 0, 0, curCons);
if (quickSelect) quickSelect.value = `c:0:${curCons}`;
} else if (mode === 1) {
if (inst) inst.innerHTML = '<b>单次点按模式</b>:按下按键发送一次按键或组合快捷键(敲击键盘直接录制)';
let curKey = (curCode > 0 && curCode < 500) ? curCode : 0x28;
renderTriggerView(1, curMod, curKey, 0);
startKeyboardRecording();
} else if (mode === 2) {
if (inst) inst.innerHTML = '<b>键盘直通模式</b>:按住按键时持续发送(敲击键盘直接录制)';
let curKey = (curCode > 0 && curCode < 500) ? curCode : 0x2C;
renderTriggerView(2, curMod, curKey, 0);
startKeyboardRecording();
} else if (mode === 7) {
if (inst) inst.innerHTML = '<b>语音按键快捷键</b>:敲击键盘录制录音时发送的快捷键(如 Alt+, 或 Win+H)';
let curKey = (curCode > 0 && curCode < 500) ? curCode : 54;
renderTriggerView(7, curMod || 64, curKey, 0);
startKeyboardRecording();
}
}
}
function renderTriggerView(type, mod, key, cons) {
const isVoice = (editingKey === 0x04 || editingKey === 0x3E);
if (isVoice && currentEditingLayer === 0) {
type = 7;
cons = 0;
} else if (type === 4 || cons > 0) {
type = 4;
key = 0;
mod = 0;
} else if (type === 9) {
key = 0;
mod = 0;
cons = 0;
} else if (type === 10) {
key = 0;
mod = 0;
cons = 0;
}
currentSelectedMode = type;
document.querySelectorAll('.mode-card').forEach(c => c.classList.remove('selected'));
const card = document.getElementById(`mode-card-${type}`);
if (card) card.classList.add('selected');
document.getElementById('adv-mod').value = '0x' + (mod || 0).toString(16).toUpperCase().padStart(2, '0');
const codeVal = (key || cons || 0);
document.getElementById('adv-code').value = '0x' + codeVal.toString(16).toUpperCase().padStart(codeVal > 255 ? 4 : 2, '0');
const display = document.getElementById('recorded-badge-display');
if (!display) return;
if (type === 9) {
const tgt = selectedTargetLayer || 0;
let tgtName = tgt === 0 ? '默认层' : `层${tgt}`;
if (currentKeymap && currentKeymap.layers && currentKeymap.layers[tgt]) {
tgtName = currentKeymap.layers[tgt].name || tgtName;
}
display.innerHTML = `<span class="kbd-chip" style="border-color:var(--accent-cyan); color:var(--accent-cyan);">切入 ${tgtName}</span>`;
return;
}
if (type === 10) {
display.innerHTML = `<span class="kbd-chip" style="border-color:#93c5fd; color:#93c5fd;">继承默认层</span>`;
return;
}
if (type === 0 || (!key && !cons && !mod)) {
display.innerHTML = '<span style="color: var(--text-muted); font-size: 14px; font-weight: normal;">未设置(敲击键盘录制,或在下方选择)</span>';
return;
}
let chips = [];
if (mod & 0x01) chips.push('Ctrl');
if (mod & 0x04) chips.push('Alt');
if (mod & 0x02) chips.push('Shift');
if (mod & 0x08) chips.push('Win');
if (cons > 0) {
const consMap = {
0x00E9: '音量+', 233: '音量+', 545: '音量+',
0x00EA: '音量-', 234: '音量-', 546: '音量-',
0x00E2: '静音', 226: '静音', 547: '静音',
0x00CD: '播放/暂停', 205: '播放/暂停', 516: '播放/暂停',
0x00B5: '下一曲', 181: '下一曲', 537: '下一曲',
0x00B6: '上一曲', 182: '上一曲', 538: '上一曲',
0x00B7: '停止', 183: '停止',
0x0032: '休眠', 50: '休眠', 530: '休眠',
0x0030: '电源', 48: '电源',
0x0224: '返回', 548: '返回', 558: '返回',
0x0223: '主页', 547: '主页', 557: '主页',
0x0225: '前进', 549: '前进',
0x0192: '计算器', 402: '计算器'
};
chips.push(consMap[cons] || `多媒体 0x${cons.toString(16).toUpperCase()}`);
} else if (key > 0) {
let keyName = `Key(0x${key.toString(16).toUpperCase()})`;
for (let k in DOM_TO_HID) {
if (DOM_TO_HID[k] === key) {
keyName = k.replace('Key', '').replace('Digit', '').replace('Arrow', '');
break;
}
}
chips.push(keyName);
}
display.innerHTML = chips.map(c => `<span class="kbd-chip">${c}</span>`).join(' + ');
}
function onToggleTriggerEnable(trigger) {
if (!editingBinding) return;
if (trigger === 'long') {
editingBinding.has_long = document.getElementById('toggle-enable-long').checked;
showToast(editingBinding.has_long ? '已启用长按动作' : '已关闭长按动作');
} else if (trigger === 'double') {
editingBinding.has_double = document.getElementById('toggle-enable-double').checked;
showToast(editingBinding.has_double ? '已启用双击动作' : '已关闭双击动作');
}
}
function onTimingSliderChange(trigger, val) {
if (!editingBinding) return;
if (trigger === 'long') {
editingBinding.long_ms = parseInt(val);
document.getElementById('label-long-ms').innerText = `${val}ms`;
} else if (trigger === 'double') {
editingBinding.double_ms = parseInt(val);
document.getElementById('label-double-ms').innerText = `${val}ms`;
}
}
async function openRemapModal(keyVk, keyName) {
editingKey = keyVk;
normalizeKeymapConfig();
const curLayer = currentKeymap.layers[currentEditingLayer];
const isVoice = (keyVk === 0x04 || keyVk === 0x3E);
let searchVk = keyVk;
if (keyVk === 0xFF) searchVk = 0x66;
if (keyVk === 0x4A) searchVk = 0x24;
if (keyVk === 0x65) searchVk = 0x5D;
if (keyVk === 0x35) searchVk = 0xC0;
if (keyVk === 0x3E) searchVk = 0x04;
const found = curLayer.bindings && curLayer.bindings.find(x => x.source_vk === searchVk);
if (found) {
editingBinding = JSON.parse(JSON.stringify(found));
} else if (currentEditingLayer === 0 && FACTORY_KEYMAP[searchVk]) {
editingBinding = JSON.parse(JSON.stringify(FACTORY_KEYMAP[searchVk]));
} else if (currentEditingLayer > 0) {
// Default to transparent on Layer 1~4
editingBinding = {
source_vk: searchVk,
has_click: true, click_type: 10, click_mod: 0, click_key: 0, click_cons: 0, click_layer: 0,
has_long: false, long_ms: 600, long_type: 10, long_mod: 0, long_key: 0, long_cons: 0, long_layer: 0,
has_double: false, double_ms: 250, double_type: 10, double_mod: 0, double_key: 0, double_cons: 0, double_layer: 0
};
} else {
editingBinding = {
source_vk: searchVk,
has_click: true, click_type: 2, click_mod: 0, click_key: 0, click_cons: 0, click_layer: 0,
has_long: false, long_ms: 600, long_type: 1, long_mod: 0, long_key: 0, long_cons: 0, long_layer: 0,
has_double: false, double_ms: 250, double_type: 1, double_mod: 0, double_key: 0, double_cons: 0, double_layer: 0
};
}
// Show / hide transparent mode card based on whether this is Layer 0
const transCard = document.getElementById('mode-card-10');
if (transCard) {
transCard.style.display = (currentEditingLayer === 0) ? 'none' : 'block';
}
// Sync switches and sliders before tab loads
const longToggle = document.getElementById('toggle-enable-long');
if (longToggle) longToggle.checked = !!editingBinding.has_long;
const longSlider = document.getElementById('slider-long-ms');
if (longSlider) longSlider.value = editingBinding.long_ms || 600;
const longLabel = document.getElementById('label-long-ms');
if (longLabel) longLabel.innerText = `${editingBinding.long_ms || 600}ms`;
const doubleToggle = document.getElementById('toggle-enable-double');
if (doubleToggle) doubleToggle.checked = !!editingBinding.has_double;
const doubleSlider = document.getElementById('slider-double-ms');
if (doubleSlider) doubleSlider.value = editingBinding.double_ms || 250;
const doubleLabel = document.getElementById('label-double-ms');
if (doubleLabel) doubleLabel.innerText = `${editingBinding.double_ms || 250}ms`;
const layerPrefix = currentEditingLayer === 0 ? '' : `【${curLayer.name || ('层' + currentEditingLayer)}】`;
document.getElementById('modal-title').innerText = (isVoice && currentEditingLayer === 0) ? '设置语音键快捷键' : `${layerPrefix}设置 ${keyName} 映射`;
document.getElementById('modal-vk-badge').innerText = '0x' + searchVk.toString(16).toUpperCase().padStart(2, '0');
document.getElementById('quick-optgroup-media').style.display = (isVoice && currentEditingLayer === 0) ? 'none' : 'block';
if (isVoice && currentEditingLayer === 0) {
document.getElementById('trigger-tab-bar').style.display = 'none';
document.getElementById('mode-selector-grid').style.display = 'none';
document.getElementById('voice-mode-locked-card').style.display = 'block';
document.getElementById('long-press-header').style.display = 'none';
document.getElementById('double-click-header').style.display = 'none';
currentTriggerTab = 'click';
selectActionMode(7);
renderTriggerView(7, editingBinding.click_mod || 64, editingBinding.click_key || 54, 0);
} else {
document.getElementById('trigger-tab-bar').style.display = 'flex';
document.getElementById('mode-selector-grid').style.display = 'grid';
document.getElementById('voice-mode-locked-card').style.display = 'none';
currentTriggerTab = 'click';
loadTriggerDataToUI('click');
}
document.getElementById('quick-key-select').value = '';
document.getElementById('remap-modal').style.display = 'flex';
}
function closeRemapModal() {
document.getElementById('remap-modal').style.display = 'none';
}
function onQuickKeySelect(val) {
if (!val) return;
const parts = val.split(':');
const prefix = parts[0];
const mod = parseHexOrDec(parts[1]);
const code = parseHexOrDec(parts[2]);
const isVoice = (editingKey === 0x04 || editingKey === 0x3E);
if (prefix === 'c') {
if (isVoice && currentEditingLayer === 0) {
showToast('语音键专用于语音录音与呼出快捷键,不可设为多媒体键', true);
document.getElementById('quick-key-select').value = '';
return;
}
selectActionMode(4);
renderTriggerView(4, 0, 0, code);
} else if (prefix === 'm') {
if (currentSelectedMode === 4 || currentSelectedMode === 9 || currentSelectedMode === 10) selectActionMode(2);
renderTriggerView(currentSelectedMode, mod, 0, 0);
} else if (prefix === 'k') {
if (currentSelectedMode === 4 || currentSelectedMode === 9 || currentSelectedMode === 10) selectActionMode(2);
renderTriggerView(currentSelectedMode, mod, code, 0);
}
}
function onAdvInputChanged() {
const mod = parseHexOrDec(document.getElementById('adv-mod').value);
const code = parseHexOrDec(document.getElementById('adv-code').value);
const isVoice = (editingKey === 0x04 || editingKey === 0x3E);
if (isVoice && currentEditingLayer === 0) {
renderTriggerView(7, mod, code, 0);
} else if (currentSelectedMode === 4 || code >= 500) {
renderTriggerView(4, 0, 0, code);
} else if (currentSelectedMode === 9 || currentSelectedMode === 10) {
renderTriggerView(currentSelectedMode, 0, 0, 0);
} else {
renderTriggerView(currentSelectedMode, mod, code, 0);
}
}
function startKeyboardRecording() {
const box = document.getElementById('key-recorder-box');
if (box) {
box.focus();
box.classList.add('recording');
}
}
// Global Keyboard Event Capturer for Ultra-Intuitive Remapping
window.addEventListener('keydown', function(e) {
const modal = document.getElementById('remap-modal');
if (modal.style.display !== 'flex') return;
// If user is in layer switch or transparent mode, ignore keypresses
if (currentSelectedMode === 9 || currentSelectedMode === 10) return;
// If user is typing in advanced numeric inputs, let it through
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
e.preventDefault();
e.stopPropagation();
// Ignore standalone modifier presses (wait for actual key)
if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return;
let mod = 0;
if (e.ctrlKey) mod |= 0x01; // LCTRL
if (e.shiftKey) mod |= 0x02; // LSHIFT
if (e.altKey) mod |= 0x04; // LALT
if (e.metaKey) mod |= 0x08; // LGUI (Win)
const hidCode = DOM_TO_HID[e.code] || 0;
const isVoice = (editingKey === 0x04 || editingKey === 0x3E);
if (hidCode > 0) {
if (!(isVoice && currentEditingLayer === 0) && currentSelectedMode === 4) {
selectActionMode(2); // Auto switch from media to hold on keyboard input
}
renderTriggerView(currentSelectedMode, mod, hidCode, 0);
}
});
function clearCurrentKeyBinding() {
if (currentTriggerTab === 'click') {
if (currentEditingLayer > 0) {
// Set to transparent
selectActionMode(10);
renderTriggerView(10, 0, 0, 0);
if (editingBinding) {
editingBinding.click_type = 10;
editingBinding.has_click = true;
}
showToast('已将单击设置为继承默认层');
} else {
renderTriggerView(0, 0, 0, 0);
if (editingBinding) editingBinding.has_click = false;
showToast('已清空单击动作配置');
}
} else if (currentTriggerTab === 'long') {
document.getElementById('toggle-enable-long').checked = false;
if (editingBinding) editingBinding.has_long = false;
renderTriggerView(0, 0, 0, 0);
showToast('已关闭长按动作配置');
} else if (currentTriggerTab === 'double') {
document.getElementById('toggle-enable-double').checked = false;
if (editingBinding) editingBinding.has_double = false;
renderTriggerView(0, 0, 0, 0);
showToast('已关闭双击动作配置');
}
document.getElementById('quick-key-select').value = '';
}
async function saveRemapConfig() {
saveActiveTabToBinding();
normalizeKeymapConfig();
const curLayer = currentKeymap.layers[currentEditingLayer];
if (!curLayer.bindings) curLayer.bindings = [];
const isVoice = (editingKey === 0x04 || editingKey === 0x3E);
if (isVoice && currentEditingLayer === 0) {
editingBinding.source_vk = 0x04;
editingBinding.has_click = true;
editingBinding.click_type = 7;
editingBinding.has_long = false;
editingBinding.has_double = false;
}
// Sparse storage cleanup: if on Layer 1~4 and binding is completely transparent or disabled, remove it
let isAllTransparent = false;
if (currentEditingLayer > 0) {
isAllTransparent =
(!editingBinding.has_click || editingBinding.click_type === 10 || editingBinding.click_type === 0) &&
(!editingBinding.has_long || editingBinding.long_type === 10 || editingBinding.long_type === 0) &&
(!editingBinding.has_double || editingBinding.double_type === 10 || editingBinding.double_type === 0);
}
const idx = curLayer.bindings.findIndex(x => x.source_vk === editingBinding.source_vk);
if (isAllTransparent) {
if (idx >= 0) curLayer.bindings.splice(idx, 1);
} else {
if (idx >= 0) {
curLayer.bindings[idx] = editingBinding;
} else {
curLayer.bindings.push(editingBinding);
}
}
const ok = await saveKeymapToServer();
if (ok) {
closeRemapModal();
showToast(`按键映射已保存至【${curLayer.name || (currentEditingLayer === 0 ? '默认层' : '层' + currentEditingLayer)}】`);
renderLayerTabs();
updateRemoteVisualTooltips();
}
}
async function resetAllKeymaps() {
if (!confirm('确定要将所有层级的按键映射与属性恢复为默认值吗?')) return;
await fetch('/api/keymap/reset', { method: 'POST' });
await loadKeymap();
showToast('已恢复出厂默认层级映射');
}
async function scanBleDevices() {
const container = document.getElementById('ble-dev-list');
container.innerHTML = '正在扫描周围蓝牙设备 (4秒)...';
try {
const res = await fetch('/api/ble/scan');
const d = await res.json();
if (!d.devices || d.devices.length === 0) {
container.innerHTML = '<div style="color:var(--text-muted);">未发现附近设备,请确保遥控器处于配对广播状态。</div>';
return;
}
let html = '<div style="display:grid; gap:10px;">';
d.devices.forEach(dev => {
html += `<div style="display:flex; justify-content:space-between; align-items:center; background:#0b0f17; padding:12px; border-radius:8px; border:1px solid #243247;">
<div><b>${dev.name}</b> <span style="font-size:12px; color:var(--text-muted); font-family:monospace;">(${dev.mac}) RSSI: ${dev.rssi}dBm</span></div>
<button class="btn" style="padding:6px 14px; font-size:12px;" onclick="connectMac('${dev.mac}')">连接</button>
</div>`;
});
html += '</div>';
container.innerHTML = html;
} catch(e){ container.innerText = '扫描出错: ' + e; }
}
async function connectMac(mac) {
const res = await fetch('/api/ble/connect', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mac }) });
alert('正在连接目标蓝牙遥控器,请查看运行日志...');
}
async function scanWifiNetworks() {
const list = document.getElementById('wifi-scan-list');
list.innerHTML = '正在搜索周围 2.4GHz Wi-Fi 网络...';
try {
const res = await fetch('/api/wifi/scan');
const d = await res.json();
if (!d.networks || d.networks.length === 0) {
list.innerHTML = '未扫描到无线网络';
return;
}
let html = '<div style="display:flex; flex-wrap:wrap; gap:8px; margin-top:8px;">';
d.networks.forEach(net => {
if (net.ssid) {
html += `<button class="btn btn-outline" style="font-size:12px; padding:6px 12px;" onclick="selectWifi('${net.ssid}')">${net.ssid} (${net.rssi}dBm)</button>`;
}
});
html += '</div>';
list.innerHTML = html;
} catch(e){ list.innerText = '搜索出错: ' + e; }
}
function selectWifi(ssid) {
document.getElementById('wifi-ssid').value = ssid;
document.getElementById('wifi-pass').focus();
}
async function refreshLogs() {
try {
const res = await fetch('/api/logs');
const d = await res.json();
const terminal = document.getElementById('log-terminal');
terminal.innerText = d.logs.join('\n');
terminal.scrollTop = terminal.scrollHeight;
} catch(e){}
}
async function clearLogs() {
await fetch('/api/logs/clear', { method: 'POST' });
refreshLogs();
}
async function saveWifi() {
const ssid = document.getElementById('wifi-ssid').value;
const pass = document.getElementById('wifi-pass').value;
if (!ssid) return alert('请输入 Wi-Fi 名称');
await fetch('/api/wifi/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ssid, pass }) });
alert('Wi-Fi 配置已保存,ESP32 正在尝试连接!');
}
async function restartDevice() {
if (!confirm('确定要重启 ESP32-S3 设备吗?')) return;
await fetch('/api/system/restart', { method: 'POST' });
alert('正在重启...');
}
function showToast(msg, isError = false) {
let toast = document.getElementById('app-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'app-toast';
toast.style.cssText = 'position:fixed; bottom:24px; left:50%; transform:translateX(-50%); padding:10px 20px; border-radius:10px; font-size:14px; font-weight:600; color:#fff; z-index:9999; box-shadow:0 8px 24px rgba(0,0,0,0.5); transition:opacity 0.3s; pointer-events:none;';
document.body.appendChild(toast);
}
toast.style.background = isError ? '#ef4444' : '#10b981';
toast.innerText = msg;
toast.style.opacity = '1';
clearTimeout(window.__toastTimer);
window.__toastTimer = setTimeout(() => {
toast.style.opacity = '0';
}, 3000);
}
async function loadNvsConfig() {
try {
const res = await fetch('/api/nvs');
const json = await res.json();
const editor = document.getElementById('nvs-editor');
editor.value = JSON.stringify(json, null, 2);
validateNvsJson();
showToast('NVS 配置已从 Flash 加载');
} catch(e) {
showToast('读取 NVS 失败: ' + e.message, true);
}
}
function validateNvsJson() {
const editor = document.getElementById('nvs-editor');
const badge = document.getElementById('nvs-json-status');
if (!editor || !badge) return false;
try {
JSON.parse(editor.value);
badge.innerHTML = 'JSON 格式有效';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
badge.style.borderColor = 'rgba(16,185,129,0.4)';
return true;
} catch(e) {
badge.innerHTML = e.message;
badge.style.background = 'rgba(239,68,68,0.2)';
badge.style.color = '#f87171';
badge.style.borderColor = 'rgba(239,68,68,0.4)';
return false;
}
}
function formatNvsJson() {
const editor = document.getElementById('nvs-editor');
try {
const obj = JSON.parse(editor.value);
editor.value = JSON.stringify(obj, null, 2);
validateNvsJson();
showToast('JSON 格式化完成');
} catch(e) {
showToast('无法格式化,当前 JSON 存在语法错误: ' + e.message, true);
}
}
function copyNvsJson() {
const editor = document.getElementById('nvs-editor');
navigator.clipboard.writeText(editor.value).then(() => {
showToast('已复制 NVS JSON 到剪贴板');
}).catch(() => {
editor.select();
document.execCommand('copy');
showToast('已复制到剪贴板');
});
}
function exportNvsJson() {
const editor = document.getElementById('nvs-editor');
const blob = new Blob([editor.value], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const timeStr = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
a.href = url;
a.download = `RemoteMapper_NVS_Backup_${timeStr}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast('NVS 备份文件已导出下载');
}
function importNvsJson(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
try {
const obj = JSON.parse(evt.target.result);
document.getElementById('nvs-editor').value = JSON.stringify(obj, null, 2);
validateNvsJson();
showToast('文件导入成功,请点击“保存写入 NVS”生效');
} catch(err) {
showToast('导入文件解析失败: ' + err.message, true);
}
};
reader.readAsText(file);
e.target.value = '';
}
async function saveNvsConfig() {
const editor = document.getElementById('nvs-editor');
if (!validateNvsJson()) {
showToast('当前 JSON 存在语法错误,无法保存!', true);
return;
}
if (!confirm('确定要将当前编辑的 JSON 写入 ESP32 Flash NVS 吗?\n写入后参数将立即更新。')) {
return;
}
try {
const res = await fetch('/api/nvs/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: editor.value
});
const ret = await res.json();
if (res.ok) {
showToast(ret.message || 'NVS 配置已成功保存到 Flash!');
loadKeymap();
} else {
showToast('保存失败: ' + (ret.error || '未知错误'), true);
}
} catch(e) {
showToast('保存请求失败: ' + e.message, true);
}
}
async function resetNvsFactory() {
if (!confirm('警告:此操作将清空 ESP32 内部的所有 NVS 持久化数据(包括 Wi-Fi 密码、遥控器绑定、自定义按键),并自动重启!\n\n确定要执行出厂重置吗?')) {
return;
}
try {
const res = await fetch('/api/nvs/reset', { method: 'POST' });
const ret = await res.json();
showToast(ret.message || 'NVS 已清空,设备重启中...');
setTimeout(() => location.reload(), 3000);
} catch(e) {
showToast('操作失败: ' + e.message, true);
}
}
function handleNvsEditorKey(e) {
if (e.key === 'Tab') {
e.preventDefault();
const textarea = e.target;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, start) + ' ' + textarea.value.substring(end);
textarea.selectionStart = textarea.selectionEnd = start + 2;
validateNvsJson();
}
}
// Periodic background pollers
setInterval(fetchKeyTelemetry, 100);
setInterval(fetchStatus, 3000);
setInterval(refreshLogs, 2000);
loadKeymap();
fetchStatus();
</script>
</body>
</html>
)rawliteral";