feat: add embedded app PC debugging skill
This commit is contained in:
commit
23e3a4910a
12 files changed
+2818
No files matched your search
@@ -0,0 +1,420 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>串口实时控制台</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #d7e0ea;
|
||||
--muted: #8b949e;
|
||||
--ok: #3fb950;
|
||||
--warn: #d29922;
|
||||
--danger: #f85149;
|
||||
--button: #21262d;
|
||||
--button-hover: #30363d;
|
||||
--accent: #238636;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
|
||||
}
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 43px;
|
||||
padding: 8px 12px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#connection { color: var(--warn); font-weight: 700; }
|
||||
#meta { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
|
||||
.follow { margin-left: auto; color: var(--muted); }
|
||||
main { flex: 1; min-height: 0; }
|
||||
#log {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 12px 24px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
tab-size: 4;
|
||||
font: 13px/1.42 Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
|
||||
}
|
||||
.console {
|
||||
flex: none;
|
||||
padding: 10px 12px 11px;
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--border);
|
||||
box-shadow: 0 -8px 24px rgba(0, 0, 0, .18);
|
||||
}
|
||||
.quick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.quick { margin-bottom: 8px; }
|
||||
.quick-label { color: var(--muted); margin-right: 2px; }
|
||||
.command-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 7px;
|
||||
}
|
||||
.command-prompt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
padding: 0 9px;
|
||||
color: #79c0ff;
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
user-select: none;
|
||||
}
|
||||
button, textarea {
|
||||
color: var(--text);
|
||||
background: var(--button);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: 13px Consolas, "Cascadia Mono", "Microsoft YaHei UI", monospace;
|
||||
}
|
||||
button { min-height: 30px; padding: 4px 9px; cursor: pointer; }
|
||||
button:hover:not(:disabled) { background: var(--button-hover); }
|
||||
button:disabled { cursor: not-allowed; opacity: .45; }
|
||||
button.primary { background: var(--accent); border-color: #2ea043; }
|
||||
button.danger { color: #ffb3ad; }
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
min-height: 38px;
|
||||
max-height: 116px;
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
}
|
||||
textarea:focus { border-color: #58a6ff; box-shadow: 0 0 0 1px #58a6ff; }
|
||||
textarea.masked { -webkit-text-security: disc; }
|
||||
#send { min-width: 92px; height: 38px; }
|
||||
@media (max-width: 720px) {
|
||||
header { flex-wrap: wrap; }
|
||||
.follow { margin-left: 0; }
|
||||
.command-row { grid-template-columns: auto minmax(0, 1fr); }
|
||||
#send { grid-column: 1 / -1; width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-token="__SESSION_TOKEN__" data-port="__SERIAL_PORT__">
|
||||
<header>
|
||||
<span id="connection">正在连接…</span>
|
||||
<span id="meta">串口状态读取中</span>
|
||||
<label class="follow"><input id="follow" type="checkbox" checked> 自动滚动</label>
|
||||
</header>
|
||||
|
||||
<main><pre id="log">正在读取串口日志…</pre></main>
|
||||
|
||||
<section class="console" aria-label="串口命令区">
|
||||
<div class="quick">
|
||||
<span class="quick-label">快捷填入:</span>
|
||||
<button class="fill" data-command="printenv systemSelect">查询分区</button>
|
||||
<button class="fill" data-command="setenv systemSelect A">填入分区 A</button>
|
||||
<button class="fill" data-command="setenv systemSelect B">填入分区 B</button>
|
||||
<button class="fill" data-command="saveenv">保存环境</button>
|
||||
<button class="fill" data-command="reset">重启设备</button>
|
||||
<button class="fill" data-command="ifconfig">ifconfig</button>
|
||||
<button class="fill" data-command="dmesg">dmesg</button>
|
||||
</div>
|
||||
|
||||
<div class="command-row">
|
||||
<span id="commandPrompt" class="command-prompt">COM ></span>
|
||||
<textarea id="command" rows="1" spellcheck="false" autocomplete="off" placeholder="输入命令;留空按 Enter 发送回车"></textarea>
|
||||
<button id="send" class="primary" disabled>发送 Enter</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const token = document.body.dataset.token;
|
||||
const configuredPort = document.body.dataset.port;
|
||||
const clientId = window.crypto && typeof window.crypto.randomUUID === 'function'
|
||||
? window.crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const logView = document.querySelector('#log');
|
||||
const connectionView = document.querySelector('#connection');
|
||||
const metaView = document.querySelector('#meta');
|
||||
const followView = document.querySelector('#follow');
|
||||
const commandView = document.querySelector('#command');
|
||||
const commandPromptView = document.querySelector('#commandPrompt');
|
||||
const sendButton = document.querySelector('#send');
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
const commandHistory = [];
|
||||
let historyIndex = 0;
|
||||
let historyDraft = '';
|
||||
let logPolling = false;
|
||||
let statusPolling = false;
|
||||
let hasRenderedLog = false;
|
||||
let serialOpen = false;
|
||||
let sendBusy = false;
|
||||
let promptMode = 'unknown';
|
||||
let pageClosing = false;
|
||||
|
||||
commandPromptView.textContent = `${configuredPort} >`;
|
||||
|
||||
function clientUrl(path) {
|
||||
const separator = path.includes('?') ? '&' : '?';
|
||||
return `${path}${separator}client=${encodeURIComponent(clientId)}`;
|
||||
}
|
||||
|
||||
// 仅清理终端显示控制字符,不修改设备输出中的普通调试内容。
|
||||
function removeTerminalControls(text) {
|
||||
text = text.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '');
|
||||
text = text.replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g, '');
|
||||
const output = [];
|
||||
for (const char of text) {
|
||||
if (char === '\b') {
|
||||
if (output.length && output[output.length - 1] !== '\n') output.pop();
|
||||
} else {
|
||||
output.push(char);
|
||||
}
|
||||
}
|
||||
return output.join('').replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '');
|
||||
}
|
||||
|
||||
function updateSessionState(text) {
|
||||
const lastLogin = text.lastIndexOf('login:');
|
||||
const lastPassword = text.lastIndexOf('Password:');
|
||||
const lastFailure = text.lastIndexOf('Login incorrect');
|
||||
const lastShell = Math.max(text.lastIndexOf(']~#'), text.lastIndexOf(']~$'));
|
||||
const latestPrompt = Math.max(lastLogin, lastPassword, lastFailure, lastShell);
|
||||
if (latestPrompt === lastShell && lastShell >= 0) {
|
||||
promptMode = 'shell';
|
||||
} else if (latestPrompt === lastPassword && lastPassword >= 0) {
|
||||
promptMode = 'password';
|
||||
} else if (latestPrompt === lastLogin && lastLogin >= 0) {
|
||||
promptMode = 'login';
|
||||
} else if (latestPrompt === lastFailure && lastFailure >= 0) {
|
||||
promptMode = 'failed';
|
||||
} else {
|
||||
promptMode = 'unknown';
|
||||
}
|
||||
commandView.classList.toggle('masked', promptMode === 'password');
|
||||
}
|
||||
|
||||
function resizeCommandInput() {
|
||||
commandView.style.height = '38px';
|
||||
commandView.style.height = `${Math.min(116, Math.max(38, commandView.scrollHeight))}px`;
|
||||
}
|
||||
|
||||
function setCommandValue(value) {
|
||||
commandView.value = value;
|
||||
resizeCommandInput();
|
||||
commandView.focus();
|
||||
commandView.setSelectionRange(value.length, value.length);
|
||||
}
|
||||
|
||||
function rememberCommand(command) {
|
||||
if (!command.length) return;
|
||||
const duplicateIndex = commandHistory.indexOf(command);
|
||||
if (duplicateIndex >= 0) commandHistory.splice(duplicateIndex, 1);
|
||||
commandHistory.push(command);
|
||||
if (commandHistory.length > 50) commandHistory.shift();
|
||||
historyIndex = commandHistory.length;
|
||||
historyDraft = '';
|
||||
}
|
||||
|
||||
function updateInputControls() {
|
||||
sendButton.disabled = !serialOpen || sendBusy;
|
||||
}
|
||||
|
||||
function applyConnectionState(isOpen) {
|
||||
serialOpen = isOpen;
|
||||
updateInputControls();
|
||||
}
|
||||
|
||||
async function refreshLog() {
|
||||
if (logPolling) return;
|
||||
logPolling = true;
|
||||
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
|
||||
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
|
||||
try {
|
||||
const response = await fetch(`${clientUrl('/api/log')}&t=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const fullText = removeTerminalControls(decoder.decode(bytes));
|
||||
updateSessionState(fullText);
|
||||
const visibleText = fullText.length > 500000
|
||||
? `[前面的日志已在页面中省略,完整数据仍保存在文件中]\n${fullText.slice(-500000)}`
|
||||
: fullText;
|
||||
logView.textContent = visibleText || `日志文件当前为空,正在等待 ${configuredPort} 数据…`;
|
||||
// 首次打开必须显示最新日志;等待浏览器完成布局后再滚动到底部,避免大日志仍停在开头。
|
||||
if (shouldFollowLatest) {
|
||||
requestAnimationFrame(() => {
|
||||
logView.scrollTop = logView.scrollHeight;
|
||||
});
|
||||
}
|
||||
hasRenderedLog = true;
|
||||
} catch (error) {
|
||||
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
|
||||
} finally {
|
||||
logPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
if (statusPolling) return;
|
||||
statusPolling = true;
|
||||
try {
|
||||
const response = await fetch(`${clientUrl('/api/status')}&t=${Date.now()}`, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const status = await response.json();
|
||||
applyConnectionState(Boolean(status.isOpen));
|
||||
connectionView.textContent = status.isOpen ? `● ${status.port} 已连接` : `● ${status.port} 已断开`;
|
||||
connectionView.style.color = status.isOpen ? 'var(--ok)' : 'var(--danger)';
|
||||
const lastRx = status.lastReceiveUtc ? new Date(status.lastReceiveUtc).toLocaleTimeString() : '暂无数据';
|
||||
metaView.textContent = `115200 8N1 · 本次接收 ${Number(status.receivedBytes).toLocaleString()} 字节 · 最后数据 ${lastRx}`;
|
||||
updateInputControls();
|
||||
} catch (error) {
|
||||
applyConnectionState(false);
|
||||
connectionView.textContent = `● ${configuredPort} 服务离线`;
|
||||
connectionView.style.color = 'var(--danger)';
|
||||
metaView.textContent = `无法连接本地串口服务:${error.message}`;
|
||||
} finally {
|
||||
statusPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postCommand(path, body = '') {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Serial-Token': token
|
||||
},
|
||||
body
|
||||
});
|
||||
let result = {};
|
||||
try { result = await response.json(); } catch { }
|
||||
if (!response.ok) throw new Error(result.error || `HTTP ${response.status}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function sendCommand(commandOverride) {
|
||||
const fromInput = typeof commandOverride !== 'string';
|
||||
const command = fromInput ? commandView.value : commandOverride;
|
||||
const wasSecret = fromInput && promptMode === 'password';
|
||||
if (!serialOpen || sendBusy) return;
|
||||
sendBusy = true;
|
||||
updateInputControls();
|
||||
try {
|
||||
await postCommand('/api/send?ending=CR', command);
|
||||
if (!wasSecret) rememberCommand(command);
|
||||
if (fromInput && commandView.value === command) setCommandValue('');
|
||||
if (wasSecret) commandView.classList.remove('masked');
|
||||
setTimeout(refreshLog, 50);
|
||||
} catch (error) {
|
||||
connectionView.textContent = `● ${configuredPort} 发送失败:${error.message}`;
|
||||
connectionView.style.color = 'var(--danger)';
|
||||
} finally {
|
||||
sendBusy = false;
|
||||
updateInputControls();
|
||||
commandView.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function disconnectClient() {
|
||||
if (pageClosing) return;
|
||||
pageClosing = true;
|
||||
const disconnectUrl = `${clientUrl('/api/client/disconnect')}&token=${encodeURIComponent(token)}`;
|
||||
// 浏览器关闭标签页时普通异步请求可能被直接取消;sendBeacon 专门用于离页通知。
|
||||
// 某些内置浏览器未实现 sendBeacon,因此同时保留 keepalive fetch 作为兼容兜底。
|
||||
if (navigator.sendBeacon) {
|
||||
try {
|
||||
navigator.sendBeacon(disconnectUrl, new Blob([], { type: 'text/plain;charset=UTF-8' }));
|
||||
} catch (_) { }
|
||||
}
|
||||
fetch(disconnectUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'X-Serial-Token': token
|
||||
},
|
||||
body: '',
|
||||
keepalive: true
|
||||
}).catch(() => { });
|
||||
}
|
||||
|
||||
document.querySelectorAll('.fill').forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
setCommandValue(button.dataset.command || '');
|
||||
});
|
||||
});
|
||||
|
||||
sendButton.addEventListener('click', sendCommand);
|
||||
followView.addEventListener('change', () => {
|
||||
if (followView.checked) {
|
||||
requestAnimationFrame(() => {
|
||||
logView.scrollTop = logView.scrollHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
commandView.addEventListener('input', resizeCommandInput);
|
||||
commandView.addEventListener('keydown', (event) => {
|
||||
// 中文输入法确认候选时的 Enter 不应被当成串口发送。
|
||||
if (event.isComposing || event.keyCode === 229) return;
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (event.repeat && commandView.value.length === 0) return;
|
||||
sendCommand();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setCommandValue('');
|
||||
return;
|
||||
}
|
||||
const atStart = commandView.selectionStart === 0 && commandView.selectionEnd === 0;
|
||||
const atEnd = commandView.selectionStart === commandView.value.length && commandView.selectionEnd === commandView.value.length;
|
||||
if (event.key === 'ArrowUp' && commandHistory.length && (atStart || !commandView.value.includes('\n'))) {
|
||||
event.preventDefault();
|
||||
if (historyIndex === commandHistory.length) historyDraft = commandView.value;
|
||||
historyIndex = Math.max(0, historyIndex - 1);
|
||||
setCommandValue(commandHistory[historyIndex]);
|
||||
} else if (event.key === 'ArrowDown' && commandHistory.length && (atEnd || !commandView.value.includes('\n'))) {
|
||||
event.preventDefault();
|
||||
historyIndex = Math.min(commandHistory.length, historyIndex + 1);
|
||||
setCommandValue(historyIndex === commandHistory.length ? historyDraft : commandHistory[historyIndex]);
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', disconnectClient);
|
||||
window.addEventListener('beforeunload', disconnectClient);
|
||||
window.addEventListener('pageshow', () => {
|
||||
pageClosing = false;
|
||||
refreshStatus();
|
||||
});
|
||||
|
||||
refreshLog();
|
||||
refreshStatus();
|
||||
setInterval(refreshLog, 800);
|
||||
setInterval(refreshStatus, 500);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user