码桶

发现社区成员的开源项目

main
NodeNanny/public/terminal.html
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">&times;</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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>