码桶
发现社区成员的开源项目
terminal.html59.1 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeNanny · 在线终端</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=2">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/css/xterm.css" />
<style>
/* ---------- 终端 UI v3(UI改版·终端,第二批修正)----------
创始人反馈v2版本:①用了一套跟主面板完全不同的灰色系配色,看起来像另一个
应用;②新发明的"紫罗兰卷草花纹"跟产品里其它任何地方都不一样,肉眼看下来
只是一条不起眼的直线;③速查表按钮里的svg图标没写尺寸,撑爆了按钮导致
文字被挤到卡片外面;④终端连接前有个来源不明的白色竖条。
这一版改正:直接复用 public/index.html 的真实变量取值(bg-a/bg-b/surface/
hairline/text/brass 等),字体也换成主面板同款的 IBM Plex Sans(正文)+
Fraunces(标题),四角装饰改用主面板本来就在用的 .rivet 铜制包边手法
(小写的L形黄铜边框+一个小圆点),不再发明新的花纹语言。 */
:root {
--bg-a: #121a1c;
--bg-b: #0c1214;
--surface: #161e21;
--surface-2: #1a2124;
--hairline: #2b3538;
--text: #e4dcc8;
--text-muted: #8a8f8a;
--brass: #c8963e;
--brass-soft: #e0b667;
--brass-dim: #7d6534;
--teal: #4d8478;
--teal-soft: #7fada4;
--rust: #c05a44;
--rust-soft: #dd8468;
--relic-glow: rgba(200, 150, 62, 0.18);
}
.xterm-viewport::-webkit-scrollbar { width: 8px; }
.xterm-viewport::-webkit-scrollbar-track { background: var(--bg-b); }
.xterm-viewport::-webkit-scrollbar-thumb { background: var(--brass-dim); border-radius: 4px; }
.xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--brass); }
* { box-sizing: border-box; }
body {
margin: 0;
background: linear-gradient(180deg, var(--bg-a) 0%, var(--bg-b) 100%);
color: var(--text);
font-family: 'IBM Plex Sans', -apple-system, 'PingFang SC', 'Hiragino Sans', 'Yu Gothic', sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.term-page {
flex: 1;
display: flex;
flex-direction: column;
padding: 18px;
gap: 10px;
min-height: 0;
}
.term-breadcrumb {
font-size: 12.5px;
color: var(--text-muted);
}
.term-breadcrumb a { color: var(--brass-soft); text-decoration: none; cursor: pointer; }
.term-breadcrumb a:hover { text-decoration: underline; }
/* 被嵌入终端悬浮层时(index.html里的iframe),外层悬浮层壳自己已经有一个
圆形关闭按钮,这里的面包屑就不需要再占一行地方,直接隐藏。判断逻辑
见下面的内嵌脚本:window.self !== window.top。 */
body.embedded .term-breadcrumb { display: none; }
body.embedded .term-page { padding-top: 10px; }
/* ---------- 卡片本体:跟主面板 .log 卡片同一套视觉语言(surface底+hairline边框+
brass顶边强调),不再自己发明一套灰色系 ---------- */
.term-card {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--surface);
border: 1px solid var(--hairline);
border-top: 3px solid var(--brass);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08), 0 8px 24px rgba(0,0,0,0.28);
}
/* ---------- 四角铜制包边:跟主面板卡片完全同款的 .rivet 手法(L形黄铜边框+
一个小圆点),不是另起一套花纹语言。四角都套用,视觉上跟主面板一眼能认出
是同一个产品。 ---------- */
.rivet { position: absolute; z-index: 2; width: 14px; height: 14px; pointer-events: none; }
.rivet::before {
content: '';
position: absolute;
width: 5px; height: 5px;
border-radius: 50%;
background: var(--brass);
box-shadow: 0 0 4px var(--relic-glow);
}
.rivet.tl { top: 10px; left: 10px; border-top: 1.4px solid var(--brass); border-left: 1.4px solid var(--brass); }
.rivet.tl::before { top: -2.5px; left: -2.5px; }
.rivet.tr { top: 10px; right: 10px; border-top: 1.4px solid var(--brass); border-right: 1.4px solid var(--brass); }
.rivet.tr::before { top: -2.5px; right: -2.5px; }
.rivet.bl { bottom: 10px; left: 10px; border-bottom: 1.4px solid var(--brass); border-left: 1.4px solid var(--brass); }
.rivet.bl::before { bottom: -2.5px; left: -2.5px; }
.rivet.br { bottom: 10px; right: 10px; border-bottom: 1.4px solid var(--brass); border-right: 1.4px solid var(--brass); }
.rivet.br::before { bottom: -2.5px; right: -2.5px; }
/* ---------- 顶部状态栏:进程标识 + 运行状态徽章 + 操作按钮 ---------- */
.term-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 22px;
background: var(--surface-2);
border-bottom: 1px solid var(--hairline);
flex-wrap: wrap;
}
.term-header-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.term-header-left > svg { width: 18px; height: 18px; flex-shrink: 0; }
.term-title { font-family: 'Fraunces', serif; font-size: 15px; font-weight: 500; color: var(--text); white-space: nowrap; letter-spacing: 0.01em; }
.term-status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11.5px;
font-family: 'IBM Plex Mono', monospace;
padding: 3px 9px 3px 7px;
border-radius: 999px;
background: rgba(138,143,138,0.14);
color: var(--text-muted);
white-space: nowrap;
}
.term-status-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
}
.term-status-badge.state-connecting { background: rgba(200,150,62,0.16); color: var(--brass-soft); }
.term-status-badge.state-connecting .term-status-dot { background: var(--brass); animation: term-pulse 1s ease-in-out infinite; }
.term-status-badge.state-connected { background: rgba(77,132,120,0.2); color: var(--teal-soft); }
.term-status-badge.state-connected .term-status-dot { background: var(--teal); animation: term-pulse 2.2s ease-in-out infinite; }
.term-status-badge.state-error { background: rgba(192,90,68,0.18); color: var(--rust-soft); }
.term-status-badge.state-error .term-status-dot { background: var(--rust); }
@keyframes term-pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
.term-header-right { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.term-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--surface);
border: 1px solid var(--hairline);
color: var(--text);
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 12.5px;
font-family: inherit;
white-space: nowrap;
}
/* 按钮内图标统一约束尺寸——上一版没写这条规则,图标撑到浏览器默认大小,
把"速查表"三个字挤出了卡片边界,这是这次修的一个真实bug。 */
.term-btn svg { width: 14px; height: 14px; flex-shrink: 0; }
.term-btn:hover:not(:disabled) { border-color: var(--brass); }
.term-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.term-btn.primary { background: var(--teal); border-color: var(--teal); color: #f1faf7; }
.term-btn.primary:hover:not(:disabled) { border-color: var(--teal-soft); }
.term-btn.ghost { background: transparent; border-color: var(--hairline); }
/* 速查表按钮:知识库修复命令的入口,是终端里唯一的"安全网"功能,
不能跟普通操作按钮一样容易被忽略,用黄铜色边框+浅色文字强调一下。 */
.term-btn.kb {
background: rgba(200,150,62,0.1);
border-color: var(--brass-dim);
color: var(--brass-soft);
}
.term-btn.kb:hover:not(:disabled) { background: rgba(200,150,62,0.18); border-color: var(--brass-soft); }
.term-btn.kb:disabled { background: transparent; color: var(--text-muted); border-color: var(--hairline); }
/* ---------- 解锁行:连接前展示,连接成功后收起,避免连上之后界面还挂着一行密码框 ---------- */
.term-unlock-row {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 22px;
background: var(--surface);
border-bottom: 1px solid var(--hairline);
flex-wrap: wrap;
}
.term-unlock-row.hidden { display: none; }
.term-unlock-row label { font-size: 12.5px; color: var(--text-muted); }
.term-unlock-row input {
background: var(--bg-b);
border: 1px solid var(--hairline);
color: var(--text);
padding: 6px 9px;
border-radius: 5px;
font-family: 'IBM Plex Mono', monospace;
font-size: 12.5px;
min-width: 160px;
}
.term-unlock-row input:focus { outline: none; border-color: var(--brass); }
/* ---------- 输出区:彩色分级由 xterm 自身的 ANSI 颜色 + 我们写入的提示行颜色承担 ---------- */
.term-body {
position: relative;
flex: 1;
min-height: 0;
padding: 10px;
background: var(--bg-b);
}
#terminal-container { height: 100%; }
/* ---------- 连接前的引导占位:解锁前终端主体完全空白容易让人以为卡住了,
这里叠一层居中提示,连接成功(收到ready消息)后由JS加.hidden隐藏,
断开重连时会重新显示。 ---------- */
.term-placeholder {
position: absolute;
inset: 10px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
text-align: center;
padding: 24px;
color: var(--text-muted);
pointer-events: none;
}
.term-placeholder.hidden { display: none; }
.term-placeholder svg { width: 34px; height: 34px; color: var(--brass-dim); }
.term-placeholder-title { font-family: 'Fraunces', serif; font-size: 14px; font-weight: 500; color: var(--text); }
.term-placeholder-sub { font-size: 12px; line-height: 1.6; max-width: 320px; }
/* ---------- 底部提示区:跟输出区在同一张卡片内,不是浮在页面外的说明文字 ---------- */
.term-footer {
padding: 9px 22px;
font-size: 11.5px;
color: var(--text-muted);
background: var(--surface-2);
border-top: 1px solid var(--hairline);
line-height: 1.6;
}
/* ---------- 知识库速查表弹窗:终端模块内直接查看修复命令,不需要跳转到wiki页面。
verified:true的条目才显示"填入终端"按钮,否则只显示"复制"——这是
v4文档要求的"AI/远程生成的建议默认仅可复制手动执行"在前端的体现,
真正的强制在core/terminal.js的写入接口那一层,这里的限制是双保险。 ---------- */
.memo-overlay { display: none; position: fixed; inset: 0; background: rgba(8,10,13,0.72); z-index: 200; align-items: flex-start; justify-content: center; }
.memo-overlay.open { display: flex; }
.memo-box {
position: relative;
background: var(--surface);
border: 1px solid var(--hairline);
border-top: 3px solid var(--brass);
border-radius: 8px;
width: 640px;
max-width: 92vw;
margin-top: 6vh;
max-height: 84vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08), 0 10px 30px rgba(0,0,0,0.4);
}
.memo-head { padding: 14px 18px; border-bottom: 1px solid var(--hairline); display: flex; align-items: center; gap: 10px; }
.memo-head input { flex: 1; background: var(--bg-b); border: 1px solid var(--hairline); color: var(--text); padding: 6px 10px; border-radius: 4px; font-family: inherit; }
.memo-close { background: none; border: none; color: var(--text-muted); font-size: 18px; cursor: pointer; }
.memo-list { overflow-y: auto; padding: 10px 18px; }
.memo-entry { border-bottom: 1px solid var(--hairline); padding: 12px 0; }
.memo-entry:last-child { border-bottom: none; }
.memo-title { font-weight: 600; font-size: 14px; display: flex; align-items: center; gap: 8px; color: var(--text); }
.risk-badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; font-weight: 500; }
.risk-low { background: rgba(77,132,120,0.22); color: var(--teal-soft); }
.risk-medium { background: rgba(200,150,62,0.2); color: var(--brass-soft); }
.risk-high { background: rgba(192,90,68,0.22); color: var(--rust-soft); }
.risk-copy { background: var(--surface-2); color: var(--text-muted); }
.memo-explain { font-size: 12.5px; color: var(--text-muted); margin: 6px 0 8px; line-height: 1.6; }
.memo-cmd-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.memo-cmd-row code { flex: 1; background: var(--bg-b); padding: 4px 8px; border-radius: 4px; font-size: 12.5px; overflow-x: auto; white-space: nowrap; font-family: 'IBM Plex Mono', monospace; }
.memo-cmd-row button { background: var(--teal); border: none; color: #f1faf7; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px; white-space: nowrap; font-family: inherit; }
.memo-cmd-row button.secondary { background: var(--surface-2); color: var(--text); }
.memo-cmd-status { font-size: 11.5px; color: var(--brass-soft); margin-left: 4px; }
.memo-section-label { font-size: 11.5px; color: var(--brass-soft); text-transform: uppercase; letter-spacing: 0.04em; margin: 14px 0 4px; padding-top: 8px; border-top: 1px dashed var(--hairline); }
.memo-section-label:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.memo-entry.memo-auto-matched { background: rgba(200,150,62,0.06); border-radius: 4px; padding: 12px 10px; margin: 0 -10px 6px; border-bottom: none; }
/* 语言切换器:跟主面板(index.html)/wiki页(wiki.html)的.lang-switch保持同一套视觉语言 */
.lang-switch { display: flex; align-items: center; flex-shrink: 0; }
.lang-switch button {
background: none;
border: none;
border-right: 1px solid var(--hairline);
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
padding: 4px 9px;
cursor: pointer;
}
.lang-switch button:last-child { border-right: none; }
.lang-switch button:hover { color: var(--text); }
.lang-switch button.active { color: var(--brass-soft); border-bottom: 1px solid var(--brass); }
.lang-switch button:focus-visible { outline: 2px solid var(--brass); outline-offset: 1px; }
/* 嵌入到主面板悬浮层里时,外层index.html自己就有一份语言切换器,
终端内部再放一份是重复的,跟隐藏面包屑同一个道理 */
body.embedded .lang-switch { display: none; }
@media (max-width: 640px) {
.term-page { padding: 10px; }
.term-header { flex-direction: column; align-items: flex-start; }
.term-header-right { width: 100%; justify-content: space-between; }
}
</style>
</head>
<body>
<div class="term-page">
<div class="term-breadcrumb"><a href="/" id="term-back-link"><span id="term-back-text">← 返回主面板</span></a></div>
<div class="term-card">
<div class="rivet tl"></div><div class="rivet tr"></div><div class="rivet bl"></div><div class="rivet br"></div>
<div class="term-header">
<div class="term-header-left">
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M14.5 3 L17.5 3 L19 10 L13 10 Z" fill="#c8963e"/>
<path d="M13.3 10.5 H18.7 L20.5 24 H11.5 Z" fill="none" stroke="#8a8f8a" stroke-width="1.1"/>
<line x1="12.2" y1="15" x2="19.8" y2="15" stroke="#8a8f8a" stroke-width="1"/>
<line x1="11.8" y1="19.5" x2="20.2" y2="19.5" stroke="#8a8f8a" stroke-width="1"/>
<line x1="6" y1="24" x2="26" y2="24" stroke="#8a8f8a" stroke-width="1.2"/>
</svg>
<span class="term-title" id="term-title">远程 Shell</span>
<span class="term-status-badge state-idle" id="status-badge">
<span class="term-status-dot"></span>
<span id="status-msg">未解锁</span>
</span>
</div>
<div class="term-header-right">
<div class="lang-switch" id="lang-switch" role="group" aria-label="Language"></div>
<button id="memo-btn" class="term-btn kb" disabled title="连接终端后可用:查看知识库修复命令速查表">
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M3 4c2-1 5-1 7 0v12c-2-1-5-1-7 0V4Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M17 4c-2-1-5-1-7 0v12c2-1 5-1 7 0V4Z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
</svg>
<span id="memo-btn-text">速查表</span>
</button>
</div>
</div>
<div class="term-unlock-row" id="unlock-row">
<label for="terminal-password" id="unlock-label">终端密码</label>
<input id="terminal-password" type="password" autocomplete="off" />
<button id="unlock-btn" class="term-btn primary"><span id="unlock-btn-text">解锁并连接</span></button>
</div>
<div class="term-body">
<div id="terminal-container"></div>
<div class="term-placeholder" id="term-placeholder">
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="3" y="6" width="26" height="20" rx="2" stroke="currentColor" stroke-width="1.3"/>
<path d="M8 13l4 3-4 3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
<line x1="15" y1="19" x2="21" y2="19" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
<div class="term-placeholder-title" id="term-placeholder-title">终端尚未连接</div>
<div class="term-placeholder-sub" id="term-placeholder-sub">在上方输入终端密码并点击"解锁并连接",即可在这里打开一个远程shell会话。</div>
</div>
</div>
<div class="term-footer" id="term-footer">
这是一台正在被 NodeNanny 看护的服务器的远程shell,操作等同于直接登录服务器终端,请谨慎执行命令。
遇到问题时,优先点击右上角"速查表"查看知识库里已核对过的修复命令,而不是凭记忆直接敲;标记为
"未经人工确认"的命令仅支持复制、需要你手动粘贴执行后再确认。会话空闲一段时间后会自动断开,重新
解锁即可继续。
</div>
</div>
</div>
<!-- 知识库速查表弹窗,逻辑不变,只是外壳挪到了终端卡片体系之外(覆盖整个页面) -->
<div class="memo-overlay" id="memoOverlay">
<div class="memo-box">
<div class="rivet tl"></div><div class="rivet tr"></div><div class="rivet bl"></div><div class="rivet br"></div>
<div class="memo-head">
<input type="text" id="memoSearch" placeholder="搜索知识库条目(标题/关键词)..." autocomplete="off">
<button class="memo-close" id="memoCloseBtn">×</button>
</div>
<div class="memo-list" id="memoList">加载中…</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/lib/xterm-addon-fit.js"></script>
<script>
// ---------- 嵌入检测(UI改版·容器方式)----------
// 判断当前是不是被index.html的悬浮层用iframe嵌入。是的话:
// 1) 给body加embedded类,隐藏面包屑(悬浮层壳自己已经有关闭按钮);
// 2) 面包屑链接本身改成postMessage通知外层关闭悬浮层,而不是在iframe
// 内部导航到"/"(那样会在悬浮层这个小窗口里显示整个主面板,很怪)。
// 直接从URL访问/terminal.html(不经过悬浮层)时,window.self === window.top,
// 走原来的行为:正常跳转回主面板。
const embeddedInOverlay = (window.self !== window.top);
if (embeddedInOverlay) {
document.body.classList.add('embedded');
}
document.getElementById('term-back-link').addEventListener('click', (e) => {
if (embeddedInOverlay) {
e.preventDefault();
window.parent.postMessage('nodenanny:terminal:close', window.location.origin);
}
// 非嵌入模式:不preventDefault,走href="/"的原生跳转
});
// ---------- 界面文案五语言字典 ----------
// 跟主面板(index.html)/wiki页(wiki.html)复用同一套 localStorage 键(nodenanny-lang)
// 和语言代码(zh/en/ja/de/ru),切主面板语言后打开终端会自动跟随。
// 只覆盖"界面壳"文案(标题、按钮、状态提示、页脚说明这些)。知识库条目本身的
// title字段目前知识库数据结构里只存了一份中文(不像explanation按语言分好几份),
// 这是数据结构的限制,不是这份字典能解决的——条目标题会一直显示中文,这是已知的、
// 记录在案的限制,不是遗漏。
const TERM_I18N = {
zh: {
code: '中',
pageTitle: 'NodeNanny · 在线终端',
backLink: '← 返回主面板',
title: '远程 Shell',
statusIdle: '未解锁',
statusVerifying: '验证中…',
statusUnlockedConnecting: (p) => `已解锁(${p.minutes}分钟内有效),正在连接…`,
statusConnectingReady: '已连接,等待会话就绪…',
statusConnected: '已连接',
statusClosed: '连接已关闭',
statusError: '连接出错',
statusPasswordError: (p) => `密码错误或终端未启用:${p.detail}`,
statusRequestFailed: (p) => `请求失败:${p.detail}`,
idleTimeoutMsg: '\r\n[会话因空闲超时已关闭,请重新解锁]\r\n',
memoBtn: '速查表',
memoBtnTitle: '连接终端后可用:查看知识库修复命令速查表',
unlockLabel: '终端密码',
unlockBtn: '解锁并连接',
placeholderTitle: '终端尚未连接',
placeholderSub: '在上方输入终端密码并点击"解锁并连接",即可在这里打开一个远程shell会话。',
footer: '这是一台正在被 NodeNanny 看护的服务器的远程shell,操作等同于直接登录服务器终端,请谨慎执行命令。遇到问题时,优先点击右上角"速查表"查看知识库里已核对过的修复命令,而不是凭记忆直接敲;标记为"未经人工确认"的命令仅支持复制、需要你手动粘贴执行后再确认。会话空闲一段时间后会自动断开,重新解锁即可继续。',
memoSearchPlaceholder: '搜索知识库条目(标题/关键词)...',
memoLoading: '加载中…',
memoLoadFailed: (p) => `加载知识库失败:${p.msg}`,
memoNoMatch: '没有匹配的知识库条目',
memoAutoMatchLabel: '根据最近故障自动匹配',
memoAllEntriesLabel: '全部知识库条目',
riskLabels: { low: '低', medium: '中', high: '高', 'copy-only': '仅复制' },
runBtn: '填入终端',
copyBtn: '复制(未经人工确认,不能一键执行)',
copiedMsg: '已复制,请手动粘贴到终端确认后执行',
copyFailedMsg: '复制失败,请手动选中命令文本',
needConnectMsg: '请先解锁并连接终端',
writingMsg: '写入中…',
busyTypingMsg: '终端正忙(你正在手动输入),请手动粘贴命令',
sessionExpiredMsg: '终端会话已失效,请重新连接',
genericFailMsg: (p) => `失败:${p.err}`,
autoExecutedMsg: '已自动执行(低风险命令)',
filledWaitEnterMsg: '已填入终端输入框,请手动按回车确认',
highRiskConfirmMsg: '高危命令,需二次确认(60秒内有效) ',
confirmBtnText: '确认执行',
unverifiedCopyOnlyMsg: '该条目未经人工确认,仅支持复制手动执行',
requestFailedMsg: (p) => `请求失败:${p.err}`,
confirmingMsg: '确认中…',
filledConfirmedMsg: '已填入终端,请手动按回车确认(高危命令始终需要人工按下Enter)',
confirmFailedMsg: (p) => `确认失败:${p.err}`
},
en: {
code: 'EN',
pageTitle: 'NodeNanny · Terminal',
backLink: '← Back to Dashboard',
title: 'Remote Shell',
statusIdle: 'Locked',
statusVerifying: 'Verifying…',
statusUnlockedConnecting: (p) => `Unlocked (valid for ${p.minutes} min), connecting…`,
statusConnectingReady: 'Connected, waiting for session…',
statusConnected: 'Connected',
statusClosed: 'Connection closed',
statusError: 'Connection error',
statusPasswordError: (p) => `Wrong password or terminal disabled: ${p.detail}`,
statusRequestFailed: (p) => `Request failed: ${p.detail}`,
idleTimeoutMsg: '\r\n[Session closed due to inactivity — please unlock again]\r\n',
memoBtn: 'Cheat Sheet',
memoBtnTitle: 'Available once connected: view knowledge-base fix commands',
unlockLabel: 'Terminal password',
unlockBtn: 'Unlock & Connect',
placeholderTitle: 'Terminal not connected',
placeholderSub: 'Enter the terminal password above and click "Unlock & Connect" to open a remote shell session here.',
footer: 'This is a remote shell on a server being watched by NodeNanny — commands here run exactly as if you were logged into the server directly, so please be careful. When something looks wrong, check the "Cheat Sheet" in the top-right first for vetted fix commands rather than typing from memory; entries marked "not human-verified" can only be copied and must be pasted and confirmed manually. The session disconnects automatically after being idle for a while — just unlock again to continue.',
memoSearchPlaceholder: 'Search knowledge base (title/keyword)...',
memoLoading: 'Loading…',
memoLoadFailed: (p) => `Failed to load knowledge base: ${p.msg}`,
memoNoMatch: 'No matching knowledge-base entries',
memoAutoMatchLabel: 'Auto-matched from recent failure',
memoAllEntriesLabel: 'All knowledge-base entries',
riskLabels: { low: 'low', medium: 'medium', high: 'high', 'copy-only': 'copy-only' },
runBtn: 'Fill into terminal',
copyBtn: 'Copy (not human-verified, cannot run with one click)',
copiedMsg: 'Copied — please paste into the terminal and confirm manually',
copyFailedMsg: 'Copy failed — please select the command text manually',
needConnectMsg: 'Please unlock and connect the terminal first',
writingMsg: 'Writing…',
busyTypingMsg: 'Terminal is busy (you are typing) — please paste the command manually',
sessionExpiredMsg: 'Terminal session has expired, please reconnect',
genericFailMsg: (p) => `Failed: ${p.err}`,
autoExecutedMsg: 'Executed automatically (low-risk command)',
filledWaitEnterMsg: 'Filled into the terminal input — please press Enter manually to confirm',
highRiskConfirmMsg: 'High-risk command, needs confirmation (valid for 60s) ',
confirmBtnText: 'Confirm & run',
unverifiedCopyOnlyMsg: 'This entry is not human-verified — copy only, run it manually',
requestFailedMsg: (p) => `Request failed: ${p.err}`,
confirmingMsg: 'Confirming…',
filledConfirmedMsg: 'Filled into the terminal — please press Enter manually to confirm (high-risk commands always need a manual Enter)',
confirmFailedMsg: (p) => `Confirmation failed: ${p.err}`
},
ja: {
code: 'JA',
pageTitle: 'NodeNanny · ターミナル',
backLink: '← メイン画面に戻る',
title: 'リモートShell',
statusIdle: '未解錠',
statusVerifying: '検証中…',
statusUnlockedConnecting: (p) => `解錠しました(${p.minutes}分間有効)、接続中…`,
statusConnectingReady: '接続済み、セッション準備待ち…',
statusConnected: '接続済み',
statusClosed: '接続が閉じられました',
statusError: '接続エラー',
statusPasswordError: (p) => `パスワードが違うか、ターミナルが無効です:${p.detail}`,
statusRequestFailed: (p) => `リクエスト失敗:${p.detail}`,
idleTimeoutMsg: '\r\n[アイドル状態のためセッションが閉じられました。再度解錠してください]\r\n',
memoBtn: 'チートシート',
memoBtnTitle: '接続後に利用可能:ナレッジベースの修復コマンドを確認',
unlockLabel: 'ターミナルパスワード',
unlockBtn: '解錠して接続',
placeholderTitle: 'ターミナル未接続',
placeholderSub: '上のパスワードを入力し「解錠して接続」を押すと、ここにリモートshellセッションが開きます。',
footer: 'これはNodeNannyが監視しているサーバーのリモートshellです。ここでの操作はサーバーに直接ログインするのと同じなので、慎重に実行してください。問題があれば、記憶に頼って直接入力する前に右上の「チートシート」で確認済みの修復コマンドを確認してください。「未検証」と表示されたコマンドはコピーのみ可能で、手動で貼り付けて確認してから実行する必要があります。セッションはしばらく操作がないと自動的に切断されますが、再度解錠すれば続行できます。',
memoSearchPlaceholder: 'ナレッジベースを検索(タイトル/キーワード)...',
memoLoading: '読み込み中…',
memoLoadFailed: (p) => `ナレッジベースの読み込みに失敗:${p.msg}`,
memoNoMatch: '該当するナレッジベース項目がありません',
memoAutoMatchLabel: '最近の障害から自動マッチ',
memoAllEntriesLabel: 'すべてのナレッジベース項目',
riskLabels: { low: '低', medium: '中', high: '高', 'copy-only': 'コピーのみ' },
runBtn: 'ターミナルに入力',
copyBtn: 'コピー(未検証のためワンクリック実行不可)',
copiedMsg: 'コピーしました。ターミナルに貼り付けて手動で確認してください',
copyFailedMsg: 'コピーに失敗しました。コマンドのテキストを手動で選択してください',
needConnectMsg: '先にターミナルを解錠して接続してください',
writingMsg: '入力中…',
busyTypingMsg: 'ターミナルが使用中です(手動入力中)。手動で貼り付けてください',
sessionExpiredMsg: 'ターミナルセッションが失効しました。再接続してください',
genericFailMsg: (p) => `失敗:${p.err}`,
autoExecutedMsg: '自動実行しました(低リスクコマンド)',
filledWaitEnterMsg: 'ターミナル入力欄に入力しました。手動でEnterを押して確認してください',
highRiskConfirmMsg: '高リスクコマンドのため再確認が必要です(60秒間有効) ',
confirmBtnText: '確認して実行',
unverifiedCopyOnlyMsg: 'この項目は未検証のため、コピーして手動実行のみ可能です',
requestFailedMsg: (p) => `リクエスト失敗:${p.err}`,
confirmingMsg: '確認中…',
filledConfirmedMsg: 'ターミナルに入力しました。手動でEnterを押して確認してください(高リスクコマンドは常に手動確認が必要です)',
confirmFailedMsg: (p) => `確認に失敗:${p.err}`
},
de: {
code: 'DE',
pageTitle: 'NodeNanny · Terminal',
backLink: '← Zurück zum Dashboard',
title: 'Remote-Shell',
statusIdle: 'Gesperrt',
statusVerifying: 'Wird geprüft…',
statusUnlockedConnecting: (p) => `Entsperrt (gültig für ${p.minutes} Min.), verbinde…`,
statusConnectingReady: 'Verbunden, warte auf Sitzung…',
statusConnected: 'Verbunden',
statusClosed: 'Verbindung geschlossen',
statusError: 'Verbindungsfehler',
statusPasswordError: (p) => `Falsches Passwort oder Terminal deaktiviert: ${p.detail}`,
statusRequestFailed: (p) => `Anfrage fehlgeschlagen: ${p.detail}`,
idleTimeoutMsg: '\r\n[Sitzung wegen Inaktivität geschlossen — bitte erneut entsperren]\r\n',
memoBtn: 'Spickzettel',
memoBtnTitle: 'Verfügbar nach Verbindung: Fix-Befehle aus der Wissensdatenbank ansehen',
unlockLabel: 'Terminal-Passwort',
unlockBtn: 'Entsperren & Verbinden',
placeholderTitle: 'Terminal nicht verbunden',
placeholderSub: 'Gib oben das Terminal-Passwort ein und klicke auf „Entsperren & Verbinden“, um hier eine Remote-Shell-Sitzung zu öffnen.',
footer: 'Dies ist eine Remote-Shell auf einem von NodeNanny überwachten Server — Befehle hier wirken genauso, als wärst du direkt am Server angemeldet, bitte vorsichtig ausführen. Prüfe bei Problemen zuerst oben rechts den „Spickzettel“ für geprüfte Fix-Befehle, statt aus dem Gedächtnis zu tippen; als „nicht manuell verifiziert“ markierte Befehle können nur kopiert werden und müssen manuell eingefügt und bestätigt werden. Die Sitzung trennt sich nach einer Weile Inaktivität automatisch — einfach erneut entsperren, um fortzufahren.',
memoSearchPlaceholder: 'Wissensdatenbank durchsuchen (Titel/Stichwort)...',
memoLoading: 'Wird geladen…',
memoLoadFailed: (p) => `Wissensdatenbank konnte nicht geladen werden: ${p.msg}`,
memoNoMatch: 'Keine passenden Einträge in der Wissensdatenbank',
memoAutoMatchLabel: 'Automatisch anhand des letzten Fehlers zugeordnet',
memoAllEntriesLabel: 'Alle Einträge der Wissensdatenbank',
riskLabels: { low: 'niedrig', medium: 'mittel', high: 'hoch', 'copy-only': 'nur kopieren' },
runBtn: 'In Terminal einfügen',
copyBtn: 'Kopieren (nicht verifiziert, kein Ein-Klick-Ausführen)',
copiedMsg: 'Kopiert — bitte manuell ins Terminal einfügen und bestätigen',
copyFailedMsg: 'Kopieren fehlgeschlagen — bitte Befehlstext manuell auswählen',
needConnectMsg: 'Bitte zuerst das Terminal entsperren und verbinden',
writingMsg: 'Wird geschrieben…',
busyTypingMsg: 'Terminal ist beschäftigt (du tippst gerade) — bitte manuell einfügen',
sessionExpiredMsg: 'Terminal-Sitzung ist abgelaufen, bitte erneut verbinden',
genericFailMsg: (p) => `Fehlgeschlagen: ${p.err}`,
autoExecutedMsg: 'Automatisch ausgeführt (risikoarmer Befehl)',
filledWaitEnterMsg: 'In das Terminal-Eingabefeld eingefügt — bitte manuell Enter drücken, um zu bestätigen',
highRiskConfirmMsg: 'Risikoreicher Befehl, erfordert Bestätigung (60 Sekunden gültig) ',
confirmBtnText: 'Bestätigen & ausführen',
unverifiedCopyOnlyMsg: 'Dieser Eintrag ist nicht verifiziert — nur Kopieren und manuelles Ausführen möglich',
requestFailedMsg: (p) => `Anfrage fehlgeschlagen: ${p.err}`,
confirmingMsg: 'Wird bestätigt…',
filledConfirmedMsg: 'In das Terminal eingefügt — bitte manuell Enter drücken, um zu bestätigen (risikoreiche Befehle erfordern immer eine manuelle Bestätigung)',
confirmFailedMsg: (p) => `Bestätigung fehlgeschlagen: ${p.err}`
},
ru: {
code: 'RU',
pageTitle: 'NodeNanny · Терминал',
backLink: '← Назад на главную',
title: 'Удалённая оболочка',
statusIdle: 'Заблокировано',
statusVerifying: 'Проверка…',
statusUnlockedConnecting: (p) => `Разблокировано (действует ${p.minutes} мин.), подключение…`,
statusConnectingReady: 'Подключено, ожидание сессии…',
statusConnected: 'Подключено',
statusClosed: 'Соединение закрыто',
statusError: 'Ошибка соединения',
statusPasswordError: (p) => `Неверный пароль или терминал отключён: ${p.detail}`,
statusRequestFailed: (p) => `Ошибка запроса: ${p.detail}`,
idleTimeoutMsg: '\r\n[Сессия закрыта из-за бездействия — разблокируйте снова]\r\n',
memoBtn: 'Шпаргалка',
memoBtnTitle: 'Доступно после подключения: команды исправления из базы знаний',
unlockLabel: 'Пароль терминала',
unlockBtn: 'Разблокировать и подключиться',
placeholderTitle: 'Терминал не подключён',
placeholderSub: 'Введите пароль терминала выше и нажмите «Разблокировать и подключиться», чтобы открыть сессию удалённой оболочки здесь.',
footer: 'Это удалённая оболочка сервера, за которым следит NodeNanny — команды здесь выполняются так же, как при прямом входе на сервер, будьте осторожны. При проблемах сначала проверьте «Шпаргалку» в правом верхнем углу с проверенными командами исправления, а не набирайте по памяти; команды с пометкой «не проверено вручную» можно только скопировать и вставить/подтвердить вручную. Сессия автоматически отключается после периода бездействия — просто разблокируйте снова, чтобы продолжить.',
memoSearchPlaceholder: 'Поиск по базе знаний (заголовок/ключевое слово)...',
memoLoading: 'Загрузка…',
memoLoadFailed: (p) => `Не удалось загрузить базу знаний: ${p.msg}`,
memoNoMatch: 'Нет подходящих записей в базе знаний',
memoAutoMatchLabel: 'Автоматически найдено по недавнему сбою',
memoAllEntriesLabel: 'Все записи базы знаний',
riskLabels: { low: 'низкий', medium: 'средний', high: 'высокий', 'copy-only': 'только копирование' },
runBtn: 'Вставить в терминал',
copyBtn: 'Копировать (не проверено вручную, нельзя выполнить в один клик)',
copiedMsg: 'Скопировано — вставьте в терминал и подтвердите вручную',
copyFailedMsg: 'Не удалось скопировать — выделите текст команды вручную',
needConnectMsg: 'Сначала разблокируйте и подключите терминал',
writingMsg: 'Запись…',
busyTypingMsg: 'Терминал занят (вы печатаете вручную) — вставьте команду вручную',
sessionExpiredMsg: 'Сессия терминала истекла, переподключитесь',
genericFailMsg: (p) => `Ошибка: ${p.err}`,
autoExecutedMsg: 'Выполнено автоматически (низкий риск)',
filledWaitEnterMsg: 'Вставлено в поле ввода терминала — нажмите Enter вручную для подтверждения',
highRiskConfirmMsg: 'Команда высокого риска, требуется подтверждение (действительно 60 секунд) ',
confirmBtnText: 'Подтвердить и выполнить',
unverifiedCopyOnlyMsg: 'Эта запись не проверена вручную — только копирование и ручное выполнение',
requestFailedMsg: (p) => `Ошибка запроса: ${p.err}`,
confirmingMsg: 'Подтверждение…',
filledConfirmedMsg: 'Вставлено в терминал — нажмите Enter вручную для подтверждения (команды высокого риска всегда требуют ручного подтверждения)',
confirmFailedMsg: (p) => `Ошибка подтверждения: ${p.err}`
}
};
const SUPPORTED_LANGS = ['zh', 'en', 'ja', 'de', 'ru'];
let LANG = localStorage.getItem('nodenanny-lang') || (navigator.language || 'zh').slice(0, 2);
if (!SUPPORTED_LANGS.includes(LANG)) LANG = 'zh';
function tt() { return TERM_I18N[LANG]; }
// KB条目的title从纯字符串改成了{zh,en,ja,de,ru}多语言对象(跟explanation结构一致);
// 兼容远程同步/AI生成等还没来得及跟进的条目仍是字符串的情况,直接原样返回。
function localizedTitle(title) {
if (!title) return '';
if (typeof title === 'string') return title;
return title[LANG] || title.zh || title.en || '';
}
// 把静态外壳文案(标题/按钮/占位提示/页脚等)按当前LANG重新渲染一遍。
// 打开页面时调用一次;切换语言时也要重新调用,否则按钮上的字不会跟着变。
function applyStaticText() {
const d = tt();
document.title = d.pageTitle;
document.documentElement.lang = LANG;
document.getElementById('term-back-text').textContent = d.backLink;
document.getElementById('term-title').textContent = d.title;
document.getElementById('memo-btn-text').textContent = d.memoBtn;
document.getElementById('memo-btn').title = d.memoBtnTitle;
document.getElementById('unlock-label').textContent = d.unlockLabel;
document.getElementById('unlock-btn-text').textContent = d.unlockBtn;
document.getElementById('term-placeholder-title').textContent = d.placeholderTitle;
document.getElementById('term-placeholder-sub').textContent = d.placeholderSub;
document.getElementById('term-footer').textContent = d.footer;
document.getElementById('memoSearch').placeholder = d.memoSearchPlaceholder;
// 状态徽章文字:只在还没连接过(idle)时才跟着切语言重置,避免语言切换
// 打断一次正在进行/已完成的连接(比如已经"已连接"了,不该被切语言打回"未解锁")。
const badge = document.getElementById('status-badge');
if (badge.className.includes('state-idle')) {
document.getElementById('status-msg').textContent = d.statusIdle;
}
}
function buildLangSwitch() {
const el = document.getElementById('lang-switch');
el.innerHTML = Object.keys(TERM_I18N).map((code) =>
`<button data-lang="${code}" class="${code === LANG ? 'active' : ''}">${TERM_I18N[code].code}</button>`
).join('');
el.querySelectorAll('button').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.lang === LANG) return;
LANG = btn.dataset.lang;
localStorage.setItem('nodenanny-lang', LANG);
buildLangSwitch();
applyStaticText();
// 知识库速查表如果已经加载过条目,也要跟着重新渲染一遍
// (title字段本身不分语言,但explanation/分组标签/风险标签都要跟着变)。
if (typeof kbEntries !== 'undefined' && kbEntries.length > 0) {
renderMemoList(document.getElementById('memoSearch').value);
}
});
});
}
buildLangSwitch();
applyStaticText();
const term = new Terminal({
convertEol: true,
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 13,
theme: { background: '#0c1214', foreground: '#e4dcc8', cursor: '#c8963e' }
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
term.open(document.getElementById('terminal-container'));
// 2026-07-30修复:真实bug——term.onData()的注册原本写在connectWebSocket()
// 函数体内部(见下方原本817行附近)。term这个xterm实例本身在整个页面生命周期
// 里只创建一次,但connectWebSocket()会在每次"解锁并连接"、以及每次断线后
// 用户重新解锁重连时被再次调用——而term.onData()每调一次就会再挂一个新的
// 监听器,旧的那个从来没有被移除。于是:只连接过一次的会话完全正常(只有
// 一个监听器);但凡重连过一次,后续每一次按键或粘贴,onData都会被触发
// 两次(两个监听器各发一次),表现出来就是"粘贴的内容被插入了两遍、中间
// 没有换行"——这正是founder反馈的那个bug。重连第二次会变成三个监听器、
// 三次重复,以此类推。
// 修复方式:把onData的注册挪到这里,只在term创建时执行一次,处理函数内部
// 通过闭包读取外层的ws变量(跟原来完全一样的判断:ws存在且readyState是
// OPEN才发送),不需要access到connectWebSocket()内部的任何东西,可以安全
// 挪出来。
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }));
}
});
// 之前这里是 term.open() 之后立刻同步调用 fitAddon.fit()——这个bug会导致
// xterm 在容器还没有完成真正的flex布局分配(此时读到的宽度可能是0或者
// 不准确的初始值)时就去计算列数,算出"1列、高度撑满"这种畸形尺寸,
// 视觉上就是一条铺满高度的白色竖条(那其实是被拉伸变形的光标块)。
// 改成等两帧动画帧之后(确保浏览器已经完成一次真正的布局/绘制)再fit,
// 保险起见resize时也一样处理。
requestAnimationFrame(() => {
requestAnimationFrame(() => fitAddon.fit());
});
let ws = null;
let currentSessionId = null;
// 状态徽章:state-idle(灰,未解锁)/ state-connecting(琥珀,脉动)/
// state-connected(青绿,脉动更慢)/ state-error(锈红,静止),
// 只是纯视觉层的状态展示,不影响下面任何一条已有的解锁/连接逻辑。
function setStatusBadge(state, text) {
const badge = document.getElementById('status-badge');
badge.className = `term-status-badge state-${state}`;
document.getElementById('status-msg').textContent = text;
}
async function unlockAndConnect() {
const password = document.getElementById('terminal-password').value;
const btn = document.getElementById('unlock-btn');
btn.disabled = true;
setStatusBadge('connecting', tt().statusVerifying);
try {
const resp = await fetch('/api/terminal/unlock', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password })
});
const data = await resp.json();
if (!data.ok) {
setStatusBadge('error', tt().statusPasswordError({ detail: data.error || '' }));
btn.disabled = false;
return;
}
setStatusBadge('connecting', tt().statusUnlockedConnecting({ minutes: data.expiresInMinutes }));
connectWebSocket();
} catch (err) {
setStatusBadge('error', tt().statusRequestFailed({ detail: err.message }));
btn.disabled = false;
}
}
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${location.host}/ws/terminal`);
ws.onopen = () => {
setStatusBadge('connecting', tt().statusConnectingReady);
// 首次连接后立刻同步一次终端尺寸
sendResize();
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'ready') {
currentSessionId = msg.sessionId;
document.getElementById('memo-btn').disabled = false;
document.getElementById('unlock-row').classList.add('hidden');
document.getElementById('term-placeholder').classList.add('hidden');
setStatusBadge('connected', tt().statusConnected);
console.log('终端会话ID(知识库写入命令时需要用到):', currentSessionId);
} else if (msg.type === 'output') {
term.write(msg.data);
} else if (msg.type === 'idle_timeout') {
term.write(tt().idleTimeoutMsg);
}
};
ws.onclose = () => {
setStatusBadge('error', tt().statusClosed);
document.getElementById('unlock-btn').disabled = false;
document.getElementById('unlock-row').classList.remove('hidden');
document.getElementById('term-placeholder').classList.remove('hidden');
document.getElementById('memo-btn').disabled = true;
currentSessionId = null;
};
ws.onerror = () => {
setStatusBadge('error', tt().statusError);
};
}
function sendResize() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
fitAddon.fit();
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
}
window.addEventListener('resize', sendResize);
document.getElementById('unlock-btn').addEventListener('click', unlockAndConnect);
// ---------- 知识库速查表:终端内直接查看修复命令,不用跳转到wiki页面 ----------
const memoBtn = document.getElementById('memo-btn');
const memoOverlay = document.getElementById('memoOverlay');
const memoCloseBtn = document.getElementById('memoCloseBtn');
const memoSearch = document.getElementById('memoSearch');
const memoList = document.getElementById('memoList');
let kbEntries = [];
// 最近一次故障事件里知识库命中的条目(完整对象,从kbEntries里按id挑出来,
// 这样能拿到title/verified/matchPatterns这些渲染需要、但/api/status里
// 事件本身不带的字段)。每次打开速查表都重新拉一次,不缓存太久——
// 故障状态是会变的,用户这次打开跟上次打开之间node可能已经好了。
let autoMatchedEntries = [];
// 超过这个时间的故障不再自动置顶:几天前修好的老问题不该被当成"正在发生"
// 摆在最前面误导用户。24小时是个先定的默认值,创始人如果觉得太长/太短
// 可以再改。
const AUTO_MATCH_RECENCY_MS = 24 * 60 * 60 * 1000;
async function loadAutoMatchedKb() {
try {
const resp = await fetch('/api/status');
const data = await resp.json();
const events = data.events || [];
const now = Date.now();
// /api/status返回的events已经是按时间倒序(store.js用unshift写入),
// 找到第一条(=最新一条)带非空kb字段、且没超出时间窗口的故障事件即可。
const recentKbEvent = events.find((e) => {
if (!e.params || !Array.isArray(e.params.kb) || e.params.kb.length === 0) return false;
const eventTime = new Date(e.time).getTime();
return Number.isFinite(eventTime) && (now - eventTime) <= AUTO_MATCH_RECENCY_MS;
});
if (!recentKbEvent) {
autoMatchedEntries = [];
return;
}
const matchedIds = new Set(recentKbEvent.params.kb.map((k) => k.id));
autoMatchedEntries = kbEntries.filter((entry) => matchedIds.has(entry.id));
} catch (err) {
// 拉取失败就退回普通全量列表,不影响速查表本身可用——自动匹配是锦上添花,
// 不是速查表能不能用的前提条件。
autoMatchedEntries = [];
}
}
function escapeHtml(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
}
function riskBadge(risk) {
const map = { low: 'risk-low', medium: 'risk-medium', high: 'risk-high' };
const cls = map[risk] || 'risk-copy';
const label = (tt().riskLabels && tt().riskLabels[risk]) || risk;
return `<span class="risk-badge ${cls}">${label}</span>`;
}
// 单条知识库条目的渲染逻辑,抽成独立函数,供下面"自动匹配置顶区块"和
// "普通搜索列表"共用,避免两处渲染逻辑长出两份、以后改一处忘了改另一处。
function renderEntry(entry, opts) {
const verified = entry.verified === true;
const explain = (entry.explanation && entry.explanation[LANG]) || '';
const cmdRows = (entry.fixCommands || []).map((cmd, idx) => {
const cmdId = `${entry.id}__${idx}`;
const actionBtn = verified
? `<button data-cmd-id="${cmdId}" class="memo-run-btn">${escapeHtml(tt().runBtn)}</button>`
: `<button data-cmd-id="${cmdId}" class="memo-copy-btn secondary">${escapeHtml(tt().copyBtn)}</button>`;
return `<div class="memo-cmd-row">
<code id="code-${cmdId}">${escapeHtml(cmd)}</code>
${actionBtn}
<span class="memo-cmd-status" id="status-${cmdId}"></span>
</div>`;
}).join('');
const cls = (opts && opts.autoMatched) ? 'memo-entry memo-auto-matched' : 'memo-entry';
return `<div class="${cls}">
<div class="memo-title">${escapeHtml(localizedTitle(entry.title))} ${riskBadge(entry.riskLevel)} ${verified ? '' : riskBadge('copy-only')}</div>
<div class="memo-explain">${escapeHtml(explain)}</div>
${cmdRows}
</div>`;
}
function renderMemoList(filterText) {
const q = (filterText || '').trim().toLowerCase();
// 没有搜索词、且有最近故障自动匹配到的条目时:把这些条目置顶展示,
// 下面接着列出完整知识库列表(去重,不重复出现)。用户一旦开始搜索
// (q非空),就回到纯搜索模式——搜索是用户主动在找别的东西,不该被置顶
// 逻辑打扰。
if (!q && autoMatchedEntries.length > 0) {
const pinnedIds = new Set(autoMatchedEntries.map((e) => e.id));
const rest = kbEntries.filter((e) => !pinnedIds.has(e.id));
memoList.innerHTML =
`<div class="memo-section-label">${escapeHtml(tt().memoAutoMatchLabel)}</div>` +
autoMatchedEntries.map((e) => renderEntry(e, { autoMatched: true })).join('') +
(rest.length ? `<div class="memo-section-label">${escapeHtml(tt().memoAllEntriesLabel)}</div>` + rest.map((e) => renderEntry(e)).join('') : '');
return;
}
const filtered = !q ? kbEntries : kbEntries.filter((e) => {
const hay = `${localizedTitle(e.title)} ${(e.explanation && e.explanation[LANG]) || ''} ${(e.matchPatterns || []).join(' ')}`.toLowerCase();
return hay.includes(q);
});
if (!filtered.length) {
memoList.innerHTML = `<div style="color:#8a8f8a;padding:12px 0;">${escapeHtml(tt().memoNoMatch)}</div>`;
return;
}
memoList.innerHTML = filtered.map((e) => renderEntry(e)).join('');
// 事件委托:一次性绑定,避免每次搜索重新渲染都重复addEventListener
}
memoList.addEventListener('click', async (e) => {
const runBtn = e.target.closest('.memo-run-btn');
const copyBtn = e.target.closest('.memo-copy-btn');
if (!runBtn && !copyBtn) return;
const btn = runBtn || copyBtn;
const cmdId = btn.getAttribute('data-cmd-id');
const [entryId, idxStr] = cmdId.split('__');
const entry = kbEntries.find((e2) => e2.id === entryId);
const command = entry && entry.fixCommands[Number(idxStr)];
const statusEl = document.getElementById(`status-${cmdId}`);
if (!command) return;
if (copyBtn) {
try {
await navigator.clipboard.writeText(command);
statusEl.textContent = tt().copiedMsg;
} catch (err) {
statusEl.textContent = tt().copyFailedMsg;
}
return;
}
if (!currentSessionId) {
statusEl.textContent = tt().needConnectMsg;
return;
}
statusEl.textContent = tt().writingMsg;
try {
const resp = await fetch('/api/terminal/write-command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: currentSessionId, command, riskLevel: entry.riskLevel, verified: entry.verified === true })
});
const data = await resp.json();
if (!data.ok) {
const msgs = { terminal_busy_user_typing: tt().busyTypingMsg, session_not_found: tt().sessionExpiredMsg };
statusEl.textContent = msgs[data.error] || tt().genericFailMsg({ err: data.error });
return;
}
if (data.risk === 'low' && data.executed) {
statusEl.textContent = tt().autoExecutedMsg;
} else if (data.risk === 'medium') {
statusEl.textContent = tt().filledWaitEnterMsg;
} else if (data.confirmToken) {
statusEl.innerHTML = `${escapeHtml(tt().highRiskConfirmMsg)}<button class="memo-confirm-btn" data-session-token="${currentSessionId}::${data.confirmToken}">${escapeHtml(tt().confirmBtnText)}</button>`;
} else if (data.note === 'unverified_entry_copy_only') {
statusEl.textContent = tt().unverifiedCopyOnlyMsg;
}
} catch (err) {
statusEl.textContent = tt().requestFailedMsg({ err: err.message });
}
});
// 高危命令的"确认执行"按钮是动态插入的innerHTML,同样走事件委托处理点击
memoList.addEventListener('click', async (e) => {
const confirmBtn = e.target.closest('.memo-confirm-btn');
if (!confirmBtn) return;
const [sessionId, confirmToken] = confirmBtn.getAttribute('data-session-token').split('::');
confirmBtn.disabled = true;
confirmBtn.textContent = tt().confirmingMsg;
try {
const resp = await fetch('/api/terminal/confirm-command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, confirmToken })
});
const data = await resp.json();
confirmBtn.parentElement.textContent = data.ok
? tt().filledConfirmedMsg
: tt().confirmFailedMsg({ err: data.error });
} catch (err) {
confirmBtn.parentElement.textContent = tt().requestFailedMsg({ err: err.message });
}
});
async function openMemo() {
memoOverlay.classList.add('open');
if (kbEntries.length === 0) {
memoList.textContent = tt().memoLoading;
try {
const resp = await fetch('/api/kb/list');
const data = await resp.json();
kbEntries = data.entries || [];
} catch (err) {
memoList.textContent = tt().memoLoadFailed({ msg: err.message });
return;
}
}
await loadAutoMatchedKb();
renderMemoList(memoSearch.value);
}
memoBtn.addEventListener('click', openMemo);
memoCloseBtn.addEventListener('click', () => memoOverlay.classList.remove('open'));
memoOverlay.addEventListener('click', (e) => { if (e.target === memoOverlay) memoOverlay.classList.remove('open'); });
memoSearch.addEventListener('input', () => renderMemoList(memoSearch.value));
</script>
</body>
</html>