Files

477 lines
18 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;
user-select: text;
-webkit-user-select: text;
cursor: text;
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 &gt;</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;
let livenessConnection = null;
let selectingLog = false;
commandPromptView.textContent = `${configuredPort} >`;
function clientUrl(path) {
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}client=${encodeURIComponent(clientId)}`;
}
function connectLiveness() {
if (pageClosing || livenessConnection || typeof EventSource !== 'function') return;
// 页面在后台时 setInterval 可能被节流;独立的原生持续连接才是页面存活依据。
// 它只维护当前页面的租约,不打开串口、不发送命令,也不会重启已经退出的服务。
livenessConnection = new EventSource(`${clientUrl('/api/client/live')}&token=${encodeURIComponent(token)}`);
livenessConnection.onerror = () => {
if (!pageClosing) refreshStatus();
};
}
async function fetchLocal(path) {
const controller = new AbortController();
// 请求失败后必须放开 polling 标志;否则一个悬挂请求会让页面永久停止更新。
const timeout = setTimeout(() => controller.abort(), 2500);
try {
const response = await fetch(path, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.arrayBuffer();
} finally {
clearTimeout(timeout);
}
}
// 仅清理终端显示控制字符,不修改设备输出中的普通调试内容。
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();
}
function isLogSelectionActive() {
// 拖选开始时选区可能仍为空,也不能替换文本节点;否则拖选会被轮询打断。
if (selectingLog) return true;
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return false;
for (let index = 0; index < selection.rangeCount; index++) {
if (selection.getRangeAt(index).intersectsNode(logView)) return true;
}
return false;
}
async function refreshLog() {
if (pageClosing || logPolling) return;
logPolling = true;
try {
const bytes = new Uint8Array(await fetchLocal(`${clientUrl('/api/log')}&t=${Date.now()}`));
const fullText = removeTerminalControls(decoder.decode(bytes));
updateSessionState(fullText);
const visibleText = fullText.length > 500000
? `[前面的日志已在页面中省略,完整数据仍保存在文件中]\n${fullText.slice(-500000)}`
: fullText;
// 只暂停页面绘制,不停串口采集、文件落盘或页面存活连接。
// 必须在请求完成后检查选区,防止等待响应期间用户刚开始选择文字。
if (isLogSelectionActive()) return;
const nextText = visibleText || `日志文件当前为空,正在等待 ${configuredPort} 数据…`;
if (logView.textContent === nextText) return;
const nearBottom = logView.scrollHeight - logView.scrollTop - logView.clientHeight < 100;
const shouldFollowLatest = followView.checked && (!hasRenderedLog || nearBottom);
logView.textContent = nextText;
// 首次打开必须显示最新日志;等待浏览器完成布局后再滚动到底部,避免大日志仍停在开头。
if (shouldFollowLatest) {
requestAnimationFrame(() => {
if (!isLogSelectionActive()) logView.scrollTop = logView.scrollHeight;
});
}
hasRenderedLog = true;
} catch (error) {
if (!isLogSelectionActive()) {
logView.textContent = `${logView.textContent}\n[网页] 日志读取失败:${error.message}`;
}
} finally {
logPolling = false;
}
}
async function refreshStatus() {
if (pageClosing || statusPolling) return;
statusPolling = true;
try {
const bytes = await fetchLocal(`${clientUrl('/api/status')}&t=${Date.now()}`);
const status = JSON.parse(decoder.decode(bytes));
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;
if (livenessConnection) {
livenessConnection.close();
livenessConnection = null;
}
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);
logView.addEventListener('pointerdown', (event) => {
if (event.button === 0) selectingLog = true;
});
window.addEventListener('pointerup', () => { selectingLog = false; });
window.addEventListener('pointercancel', () => { selectingLog = false; });
window.addEventListener('blur', () => { selectingLog = false; });
followView.addEventListener('change', () => {
if (followView.checked) {
requestAnimationFrame(() => {
if (!isLogSelectionActive()) 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;
connectLiveness();
refreshLog();
refreshStatus();
});
connectLiveness();
refreshLog();
refreshStatus();
setInterval(refreshLog, 800);
setInterval(refreshStatus, 500);
</script>
</body>
</html>