421 lines
16 KiB
HTML
421 lines
16 KiB
HTML
<!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>
|