码桶
发现社区成员的开源项目
index.html173.6 KB
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no">
<title>NodeNanny</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=2">
<link rel="shortcut icon" type="image/png" href="/assets/favicon.png?v=2">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;1,9..144,500&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
: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;
--unknown: #5b6265;
--manual: #4d7fa8;
--manual-soft: #7ba7cc;
--mortar: rgba(200, 150, 62, 0.07);
--relic-glow: rgba(200, 150, 62, 0.18);
--patina-mid: #8a7a45;
--surface-glass: rgba(22, 30, 33, 0.64);
/* 四角铜制包边构件,抽成变量,方便后面按语言叠加不同的文化背景层而不用重复写四遍 */
--corner-brackets:
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 13 V1 H13' fill='none' stroke='%23c8963e' stroke-width='1.4'/%3E%3Ccircle cx='1' cy='1' r='1.6' fill='%23c8963e'/%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M15 13 V1 H3' fill='none' stroke='%23c8963e' stroke-width='1.4'/%3E%3Ccircle cx='15' cy='1' r='1.6' fill='%23c8963e'/%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M1 3 V15 H13' fill='none' stroke='%23c8963e' stroke-width='1.4'/%3E%3Ccircle cx='1' cy='15' r='1.6' fill='%23c8963e'/%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M15 3 V15 H3' fill='none' stroke='%23c8963e' stroke-width='1.4'/%3E%3Ccircle cx='15' cy='15' r='1.6' fill='%23c8963e'/%3E%3C/svg%3E");
--corner-positions: top 10px left 10px, top 10px right 10px, bottom 10px left 10px, bottom 10px right 10px;
--corner-sizes: 16px 16px, 16px 16px, 16px 16px, 16px 16px;
--corner-repeats: no-repeat, no-repeat, no-repeat, no-repeat;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
min-height: 100vh;
/* 2026-08-04:跟wiki.html同一处理由——微信/QQ内置浏览器(X5内核)偶尔算错画布
宽度导致布局没能按手机宽度收起来,这里做兜底,不让正文因此被横向裁掉。 */
overflow-x: hidden;
background:
radial-gradient(ellipse 1100px 640px at 78% -8%, #1c2620 0%, transparent 55%),
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',
'Microsoft YaHei', sans-serif;
-webkit-font-smoothing: antialiased;
position: relative;
/* 关键一行:position:relative 配 z-index:auto 不会让 body 自己开一个新的层叠
上下文,body::before/::after 里 z-index:-1 的打光层/齿轮水印会因此"穿透"到
更外层的层叠上下文里排位置,反而排在 body 自己的不透明背景后面、被盖住
(跟铆钉那次是同一类"缺一个显式层叠上下文"的问题,只是这次是 body 自己)。
显式给个 z-index,让 body 自己开一个层叠上下文,::before/::after 才会在
"body 自身背景"和"卡片等正常内容"两层之间正确显示出来。 */
z-index: 0;
}
/* ---------- 石砌背景层 + 按语言切换的"打光"效果 ----------
之前那五套纹样 SVG(云雷纹/青海波/锻铁涡卷/木结构方格/洋葱顶)本次整体
放弃——不是纹样本身画错了,而是"手绘出让人一眼认出文化归属的具体图形"这
条路本来就在更早期会话里被创始人三轮打回过一次(当时是建筑剪影),这次
纹样虽然改成了抽象回旋纹,本质上还是同一类"用线条图案表意"的路线,容易
做得单薄。改成更简单、更不容易翻车的思路:不再画图案,而是给整个页面
背景打一层有方向、有颜色的"光"——五种语言只是打光的方位角和颜色不同,
像是同一座灯塔在不同季节/角度被太阳照到的样子,而不是五套互不相关的图形。
颜色沿用原来纹样定下的那五个"青铜氧化阶段"色系(不重新拍板配色),
方向按五个不同方位分布,一眼能区分出当前是哪种语言:
zh 中文 → 左上方打光,亮金色
ja 日文 → 右上方打光,铜绿色(呼应原本纹样里"海面反光"这个意象)
en 英文 → 左侧偏中打光,暖铜色
de 德文 → 左下方打光,深古铜棕
ru 俄文 → 右下方打光,琥珀铜色
实现方式:body::before 是一层固定定位的大椭圆径向渐变(光源)+ 一层极淡的
石材纹理(保留原有质感,跟语言无关,一直存在)。语言切换时只是通过
html[lang=...] 覆盖 --light-x/--light-y/--light-color 这三个自定义属性,
不需要新增任何 JS,document.documentElement.lang 早就在切换时同步更新了。 */
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: -1;
pointer-events: none;
--light-x: 15%;
--light-y: 8%;
--light-color: rgba(224, 182, 103, 0.16);
background-repeat: no-repeat, repeat;
background-position: center, center;
background-size: cover, 240px 240px;
background-image:
radial-gradient(ellipse 60% 50% at var(--light-x) var(--light-y), var(--light-color) 0%, transparent 68%),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Cline x1='14' y1='14' x2='14' y2='186' stroke='%23c8963e' stroke-width='1' stroke-opacity='0.28'/%3E%3Crect x='84' y='84' width='32' height='32' fill='%23c8963e' fill-opacity='0.12'/%3E%3Cpath d='M28,28 H172 V172 H64 V64 H136 V136 H100' fill='none' stroke='%23c8963e' stroke-width='1' stroke-opacity='0.3' stroke-linejoin='round'/%3E%3C/svg%3E");
}
html[lang="zh"] body::before { --light-x: 15%; --light-y: 8%; --light-color: rgba(224, 182, 103, 0.16); }
html[lang="ja"] body::before { --light-x: 88%; --light-y: 10%; --light-color: rgba(111, 160, 143, 0.16); }
html[lang="en"] body::before { --light-x: 8%; --light-y: 55%; --light-color: rgba(185, 138, 82, 0.16); }
html[lang="de"] body::before { --light-x: 14%; --light-y: 94%; --light-color: rgba(122, 92, 52, 0.18); }
html[lang="ru"] body::before { --light-x: 90%; --light-y: 90%; --light-color: rgba(193, 122, 58, 0.17); }
/* 大尺寸齿轮水印,压在页面最外层留白处(宽屏时 .page 两侧的空白),
极低透明度,只在宽屏能瞥见一角,起"填补空旷"的作用,不会跟卡片内容打架。 */
body::after {
content: '';
position: fixed;
right: -120px;
bottom: -120px;
width: 460px;
height: 460px;
z-index: -1;
pointer-events: none;
opacity: 0.05;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23c8963e' stroke-width='2.5'%3E%3Ccircle cx='50' cy='50' r='34'/%3E%3Ccircle cx='50' cy='50' r='14'/%3E%3Cg%3E%3Crect x='45' y='0' width='10' height='18'/%3E%3Crect x='45' y='82' width='10' height='18'/%3E%3Crect x='0' y='45' width='18' height='10'/%3E%3Crect x='82' y='45' width='18' height='10'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(30 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(60 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(120 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(150 50 50)'/%3E%3Crect x='45' y='82' width='10' height='18' transform='rotate(30 50 50)'/%3E%3Crect x='45' y='82' width='10' height='18' transform='rotate(60 50 50)'/%3E%3Crect x='45' y='82' width='10' height='18' transform='rotate(120 50 50)'/%3E%3Crect x='45' y='82' width='10' height='18' transform='rotate(150 50 50)'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
background-repeat: no-repeat;
background-size: contain;
}
@media (max-width: 760px) {
body::after { display: none; }
}
body {
display: flex;
justify-content: center;
padding: 40px 20px 90px;
}
.page { width: 100%; max-width: 980px; }
/* ---------- 顶栏:图腾 + 语言切换 ---------- */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 40px;
}
.brand { display: flex; align-items: center; gap: 10px; }
.brand svg { width: 26px; height: 26px; flex-shrink: 0; }
.brand-word {
font-family: 'Fraunces', serif;
font-weight: 500;
font-size: 18px;
letter-spacing: 0.01em;
}
.topbar-nav { display: flex; align-items: center; gap: 10px; }
/* 导航按钮 v2(UI改版):原来是通用的矩形描边按钮,跟语言切换的"标签式下划线"
风格不统一。改成带小图标+下划线强调的标签样式,跟 .lang-switch 的视觉语言
统一起来,不再是孤零零两个方框按钮。 */
.wiki-entry-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
padding: 5px 2px 6px;
text-decoration: none;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.wiki-entry-link svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0.85; }
.wiki-entry-link:hover { color: var(--brass-soft); border-bottom-color: var(--brass-dim); text-decoration: none; }
/* 终端入口这次改成了<button>(要挂JS打开悬浮层,不是纯跳转链接),
这里重置一下按钮默认外观,让它跟<a>版本的导航项视觉上完全一样。
本轮修复:此前只重置了background/font-family/cursor三项,浏览器给
<button>的默认边框(border)、内外边距(margin/padding)、轮廓(outline)
没有一起清掉——这些都是<a>标签天生没有、<button>天生有的默认样式,
不清掉的话终端按钮看起来还是会比旁边"Wiki百科""流量池明细"两个<a>
按钮多一圈边框、位置对不齐,这正是创始人反馈"样式比旁边丑、不统一"
的根本原因。这里补齐完整重置。 */
button.wiki-entry-link {
background: none;
font-family: inherit;
font-size: inherit;
cursor: pointer;
border: none;
margin: 0;
line-height: inherit;
-webkit-appearance: none;
appearance: none;
}
button.wiki-entry-link:focus-visible {
outline: 1px solid var(--brass-dim);
outline-offset: 2px;
}
.lang-switch { display: flex; align-items: center; }
.lang-switch button {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
letter-spacing: 0.04em;
background: transparent;
border: none;
border-right: 1px solid var(--hairline);
color: var(--text-muted);
padding: 4px 11px;
cursor: pointer;
transition: color 0.15s ease;
}
.lang-switch button:last-child { border-right: none; }
.lang-switch button:hover { color: var(--text); }
.pool-banner {
display: none;
align-items: center;
gap: 10px;
margin: 0 0 14px;
padding: 11px 14px;
border-radius: 6px;
background: rgba(192, 90, 68, 0.12);
border: 1px solid rgba(192, 90, 68, 0.38);
color: var(--rust-soft);
font-size: 13px;
line-height: 1.5;
}
.pool-banner.show { display: flex; }
/* 手动模式配色:蓝色而不是红色,让用户一眼分清"我自己切过去的"跟"出故障自动切的",
不用点开事件日志才能确认。 */
.pool-banner.manual {
background: rgba(77, 127, 168, 0.12);
border-color: rgba(77, 127, 168, 0.38);
color: var(--manual-soft);
}
.pool-banner.manual .dot {
background: var(--manual-soft);
animation: dot-breathe-manual 2.4s ease-in-out infinite;
}
@keyframes dot-breathe-manual {
0%, 100% { opacity: 0.55; box-shadow: 0 0 0 2px rgba(77, 127, 168, 0.2); }
50% { opacity: 1; box-shadow: 0 0 0 5px rgba(77, 127, 168, 0.12); }
}
.pool-manual-toggle-btn {
display: none;
margin: -6px 0 14px 2px;
padding: 6px 14px;
font-size: 12.5px;
border-radius: 5px;
background: transparent;
border: 1px solid var(--brass-dim);
color: var(--brass-soft);
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
}
.pool-manual-toggle-btn.show { display: inline-block; }
.pool-manual-toggle-btn:hover { border-color: var(--brass); }
.pool-manual-toggle-btn.active {
border-color: var(--manual);
color: var(--manual-soft);
}
.pool-manual-toggle-btn.active:hover { border-color: var(--manual-soft); }
.pool-manual-toggle-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.pool-sources-line {
margin: -8px 0 14px 2px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
}
.pool-banner .dot {
flex: none;
width: 8px; height: 8px; border-radius: 50%;
background: var(--rust-soft);
animation: dot-breathe 1.6s ease-in-out infinite;
}
@keyframes dot-breathe {
0%, 100% { opacity: 0.55; box-shadow: 0 0 0 2px rgba(192, 90, 68, 0.2); }
50% { opacity: 1; box-shadow: 0 0 0 5px rgba(192, 90, 68, 0.12); }
}
.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; }
/* ---------- 主体:左文字 / 右仪表景 ---------- */
.hero {
position: relative;
display: grid;
grid-template-columns: 1.05fr 0.95fr;
background: var(--surface);
border: 3px solid var(--hairline);
border-radius: 6px;
box-shadow: 0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08);
overflow: visible;
}
.hero-text {
position: relative;
padding: 44px 36px 34px 42px;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
/* 左侧铜色书脊条,填补纯色留白 */
.hero-text::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0; width: 3px;
background: linear-gradient(180deg, transparent, var(--brass-dim) 30%, var(--brass-dim) 70%, transparent);
}
/* 极淡的罗盘水印,装饰但不干扰阅读 */
.compass-mark {
position: absolute;
right: -30px; bottom: -30px;
width: 220px; height: 220px;
opacity: 0.06;
pointer-events: none;
}
.node-label {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
letter-spacing: 0.06em;
color: var(--text-muted);
text-transform: uppercase;
margin: 0 0 14px;
}
.status-line {
font-family: 'Fraunces', serif;
font-weight: 500;
font-size: 34px;
line-height: 1.15;
margin: 0 0 12px;
position: relative;
transition: color 0.4s ease;
}
body.status-ok .status-line { color: var(--brass-soft); }
body.status-down .status-line { color: var(--rust-soft); }
body.status-unknown .status-line { color: var(--unknown); }
.duration-line { color: var(--text-muted); font-size: 14.5px; margin: 0 0 32px; }
.duration-line strong { color: var(--text); font-weight: 500; }
.stat-grid {
position: relative;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px 16px;
border-top: 1px solid var(--hairline);
padding-top: 22px;
}
/* 数字滚动容器:数值变化时旧数字往上滑出、新数字滑入,而不是瞬间跳变 */
.num-roll {
display: inline-block;
height: 24px;
overflow: hidden;
position: relative;
vertical-align: top;
}
.num-roll .slide {
display: block;
font-family: 'IBM Plex Mono', monospace;
font-size: 20px;
font-variant-numeric: tabular-nums;
transition: transform 0.5s cubic-bezier(.2,.8,.2,1);
}
.stat .label { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
.stat .label-detail { font-size: 10px; color: var(--text-muted); opacity: 0.75; margin-top: 1px; }
/* 发现17 修复:面板连不上时,把统计数字/守望记录整体标记成"可能已过时",
而不是只改一行提示文字、让下面的旧数字看起来像是刚刚发生的一样。 */
.stale-banner {
display: none;
font-size: 12.5px;
color: var(--rust-soft, #c97b63);
border: 1px dashed var(--rust-soft, #c97b63);
border-radius: 6px;
padding: 6px 10px;
margin: 8px 0 18px;
}
.stale-banner.show { display: block; }
.is-stale { opacity: 0.45; filter: grayscale(0.4); transition: opacity 0.3s, filter 0.3s; }
/* ---------- 仪表景:签名视觉(信号灯仪表盘,替代原来的灯塔风景插画) ---------- */
.hero-scene {
position: relative;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
border-left: 3px solid var(--hairline);
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
background: radial-gradient(circle at 50% 40%, #241d13 0%, #171f22 55%, #171f22 100%);
}
.hero-scene svg { width: 100%; height: 100%; display: block; max-width: 320px; max-height: 320px; }
.horizon-line { position: absolute; left: 0; right: 0; bottom: 32%; height: 1px; background: var(--hairline); }
/* 灯塔仪表盘 v2(UI改版·灯塔):创始人明确要求去掉光点下方的塔状/钥匙孔剪影,
只保留一个纯粹的信号灯光源,外圈改成更繁复的钟表式刻度环+四角包边装饰,
方向定稿见 lighthouse-concept-preview.html 概念稿。原来的三段收分石座剪影
(.hero-scene::before,呼应"立在石头上"着地感的塔状图形)到此彻底移除。 */
/* ---------- 转动的齿轮:大小两枚咬合,挪到雷达表盘(.hero-scene)右下角 ----------
之前的版本让齿轮圆心卡在整个 NODE 卡片的下边框线上,一半用 clip-path 裁出
"模糊+调暗"冒充"在毛玻璃后面",一半裁出"清晰"冒充"探出面板"——这个裁切法
本身就是"毛玻璃转动时穿帮"这个 bug 的根源:clip-path 硬切的那条分界线不会
跟着 backdrop-filter 的实际虚化边界走,转起来两半衔接处必然会看出破绽。
这版直接放弃这个"以假乱真"的思路,改成结构上更简单也更合理的做法:
齿轮完全挪进雷达表盘(.hero-scene)内部——表盘背景本身是不透明的深色渐变,
不是毛玻璃,齿轮在这里转动不会跟任何 backdrop-filter 产生冲突,"穿帮"这个
问题不是被掩盖,是从结构上不会再发生。大小两枚齿轮中心距离刻意小于两个
半径之和,制造"咬合镶嵌"的观感,转速方向相反(大齿轮带小齿轮)。 */
.gear-cluster {
position: absolute;
right: 4px;
bottom: 4px;
width: 68px;
height: 68px;
pointer-events: none;
}
.gear-a, .gear-b {
position: absolute;
background-repeat: no-repeat;
background-size: contain;
opacity: 0.5;
will-change: transform;
}
.gear-a {
right: 0; bottom: 0;
width: 40px; height: 40px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23c8963e' stroke-width='5'%3E%3Ccircle cx='50' cy='50' r='30'/%3E%3Ccircle cx='50' cy='50' r='12'/%3E%3Cg%3E%3Crect x='46' y='2' width='8' height='16'/%3E%3Crect x='46' y='82' width='8' height='16'/%3E%3Crect x='2' y='46' width='16' height='8'/%3E%3Crect x='82' y='46' width='16' height='8'/%3E%3Crect x='46' y='2' width='8' height='16' transform='rotate(45 50 50)'/%3E%3Crect x='46' y='82' width='8' height='16' transform='rotate(45 50 50)'/%3E%3Crect x='2' y='46' width='16' height='8' transform='rotate(45 50 50)'/%3E%3Crect x='82' y='46' width='16' height='8' transform='rotate(45 50 50)'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
animation: gear-spin 14s linear infinite;
}
.gear-b {
right: 28px; bottom: 28px;
width: 26px; height: 26px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Cg fill='none' stroke='%23c8963e' stroke-width='6'%3E%3Ccircle cx='50' cy='50' r='28'/%3E%3Ccircle cx='50' cy='50' r='10'/%3E%3Cg%3E%3Crect x='45' y='0' width='10' height='18'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(60 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(120 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(180 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(240 50 50)'/%3E%3Crect x='45' y='0' width='10' height='18' transform='rotate(300 50 50)'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
animation: gear-spin-rev 9s linear infinite;
}
@keyframes gear-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes gear-spin-rev { from { transform: rotate(0deg); } to { transform: rotate(-360deg); } }
@media (prefers-reduced-motion: reduce) {
.gear-a, .gear-b { animation: none; }
}
/* 光束 v2(UI改版·灯塔):原来是单道扇形光束旋转,创始人要求改成"快慢两道扫描光束,
类似钟表时针分针叠加"——两条细光束方向相反、速度不同地持续旋转,比单道扇形更有
"精密仪表"的感觉,也呼应外圈新增的钟表式刻度环。异常时两道都转得更急促。 */
.sweep-slow, .sweep-fast { transform-origin: 150px 150px; opacity: 0; }
body.status-ok .sweep-slow { opacity: 1; animation: rotate-beam 8s linear infinite; }
body.status-ok .sweep-fast { opacity: 1; animation: rotate-beam-rev 3s linear infinite; }
body.status-down .sweep-slow { opacity: 1; animation: rotate-beam 3.4s linear infinite; }
body.status-down .sweep-fast { opacity: 1; animation: rotate-beam-rev 1.4s linear infinite; }
body.status-unknown .sweep-slow, body.status-unknown .sweep-fast { opacity: 0; animation: none; }
@keyframes rotate-beam {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes rotate-beam-rev {
from { transform: rotate(0deg); }
to { transform: rotate(-360deg); }
}
#beam-shape-slow { stroke: url(#beamLineUnknown); }
#beam-shape-fast { stroke: url(#beamLineUnknownDim); }
body.status-ok #beam-shape-slow { stroke: url(#beamLineOk); }
body.status-ok #beam-shape-fast { stroke: url(#beamLineOkDim); }
body.status-down #beam-shape-slow { stroke: url(#beamLineDown); }
body.status-down #beam-shape-fast { stroke: url(#beamLineDownDim); }
/* 刻度环:60 格钟表式细刻度,替代原来只有 4 个十字准星的简朴表盘,
体现"繁复"感的同时呼应"像钟"的整体意象;由 JS 生成,见页面底部脚本。 */
.gauge-tick { stroke: var(--brass); }
.gauge-tick.major { opacity: 0.85; }
.gauge-tick.minor { opacity: 0.32; }
/* 四角包边:呼应卡片四角已有的 .rivet 铜制包边视觉语言,这里画在表盘内部,
加密一层"繁复"感,不是简单挪用而是按表盘尺寸重新画的一套小号版本。 */
.gauge-corner { stroke: var(--brass); fill: none; opacity: 0.85; }
.gauge-corner-dot { fill: var(--brass); }
/* 灯芯:呼吸灯式脉动,代替死板圆点。异常时呼吸更急促、颜色切换到暗砖红 */
.lamp-halo { fill: url(#lampGlowUnknown); animation: breathe 3.4s ease-in-out infinite; }
body.status-ok .lamp-halo { fill: url(#lampGlowOk); animation: breathe 3.4s ease-in-out infinite; }
body.status-down .lamp-halo { fill: url(#lampGlowDown); animation: breathe 1.2s ease-in-out infinite; }
body.status-unknown .lamp-halo { animation: breathe 4.5s ease-in-out infinite; opacity: 0.45; }
@keyframes breathe { 0%, 100% { opacity: 0.5; r: 17px; } 50% { opacity: 1; r: 23px; } }
.lamp-core { fill: var(--unknown); transition: fill 0.4s ease; }
body.status-ok .lamp-core { fill: var(--brass); }
body.status-down .lamp-core { fill: var(--rust-soft); }
/* 手动模式的灯塔视觉——故意放在上面status-*规则之后,靠源码顺序覆盖:自建节点本身
状态可能还是ok(没真的挂),只是被用户手动切去用流量池了,这时候要盖掉status-ok
的金色,改成蓝色,节奏介于ok(慢/平静)和down(快/告急)之间,表示"人为、非故障"。 */
body.pool-manual .status-line { color: var(--manual-soft); }
body.pool-manual .sweep-slow { opacity: 1; animation: rotate-beam 5.5s linear infinite; }
body.pool-manual .sweep-fast { opacity: 1; animation: rotate-beam-rev 2.2s linear infinite; }
body.pool-manual #beam-shape-slow { stroke: url(#beamLineManual); }
body.pool-manual #beam-shape-fast { stroke: url(#beamLineManualDim); }
body.pool-manual .lamp-halo { fill: url(#lampGlowManual); animation: breathe 2.6s ease-in-out infinite; }
body.pool-manual .lamp-core { fill: var(--manual); }
@media (prefers-reduced-motion: reduce) {
.sweep-slow, .sweep-fast, .lamp-halo {
animation: none !important;
}
}
/* ---------- 守望记录(事件时间线) ---------- */
.log {
margin-top: 22px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 6px;
padding: 26px 32px 10px;
}
.log h2 {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 16px;
color: var(--text-muted);
margin: 0 0 16px;
display: flex;
align-items: baseline;
gap: 8px;
}
.log-tz-label {
font-family: 'IBM Plex Mono', monospace;
font-style: normal;
font-size: 11px;
color: var(--text-faint, var(--text-muted));
opacity: 0.7;
}
.log-item {
display: grid;
grid-template-columns: 100px 20px 1fr;
gap: 0 12px;
align-items: baseline;
padding: 13px 0;
border-top: 1px solid var(--hairline);
font-size: 13.5px;
}
.log-item:first-of-type { border-top: none; }
.log-time {
font-family: 'IBM Plex Mono', monospace;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
font-size: 12px;
}
.log-tick { color: var(--text-muted); text-align: center; }
.log-item.type-down .log-tick { color: var(--rust-soft); }
.log-item.type-recovered .log-tick { color: var(--brass-soft); }
.log-item.type-restart .log-tick, .log-item.type-restart_failed .log-tick { color: var(--teal-soft); }
.log-msg { color: var(--text); }
.empty-log { color: var(--text-muted); font-size: 13.5px; padding: 6px 0 22px; }
/* ---------- 终端悬浮层(UI改版·容器方式)----------
悬浮层本身只提供"边框+关闭按钮"这层外壳,终端页面自己的顶部状态栏/
解锁行/输出区/速查表弹窗全部在iframe内部,跟外壳完全独立,互不影响。 */
.terminal-overlay {
display: none;
position: fixed;
inset: 0;
z-index: 300;
align-items: center;
justify-content: center;
padding: 24px;
}
.terminal-overlay.open { display: flex; }
.terminal-overlay-backdrop {
position: absolute;
inset: 0;
background: rgba(8, 10, 13, 0.72);
}
.terminal-overlay-panel {
position: relative;
width: min(920px, 100%);
height: min(680px, 100%);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 10px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5), inset 0 0 0 1px rgba(232,196,119,0.06);
overflow: hidden;
display: flex;
}
.terminal-overlay-frame {
flex: 1;
width: 100%;
height: 100%;
border: none;
background: #0c1214;
}
.terminal-overlay-close {
position: absolute;
top: 8px;
right: 10px;
z-index: 1;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--hairline);
background: rgba(12, 18, 20, 0.72);
color: var(--text-muted);
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.terminal-overlay-close:hover { color: var(--brass-soft); border-color: var(--brass-dim); }
@media (max-width: 640px) {
.terminal-overlay { padding: 0; }
.terminal-overlay-panel { width: 100%; height: 100%; border-radius: 0; }
}
/* ---------- AI 诊断卡片 ---------- */
.ai-card {
margin-top: 22px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 6px;
padding: 26px 32px 28px;
}
.ai-card h2 {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 16px;
color: var(--text-muted);
margin: 0 0 14px;
}
.ai-body { font-size: 13.5px; line-height: 1.7; color: var(--text); white-space: pre-wrap; }
.ai-meta { font-size: 12px; color: var(--text-muted); margin-top: 10px; font-family: 'IBM Plex Mono', monospace; }
.ai-empty { color: var(--text-muted); font-size: 13.5px; }
.ai-error { color: var(--rust-soft); font-size: 13.5px; }
.ai-disabled-hint { color: var(--text-muted); font-size: 12.5px; margin-top: 4px; }
.ai-actions { margin-top: 16px; }
.ai-diagnose-btn {
display: none;
font-family: 'IBM Plex Mono', monospace;
font-size: 12.5px;
background: transparent;
border: 1px solid var(--brass-dim);
color: var(--brass);
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.ai-diagnose-btn:hover { border-color: var(--brass); }
.ai-diagnose-btn:disabled { opacity: 0.5; cursor: default; }
.log-item.type-ai_diagnosis .log-tick { color: var(--teal-soft); }
.log-item.type-ai_diagnosis_failed .log-tick { color: var(--rust-soft); }
.log-item.type-usability_ok .log-tick { color: var(--teal-soft); }
.log-item.type-usability_down .log-tick { color: var(--rust-soft); }
.log-item.type-usability_check_error .log-tick { color: var(--brass); }
.log-item.type-usability_restart_suggested .log-tick { color: var(--brass); }
.log-item.type-usability_auto_restart .log-tick { color: var(--teal-soft); }
.log-item.type-kb_sync_applied .log-tick { color: var(--teal-soft); }
.log-item.type-wiki_sync_applied .log-tick { color: var(--teal-soft); }
/* ---------- 内容同步卡片(本轮新增:知识库/Wiki远程同步的面板入口) ---------- */
.sync-card {
margin-top: 22px;
background: var(--surface);
border: 2px solid var(--hairline);
border-radius: 6px;
padding: 26px 32px 28px;
}
.sync-card h2 {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 16px;
color: var(--text-muted);
margin: 0 0 14px;
}
.sync-section { margin-bottom: 18px; }
.sync-section:last-child { margin-bottom: 0; }
.sync-section h3 {
font-size: 13.5px;
font-weight: 600;
color: var(--text);
margin: 0 0 6px;
}
.sync-status { font-size: 12.5px; color: var(--text-muted); }
.sync-result { font-size: 12.5px; color: var(--text); margin-top: 4px; min-height: 1em; }
.sync-result.sync-error { color: var(--rust-soft); }
.sync-actions { margin-top: 10px; display: flex; gap: 10px; }
.sync-btn {
font-family: 'IBM Plex Mono', monospace;
font-size: 12.5px;
background: transparent;
border: 1px solid var(--brass-dim);
color: var(--brass);
padding: 7px 14px;
border-radius: 4px;
cursor: pointer;
}
.sync-btn:hover { border-color: var(--brass); }
.sync-btn:disabled { opacity: 0.5; cursor: default; }
.sync-btn-confirm { border-color: var(--teal-soft); color: var(--teal-soft); }
.sync-btn-confirm:hover { border-color: var(--teal); }
/* ---------- 订阅信息卡片(批次二新增) ---------- */
.sub-card {
margin-top: 22px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 6px;
padding: 26px 32px 28px;
}
.sub-card h2 {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 16px;
color: var(--text-muted);
margin: 0 0 6px;
}
.sub-lead { font-size: 13px; color: var(--text-muted); margin: 0 0 20px; line-height: 1.6; }
.sub-item { padding: 18px 0; border-top: 1px solid var(--hairline); }
.sub-item:first-of-type { padding-top: 0; border-top: none; }
.sub-item-head { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
.sub-item-label { font-size: 13.5px; color: var(--text); font-weight: 500; }
.sub-item-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 10.5px;
color: var(--brass);
border: 1px solid var(--brass-dim);
border-radius: 3px;
padding: 1px 6px;
}
.sub-item-body { display: flex; align-items: center; gap: 16px; }
.sub-qr {
flex: none;
width: 96px; height: 96px;
background: #fff;
border-radius: 4px;
padding: 6px;
display: flex; align-items: center; justify-content: center;
}
.sub-qr img, .sub-qr canvas { display: block; width: 100%; height: 100%; }
.sub-url-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.sub-url {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: var(--text-muted);
background: var(--surface-2);
border: 1px solid var(--hairline);
border-radius: 4px;
padding: 8px 10px;
word-break: break-all;
display: block;
}
.sub-copy-btn {
align-self: flex-start;
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
background: transparent;
border: 1px solid var(--brass-dim);
color: var(--brass);
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
}
.sub-copy-btn:hover { border-color: var(--brass); }
.sub-hint { font-size: 12px; color: var(--text-muted); margin: 10px 0 0; line-height: 1.6; }
.sub-empty-hint {
font-size: 12.5px;
color: var(--text-muted);
background: var(--surface-2);
border: 1px dashed var(--hairline);
border-radius: 4px;
padding: 12px 14px;
line-height: 1.6;
}
/* ---------- 守望星图(流量池来源可视化,批次三第三步) ---------- */
/* 只在流量池启用且拿到至少一颗星时显示,卡片外壳跟 ai-card/sub-card 同一套视觉规范。 */
.starmap-card {
margin-top: 22px;
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: 6px;
overflow: hidden;
}
.starmap-head {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px 16px;
padding: 26px 32px 14px;
}
.starmap-head h2 {
font-family: 'Fraunces', serif;
font-style: italic;
font-weight: 500;
font-size: 16px;
color: var(--text-muted);
margin: 0;
}
.starmap-tally {
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
color: var(--text-muted);
}
.starmap-tally b { color: var(--teal-soft); font-weight: 500; }
.starmap-tally .trial-count { color: var(--brass-soft); }
.starmap-grid { display: grid; grid-template-columns: 1.3fr 1fr; }
.starmap-chart {
position: relative;
min-height: 280px;
border-top: 1px solid var(--hairline);
border-right: 1px solid var(--hairline);
background: radial-gradient(ellipse at 40% 30%, #16211f 0%, #101617 70%);
}
.starmap-chart svg { width: 100%; height: 280px; display: block; }
.star-halo { animation: sm-twinkle 4.5s ease-in-out infinite; }
.star.tier-established { fill: var(--teal-soft); }
.star.tier-trial { fill: var(--text); opacity: 0.55; }
@keyframes sm-twinkle { 0%, 100% { opacity: 0.35; } 50% { opacity: 0.85; } }
@keyframes sm-ring-pulse { 0% { opacity: 0.9; r: 6; } 100% { opacity: 0; r: 20; } }
.starmap-harbor-mark { fill: var(--brass-dim); }
.starmap-harbor-label { font-family: 'IBM Plex Mono', monospace; font-size: 9.5px; fill: var(--text-muted); }
.starmap-overflow-note { font-family: 'IBM Plex Mono', monospace; font-size: 9px; fill: var(--text-muted); opacity: 0.75; }
.starmap-route { fill: none; stroke: var(--brass-soft); stroke-width: 1.2; stroke-linecap: round; stroke-dasharray: 4 3; animation: sm-route-draw 1.6s ease forwards; }
@keyframes sm-route-draw { from { stroke-dashoffset: 60; opacity: 0; } to { stroke-dashoffset: 0; opacity: 0.85; } }
.starmap-caption {
padding: 10px 20px 16px;
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
border-right: 1px solid var(--hairline);
}
.starmap-caption span { display: inline-flex; align-items: center; gap: 6px; }
.starmap-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.starmap-empty {
padding: 36px 32px 40px;
font-size: 13px;
color: var(--text-muted);
line-height: 1.7;
border-top: 1px solid var(--hairline);
}
.starmap-roster { padding: 22px 26px 24px; border-top: 1px solid var(--hairline); }
.starmap-roster h3 {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-muted);
margin: 0 0 12px;
text-transform: uppercase;
}
.starmap-row {
display: grid;
grid-template-columns: 22px 1fr auto;
align-items: center;
gap: 8px 10px;
padding: 9px 0;
border-top: 1px solid var(--hairline);
font-size: 12.5px;
}
.starmap-row:first-of-type { border-top: none; }
.starmap-flag { font-size: 14px; }
.starmap-source { min-width: 0; overflow-wrap: break-word; }
.starmap-source-stat { display: block; font-family: 'IBM Plex Mono', monospace; font-size: 10.5px; color: var(--text-muted); margin-top: 2px; }
.starmap-tier-tag { font-family: 'IBM Plex Mono', monospace; font-size: 10.5px; text-align: right; white-space: nowrap; }
.starmap-tier-tag.established { color: var(--teal-soft); }
.starmap-tier-tag.trial { color: var(--brass-soft); }
.starmap-tier-tag.blacklisted { color: var(--rust-soft); opacity: 0.65; text-decoration: line-through; }
@media (prefers-reduced-motion: reduce) {
.star-halo, .starmap-route { animation: none !important; }
}
/* ---------- 底部 ---------- */
.footer {
margin-top: 22px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
font-size: 12.5px;
color: var(--text-muted);
}
.check-btn {
background: transparent;
border: 1px solid var(--brass-dim);
color: var(--brass);
font-family: 'IBM Plex Mono', monospace;
font-size: 12.5px;
padding: 10px 18px;
border-radius: 4px;
cursor: pointer;
transition: border-color 0.15s ease;
white-space: nowrap;
}
.check-btn:hover { border-color: var(--brass); }
.check-btn:focus-visible { outline: 2px solid var(--brass); outline-offset: 2px; }
.check-btn:disabled { opacity: 0.5; cursor: default; }
.footer-actions { display: flex; align-items: center; gap: 12px; }
.logout-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
font-size: 12px;
cursor: pointer;
text-decoration: underline;
padding: 0;
}
.logout-btn:hover { color: var(--text); }
/* ---------- 毛玻璃基础质感(五个模块共用)+ 每个模块各自不同的边框风格 ----------
上一版四个角用一张背景图拼出 L 形边角,在 Safari 上右侧两个角只显示了铆钉圆点、
线条没画出来——那套"一个属性里塞五层背景图、每层各自不同位置"的写法太绕,
Safari 对这种多层 background-position 的兼容性明显不如 Chrome,这次直接换成
最基础、最不会出兼容问题的写法:border / box-shadow,每个模块单独给一种风格。 */
.hero, .log, .ai-card, .sub-card, .starmap-card {
position: relative;
background-color: var(--surface-glass);
backdrop-filter: blur(16px) saturate(1.15);
-webkit-backdrop-filter: blur(16px) saturate(1.15);
transition: box-shadow 0.25s ease;
}
.hero:hover, .log:hover, .ai-card:hover, .sub-card:hover, .starmap-card:hover {
box-shadow: 0 0 0 1px var(--relic-glow), 0 0 22px -6px var(--relic-glow);
}
/* 不支持 backdrop-filter 的极少数环境下,退回不透明底色,保证可读性 */
@supports not (backdrop-filter: blur(1px)) {
.hero, .log, .ai-card, .sub-card, .starmap-card { background-color: var(--surface); }
}
/* NODE 信号灯模块——旗舰位置,用四个真实的铜制边角构件(border 画的 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; }
/* 日志时间线模块——账本/记事簿的感觉:顶部一道粗铜线当"书脊",其余三边细线 */
.log {
border: 3px solid var(--hairline);
border-top: 3px solid var(--brass);
box-shadow: 0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08);
}
/* AI 诊断模块——技术图纸感:虚线边框,像电路图/工程图纸的框线 */
.ai-card {
border: 2px dashed var(--brass-dim);
}
/* 订阅信息模块——最常被操作的模块,双线"铭牌"边框,内嵌一圈细线强调它的重要性 */
.sub-card {
border: 3px solid var(--hairline);
box-shadow: inset 0 0 0 4px rgba(200, 150, 62, 0.05), inset 0 0 0 5px var(--brass-dim),
0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08);
}
/* 星图/来源模块——星图仪的玻璃穹顶感:无硬边框,只有一圈内发光 */
.starmap-card {
border: 3px solid var(--hairline);
box-shadow: inset 0 0 20px -8px var(--relic-glow),
0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08);
}
/* 内容同步/故障报告模块——founder反馈这两张卡片之前只有素色2px边框,跟
紧邻的守望记录(.log)/AI诊断(.ai-card)站在一起显得"扁"、不契合。改成
跟守望记录同一套"账本"边框(3px+顶部古铜色线),视觉重量对齐。 */
.sync-card {
border: 3px solid var(--hairline);
border-top: 3px solid var(--brass);
box-shadow: 0 1px 0 rgba(0,0,0,0.35), inset 0 0 0 1px rgba(232,196,119,0.08);
}
/* ---------- 模块标题:希腊回纹(meander)细线替代普通下划线 ----------
体现"亚历山大灯塔"所在的古代地中海建筑语汇,线条极细,只作点缀。 */
.log h2, .ai-card h2, .sub-card h2, .sync-card h2, .starmap-head h2 {
display: inline-block;
padding-bottom: 8px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 6'%3E%3Cpath d='M0 5 H4 V1 H8 V5 H12 V1 H16' fill='none' stroke='%237d6534' stroke-width='1'/%3E%3C/svg%3E");
background-repeat: repeat-x;
background-position: left bottom;
background-size: 16px 6px;
}
@media (max-width: 760px) {
.hero { grid-template-columns: 1fr; }
.hero-scene { min-height: 220px; order: -1; border-left: none; border-bottom: 1px solid var(--hairline); }
.hero-text { padding: 30px 24px 28px; }
.status-line { font-size: 27px; }
.log { padding: 22px 20px 6px; }
.log-item { grid-template-columns: 78px 16px 1fr; font-size: 13px; }
.footer { flex-direction: column; align-items: flex-start; gap: 14px; }
.footer-actions { width: 100%; flex-direction: column-reverse; align-items: stretch; gap: 10px; }
.check-btn { min-height: 44px; }
.logout-btn { align-self: center; padding: 8px 0; }
.lang-switch button { min-width: 28px; padding: 4px 8px; }
.topbar { flex-wrap: wrap; gap: 12px; }
/* 窄屏(手机浏览器打开面板):星图和名册从左右两栏改成上下堆叠,
星图区域的边框相应从"右边框"改成"下边框",避免中间悬空一条竖线。 */
.starmap-grid { grid-template-columns: 1fr; }
.starmap-chart, .starmap-caption { border-right: none; border-bottom: 1px solid var(--hairline); }
.starmap-head { padding: 22px 20px 12px; }
.starmap-roster { padding: 18px 20px 20px; }
}
@media (max-width: 480px) {
body { padding: 24px 14px 70px; }
.hero { border-radius: 6px; }
.hero-scene { min-height: 170px; }
.hero-scene svg { max-width: 220px; max-height: 220px; }
.hero-text { padding: 24px 18px 22px; }
.compass-mark { width: 160px; height: 160px; }
.node-label { font-size: 11px; }
.status-line { font-size: 23px; }
.duration-line { font-size: 13px; margin-bottom: 24px; }
.stat-grid { gap: 16px 12px; padding-top: 18px; }
.num-roll .slide { font-size: 17px; }
.log { border-radius: 6px; padding: 18px 16px 4px; }
.log-item { grid-template-columns: 66px 14px 1fr; font-size: 12.5px; padding: 11px 0; }
.brand-word { font-size: 16px; }
.starmap-chart svg { height: 230px; }
.starmap-chart { min-height: 230px; }
.starmap-tally { font-size: 11px; }
.starmap-row { grid-template-columns: 18px 1fr auto; font-size: 12px; }
}
</style>
</head>
<body class="status-unknown">
<div class="page">
<div class="topbar">
<div class="brand">
<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="var(--brass)"/>
<path d="M13.3 10.5 H18.7 L20.5 24 H11.5 Z" fill="none" stroke="var(--text-muted)" stroke-width="1.1"/>
<line x1="12.2" y1="15" x2="19.8" y2="15" stroke="var(--text-muted)" stroke-width="1"/>
<line x1="11.8" y1="19.5" x2="20.2" y2="19.5" stroke="var(--text-muted)" stroke-width="1"/>
<line x1="6" y1="24" x2="26" y2="24" stroke="var(--text-muted)" stroke-width="1.2"/>
</svg>
<span class="brand-word">NodeNanny</span>
</div>
<div class="topbar-nav">
<a class="wiki-entry-link" href="/wiki.html">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M8 3.4C6.7 2.6 5 2.3 3 2.3v9.4c2 0 3.7.3 5 1.1 1.3-.8 3-1.1 5-1.1V2.3c-2 0-3.7.3-5 1.1Z" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
<path d="M8 3.4v9.4" stroke="currentColor" stroke-width="1.1"/>
</svg>
<span id="wiki-nav-label">Wiki百科</span>
</a>
<a class="wiki-entry-link" href="/pool-events.html" id="pool-events-nav-link">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M2 13.5 H14" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
<rect x="3.4" y="8.6" width="2.6" height="4.9" rx="0.4" fill="currentColor" opacity="0.85"/>
<rect x="7" y="5.4" width="2.6" height="8.1" rx="0.4" fill="currentColor" opacity="0.85"/>
<rect x="10.6" y="2.6" width="2.6" height="10.9" rx="0.4" fill="currentColor" opacity="0.85"/>
</svg>
<span id="pool-events-nav-label"></span>
</a>
<!-- 终端入口 v2(UI改版·容器方式):不再直接跳转到独立页面,改成从主面板
唤起一个悬浮层(内部用iframe加载/terminal.html),主面板本身不跳走,
跟星图/流量池明细这类"独立页面"的处理方式刻意区分开——见交接文档
"B方案+C方案结合"的决策。terminal.html本身仍然可以被直接访问到。 -->
<button type="button" class="wiki-entry-link" id="terminal-launch-btn">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<rect x="1.5" y="2.5" width="13" height="11" rx="1.4" stroke="currentColor" stroke-width="1.1"/>
<path d="M4 6.2 L6.6 8 L4 9.8" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 9.8 H11" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
</svg>
<span id="terminal-launch-label"></span>
</button>
<div class="lang-switch" id="lang-switch" role="group" aria-label="Language"></div>
</div>
</div>
<div class="pool-banner" id="pool-banner">
<span class="dot" aria-hidden="true"></span>
<span id="pool-banner-text"></span>
</div>
<div class="pool-sources-line" id="pool-sources-line" style="display:none;"></div>
<div class="pool-sources-line" id="pool-active-nodes-line" style="display:none;"></div>
<button class="pool-manual-toggle-btn" id="pool-manual-toggle-btn" onclick="handlePoolManualToggle()"></button>
<div class="hero">
<div class="rivet tl"></div><div class="rivet tr"></div><div class="rivet bl"></div><div class="rivet br"></div>
<div class="hero-text">
<svg class="compass-mark" viewBox="0 0 200 200" aria-hidden="true">
<circle cx="100" cy="100" r="90" fill="none" stroke="var(--brass)" stroke-width="0.6"/>
<circle cx="100" cy="100" r="60" fill="none" stroke="var(--brass)" stroke-width="0.6"/>
<line x1="100" y1="6" x2="100" y2="194" stroke="var(--brass)" stroke-width="0.6"/>
<line x1="6" y1="100" x2="194" y2="100" stroke="var(--brass)" stroke-width="0.6"/>
<line x1="27" y1="27" x2="173" y2="173" stroke="var(--brass)" stroke-width="0.4"/>
<line x1="173" y1="27" x2="27" y2="173" stroke="var(--brass)" stroke-width="0.4"/>
</svg>
<p class="node-label" id="node-label">···</p>
<p class="status-line" id="status-line">···</p>
<p class="duration-line" id="duration-line"></p>
<p class="stale-banner" id="stale-banner"></p>
<div class="stat-grid">
<div class="stat">
<div class="num-roll" id="stat-restarts"><span class="slide">—</span></div>
<div class="label" id="label-restarts"></div>
<div class="label-detail" id="label-restarts-detail"></div>
</div>
<div class="stat">
<div class="num-roll" id="stat-lastcheck"><span class="slide">—</span></div>
<div class="label" id="label-lastcheck"></div>
</div>
<div class="stat" style="grid-column: 1 / -1;">
<div class="num-roll" id="stat-interval"><span class="slide">—</span></div>
<div class="label" id="label-interval"></div>
</div>
</div>
</div>
<div class="hero-scene">
<div class="horizon-line"></div>
<svg viewBox="0 0 300 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden="true">
<defs>
<radialGradient id="lampGlowOk" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="var(--brass-soft)" stop-opacity="0.9"/>
<stop offset="100%" stop-color="var(--brass-soft)" stop-opacity="0"/>
</radialGradient>
<radialGradient id="lampGlowDown" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="var(--rust-soft)" stop-opacity="0.9"/>
<stop offset="100%" stop-color="var(--rust-soft)" stop-opacity="0"/>
</radialGradient>
<radialGradient id="lampGlowUnknown" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="var(--unknown)" stop-opacity="0.7"/>
<stop offset="100%" stop-color="var(--unknown)" stop-opacity="0"/>
</radialGradient>
<!-- filterUnits 显式指定为 userSpaceOnUse:光束是一条 x1=x2 的垂直线,
包围盒宽度为 0,若用默认的 objectBoundingBox 百分比坐标,滤镜区域宽度
会被计算成 0 * 220% = 0,导致整条光束连同模糊效果被裁剪到完全不可见——
这就是"完全没有光束"这个问题的真正根因,跟状态/颜色/动画都无关。
换成绝对坐标(覆盖整个 300x300 viewBox 并留出模糊余量)后不再有这个塌缩问题。 -->
<filter id="beam-soften" filterUnits="userSpaceOnUse" x="-20" y="-20" width="340" height="340">
<feGaussianBlur stdDeviation="3.2"/>
</filter>
<filter id="lamp-soften" x="-200%" y="-200%" width="500%" height="500%">
<feGaussianBlur stdDeviation="6"/>
</filter>
<!-- 光束 v2 用的线性渐变:近灯芯不透明、远端透明,画在一条细线上,
比原来的锥形扇区更像"扫描光束"而不是探照灯。慢/快两道各配一条主色+一条暗色,
呼应概念稿里 beam-slow(亮金)/ beam-fast(暗铜)的层次区分。 -->
<linearGradient id="beamLineOk" x1="150" y1="150" x2="150" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--brass-soft)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--brass-soft)" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="beamLineOkDim" x1="150" y1="150" x2="150" y2="55" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--brass)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--brass)" stop-opacity="0.42"/>
</linearGradient>
<linearGradient id="beamLineDown" x1="150" y1="150" x2="150" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--rust-soft)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--rust-soft)" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="beamLineDownDim" x1="150" y1="150" x2="150" y2="55" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--rust)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--rust)" stop-opacity="0.42"/>
</linearGradient>
<linearGradient id="beamLineUnknown" x1="150" y1="150" x2="150" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--unknown)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--unknown)" stop-opacity="0.5"/>
</linearGradient>
<linearGradient id="beamLineUnknownDim" x1="150" y1="150" x2="150" y2="55" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--unknown)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--unknown)" stop-opacity="0.35"/>
</linearGradient>
<!-- 手动切换备用池模式专用配色(蓝色),跟status-ok(金)/status-down(红)/
status-unknown(灰)区分开——这个状态是独立于status之外的一层视觉,靠
body.pool-manual这个额外class叠加,不影响原来status-*那一套判断。 -->
<radialGradient id="lampGlowManual" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="var(--manual-soft)" stop-opacity="0.9"/>
<stop offset="100%" stop-color="var(--manual-soft)" stop-opacity="0"/>
</radialGradient>
<linearGradient id="beamLineManual" x1="150" y1="150" x2="150" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--manual-soft)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--manual-soft)" stop-opacity="0.6"/>
</linearGradient>
<linearGradient id="beamLineManualDim" x1="150" y1="150" x2="150" y2="55" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="var(--manual)" stop-opacity="0"/>
<stop offset="100%" stop-color="var(--manual)" stop-opacity="0.42"/>
</linearGradient>
</defs>
<!-- 刻度盘 v2(UI改版·灯塔):原来只有两圈素圆环+4个十字准星,创始人要求外圈加
繁复的美术装饰,改成 60 格钟表式细刻度(每 5 格加粗,呼应分钟刻度感)+
四角包边装饰,方向定稿见 lighthouse-concept-preview.html。刻度本身由页面
底部脚本 renderGaugeTicks() 生成并插入 #gauge-ticks 分组。 -->
<circle cx="150" cy="150" r="128" fill="none" stroke="var(--hairline)" stroke-width="1"/>
<circle cx="150" cy="150" r="112" fill="none" stroke="var(--brass-dim)" stroke-width="0.8"/>
<g id="gauge-ticks" aria-hidden="true"></g>
<!-- 四角包边:呼应卡片四角 .rivet 铜制包边视觉语言的小号 SVG 版本 -->
<g class="gauge-corner-group" aria-hidden="true">
<g class="gauge-corner">
<path d="M 40 60 L 40 34 L 66 34"/>
<circle class="gauge-corner-dot" cx="40" cy="34" r="2.2"/>
</g>
<g class="gauge-corner">
<path d="M 260 60 L 260 34 L 234 34"/>
<circle class="gauge-corner-dot" cx="260" cy="34" r="2.2"/>
</g>
<g class="gauge-corner">
<path d="M 40 240 L 40 266 L 66 266"/>
<circle class="gauge-corner-dot" cx="40" cy="266" r="2.2"/>
</g>
<g class="gauge-corner">
<path d="M 260 240 L 260 266 L 234 266"/>
<circle class="gauge-corner-dot" cx="260" cy="266" r="2.2"/>
</g>
</g>
<!-- 光束 v2:快慢两道扫描光束,类似钟表时针/分针叠加旋转,取代原来单道扇形光束
(唯一的核心动效之一)。慢的那道更长更亮,快的那道更短更暗,制造层次感。 -->
<g class="sweep-slow" filter="url(#beam-soften)">
<line id="beam-shape-slow" x1="150" y1="150" x2="150" y2="22" stroke-width="9" stroke-linecap="round"/>
</g>
<g class="sweep-fast" filter="url(#beam-soften)">
<line id="beam-shape-fast" x1="150" y1="150" x2="150" y2="55" stroke-width="5" stroke-linecap="round"/>
</g>
<!-- 灯芯:呼吸灯式脉动(唯一的核心动效之二),代替死板圆点。去掉了下方的
塔状/钥匙孔剪影,这里只保留一个纯粹的光源。 -->
<circle class="lamp-halo" cx="150" cy="150" r="22" filter="url(#lamp-soften)"/>
<circle class="lamp-core" cx="150" cy="150" r="6.5"/>
</svg>
<div class="gear-cluster" aria-hidden="true">
<div class="gear-a"></div>
<div class="gear-b"></div>
</div>
</div>
</div>
<div class="sub-card" id="sub-card">
<div class="rivet tl"></div><div class="rivet tr"></div><div class="rivet bl"></div><div class="rivet br"></div>
<h2 id="sub-title"></h2>
<p class="sub-lead" id="sub-lead"></p>
<div class="sub-item">
<div class="sub-item-head">
<span class="sub-item-label" id="sub-label-nanny"></span>
<span class="sub-item-badge" id="sub-badge-recommend"></span>
</div>
<div class="sub-item-body">
<div class="sub-qr" id="sub-qr-nanny"></div>
<div class="sub-url-block">
<code class="sub-url" id="sub-url-nanny">···</code>
<button class="sub-copy-btn" data-target="sub-url-nanny" id="sub-copy-nanny"></button>
</div>
</div>
<p class="sub-hint" id="sub-hint-nanny"></p>
<p class="sub-empty-hint" id="sub-warning-nanny" style="display:none;"></p>
</div>
<div class="sub-item">
<div class="sub-item-head">
<span class="sub-item-label" id="sub-label-node"></span>
</div>
<div class="sub-item-body" id="sub-node-body">
<div class="sub-qr" id="sub-qr-node"></div>
<div class="sub-url-block">
<code class="sub-url" id="sub-url-node">···</code>
<button class="sub-copy-btn" data-target="sub-url-node" id="sub-copy-node"></button>
</div>
</div>
<p class="sub-hint" id="sub-hint-node"></p>
<p class="sub-empty-hint" id="sub-empty-node" style="display:none;"></p>
</div>
</div>
<div class="starmap-card" id="starmap-card" style="display:none;">
<div class="rivet tl"></div><div class="rivet tr"></div><div class="rivet bl"></div><div class="rivet br"></div>
<div class="starmap-head">
<h2 id="starmap-title"></h2>
<div class="starmap-tally" id="starmap-tally"></div>
</div>
<div class="starmap-grid">
<div>
<div class="starmap-chart">
<svg viewBox="0 0 420 320" id="starmap-svg" xmlns="http://www.w3.org/2000/svg" role="img" aria-hidden="true"></svg>
</div>
<div class="starmap-caption">
<span><i class="starmap-dot" style="background:var(--teal-soft)"></i><span id="starmap-legend-established"></span></span>
<span><i class="starmap-dot" style="background:var(--text);opacity:.6"></i><span id="starmap-legend-trial"></span></span>
<span><i class="starmap-dot" style="background:var(--rust);opacity:.5"></i><span id="starmap-legend-blacklisted"></span></span>
</div>
</div>
<div class="starmap-roster" id="starmap-roster">
<h3 id="starmap-roster-title"></h3>
<div id="starmap-roster-list"></div>
</div>
</div>
<div class="starmap-empty" id="starmap-empty" style="display:none;"></div>
</div>
<div class="log">
<div class="rivet tl"></div><div class="rivet tr"></div><div class="rivet bl"></div><div class="rivet br"></div>
<h2 id="log-title"><span id="log-title-text"></span><span class="log-tz-label" id="log-tz-label"></span></h2>
<div id="log-list"></div>
</div>
<div class="ai-card">
<h2 id="ai-title"></h2>
<div id="ai-content"></div>
<div class="ai-actions">
<button class="ai-diagnose-btn" id="ai-diagnose-btn"></button>
</div>
</div>
<div class="sync-card">
<h2 id="sync-title"></h2>
<div class="sync-section">
<h3 id="sync-kb-title"></h3>
<div class="sync-status" id="sync-kb-status"></div>
<div class="sync-result" id="sync-kb-result"></div>
<div class="sync-actions">
<button class="sync-btn" id="sync-kb-check-btn"></button>
<button class="sync-btn sync-btn-confirm" id="sync-kb-apply-btn" style="display:none;"></button>
</div>
</div>
<div class="sync-section">
<h3 id="sync-wiki-title"></h3>
<div class="sync-status" id="sync-wiki-status"></div>
<div class="sync-result" id="sync-wiki-result"></div>
<div class="sync-actions">
<button class="sync-btn" id="sync-wiki-check-btn"></button>
<button class="sync-btn sync-btn-confirm" id="sync-wiki-apply-btn" style="display:none;"></button>
</div>
</div>
<div class="sync-section">
<h3 id="sync-sources-title"></h3>
<div class="sync-status" id="sync-sources-status"></div>
<div class="sync-result" id="sync-sources-result"></div>
<div class="sync-actions">
<button class="sync-btn" id="sync-sources-check-btn"></button>
<button class="sync-btn sync-btn-confirm" id="sync-sources-apply-btn" style="display:none;"></button>
</div>
</div>
</div>
<div class="sync-card">
<h2 id="report-title"></h2>
<div class="sync-section">
<div class="sync-status" id="report-hint"></div>
<div class="sync-actions">
<button class="sync-btn" id="report-generate-btn"></button>
<button class="sync-btn sync-btn-confirm" id="report-copy-btn" style="display:none;"></button>
</div>
<textarea
id="report-textarea"
readonly
style="display:none; width:100%; box-sizing:border-box; margin-top:12px; min-height:220px;
font-family:'IBM Plex Mono', monospace; font-size:12px; line-height:1.6;
background:var(--bg); color:var(--text); border:1px solid var(--hairline);
border-radius:4px; padding:12px; resize:vertical;"
></textarea>
</div>
</div>
<div class="footer">
<span id="footer-note"></span>
<div class="footer-actions">
<button class="logout-btn" id="logout-btn"></button>
<button class="check-btn" id="check-btn"></button>
</div>
</div>
</div>
<!-- 终端悬浮层容器(UI改版·容器方式):主面板不跳走,点击顶部"终端"按钮时
才把/terminal.html加载进这个iframe;关闭时把iframe.src重置为about:blank,
这样WebSocket连接会随iframe内容卸载一起干净断开,不会挂在后台。 -->
<div class="terminal-overlay" id="terminal-overlay" aria-hidden="true">
<div class="terminal-overlay-backdrop" id="terminal-overlay-backdrop"></div>
<div class="terminal-overlay-panel" role="dialog" aria-modal="true">
<button type="button" class="terminal-overlay-close" id="terminal-overlay-close" aria-label="Close">×</button>
<iframe class="terminal-overlay-frame" id="terminal-overlay-frame" title="NodeNanny Terminal"></iframe>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
/* ---------- 多语言词典 ---------- */
// v34本轮新增:usability_down/usability_check_error/usability_restart_suggested这三种
// (v36又加了usability_auto_restart一种,做法一样)事件现在后端写入时就自动附带了
// 知识库匹配结果(params.kb,见checker.js的matchKbForEvent),
// 这里统一拼成一段追加文字,按当前界面语言取explanation,缺失回退英文。之前这几种事件
// 只有写死的翻译文本,没有真正查过知识库——这个函数就是补上"自动展示知识库建议"这一步,
// 五个语言的事件渲染函数都调用同一份,不用各自重复实现。
function kbSuggestionSuffix(kb) {
if (!Array.isArray(kb) || kb.length === 0) return '';
const texts = kb
.map((h) => (h.explanation && (h.explanation[lang] || h.explanation.en)) || '')
.filter(Boolean);
if (texts.length === 0) return '';
return '\n' + texts.join('\n');
}
const I18N = {
zh: {
code: '中',
timeZone: 'Asia/Shanghai',
tzLabel: '北京时间',
nodeWaiting: '正在连接节点…',
status: { ok: '安好', down: '异常,正在处理', unknown: '等待第一次检测' },
durationOk: (d,h,m) => d>0 ? `已持续 ${d} 天 ${h} 小时` : h>0 ? `已持续 ${h} 小时 ${m} 分钟` : `已持续 ${m} 分钟`,
durationWaiting: '正在等待第一次检测结果',
labelRestarts: '今日自动重启',
labelRestartsDetail: (success, attempts) => attempts > 0 ? `其中成功 ${success} 次、失败 ${attempts - success} 次` : '',
labelLastcheck: '最近一次检测',
labelInterval: (n) => `每 ${n} 分钟检测一次`,
navWikiLabel: 'Wiki百科',
navPoolEventsLabel: '流量池明细',
navTerminalLabel: '终端',
logTitle: '守望记录',
emptyLog: '还没有记录,一切安静。',
footerNote: 'NodeNanny 在后台持续看着,你不用做任何事',
logout: '退出登录',
checkBtn: '立即检测',
checkingBtn: '检测中…',
connError: '暂时连不上面板服务(不代表节点异常,可能是网络暂时中断,也可能是面板进程本身出了问题)',
dataStale: '连不上面板服务,以下数字可能已经过时,不代表当前真实状态',
poolBanner: '正在使用流量池应急节点——这是陌生人的服务器,仅临时使用,自建节点恢复后会自动切回',
poolBannerManual: '正在使用流量池应急节点——这是陌生人的服务器,仅临时使用。当前是你手动切过来的,不会自动切回,点下面的开关自己切回去',
poolManualToggleOn: '手动切到备用池',
poolManualToggleOff: '手动切回自建节点',
poolManualEmptyError: '流量池里暂时没有可用节点,切不了',
poolManualToggleError: '切换失败,稍后再试一次',
poolSourcesSummary: (established, trial) => {
if (established + trial === 0) return '';
const parts = [];
if (established > 0) parts.push(`${established} 个长期来源`);
if (trial > 0) parts.push(`${trial} 个观察期来源(新发现,还在测试可信度)`);
return `流量池节点来自:${parts.join(',')}`;
},
poolActiveNodesLine: (nodes) => {
if (!nodes || nodes.length === 0) return '';
const items = nodes.map((n) => {
const flag = countryCodeToFlag(n.countryCode) || '';
const tierLabel = n.tier === 'trial' ? '试用期来源' : '长期来源';
return `${flag ? flag + ' ' : ''}${n.sourceId}(${tierLabel})`;
});
return `当前接替的备用节点:${items.join('、')}`;
},
starmapTitle: '守望星图 · 应急节点',
starmapTally: (established, trial, blacklisted) => `长期来源 ${established} · 试用期来源 ${trial} · 已拉黑 ${blacklisted}`,
starmapLegendEstablished: '长期来源',
starmapLegendTrial: '试用期来源',
starmapLegendBlacklisted: '已拉黑',
starmapHarborLabel: '你的主节点',
starmapOverflowMain: (n) => `另有 ${n} 个观察期来源未在图中显示`,
starmapOverflowBlacklist: (n) => `另有 ${n} 个拉黑来源未在图中显示`,
starmapRosterTitle: '节点名册',
starmapTierEstablished: '长期',
starmapTierTrial: '试用期',
starmapTierBlacklisted: '已拉黑 · 疑似不可信',
starmapStat: (passed, total) => `${passed}/${total} 通过检测`,
starmapEmpty: '流量池已开启,但暂时还没有可展示的来源数据——可能是还没跑过第一轮抓取,或者这一轮抓到的候选节点全部没通过检测,都是正常情况,不代表出了故障。',
starmapCountryUnknown: '🏳️',
aiTitle: 'AI 故障诊断',
aiEmpty: '暂无诊断记录。节点连续异常达到设定次数后会自动诊断一次,也可以手动触发。',
aiDisabledHint: '未开启 AI 诊断功能,需要在 config.json 里配置 ai.enabled 和你自己的 API Key',
aiErrorPrefix: '诊断失败:',
aiAt: (t) => `诊断时间:${t}`,
aiDiagnoseBtn: '立即诊断',
aiDiagnosingBtn: '诊断中…',
syncTitle: '内容同步',
syncKbTitle: '知识库同步',
syncWikiTitle: 'Wiki 百科同步',
syncNeverChecked: '还没有检查过',
syncCheckBtn: '检查更新',
syncCheckingBtn: '检查中…',
syncApplyKbBtn: '确认合并',
syncApplyWikiBtn: '确认覆盖',
syncApplyingBtn: '处理中…',
syncNotConfiguredKb: '尚未配置知识库同步来源(config.json 里的 kbSync.rawUrl)',
syncNotConfiguredWiki: '尚未配置 Wiki 同步来源(config.json 里的 wikiSync.owner/repo)',
syncUpToDateKb: (n) => `已是最新,当前共有 ${n} 条知识库条目`,
syncUpToDateWiki: (n) => `已是最新,当前追踪 ${n} 个文件`,
syncHasUpdateKb: (a,m,r) => `发现更新:新增 ${a} 条、修改 ${m} 条、删除 ${r} 条,点击下面按钮确认合并`,
syncHasUpdateWiki: (a,c,r) => `发现更新:新增 ${a} 个、修改 ${c} 个、删除 ${r} 个文件,点击下面按钮确认覆盖`,
syncAppliedKb: (n) => `已合并 ${n} 条内容`,
syncAppliedWiki: (dl,rm) => `已覆盖完成:更新 ${dl} 个文件${rm > 0 ? `、删除 ${rm} 个文件` : ''}`,
syncErrorPrefix: '同步出错:',
syncLastAppliedWiki: (t) => `上次同步:${t}`,
syncSourcesTitle: '节点来源列表同步',
syncNotConfiguredSources: '尚未配置节点来源同步(config.json 里的 sourceListSync.rawUrl)',
syncUpToDateSources: (n) => `已是最新,当前共有 ${n} 个候选来源`,
syncHasUpdateSources: (a,m,r) => `发现更新:新增 ${a} 个、修改 ${m} 个、移除 ${r} 个,点击下面按钮确认合并`,
syncApplySourcesBtn: '确认合并',
syncAppliedSources: (n) => `已合并 ${n} 个来源,会走完整的试用期检测,不会直接信任`,
syncSourcesConflictNote: (n) => `另有 ${n} 个跟你已有的手动来源 ID 冲突,为安全起见未合并,需要你自己确认后处理`,
reportTitle: '故障报告',
reportHint: '把当前状态、最近事件、已尝试的自愈动作、AI诊断结论(如果有)自动整理成一段文字,方便你直接粘贴到论坛帖子里寻求帮助。',
reportGenerateBtn: '生成报告',
reportGeneratingBtn: '生成中…',
reportCopyBtn: '复制',
reportCopiedBtn: '已复制',
reportErrorPrefix: '生成失败:',
subTitle: '订阅信息',
subLead: '把下面的订阅地址导入客户端,节点异常时会自动切换到应急节点,恢复后自动切回,你不用手动做任何事。',
subLabelNanny: 'NodeNanny 智能订阅',
subBadgeRecommend: '推荐使用',
subHintNanny: '推荐把这个地址填进客户端的"订阅"功能。平时直接用你自己的节点;自建节点异常且有应急节点可用时,客户端下次刷新订阅会自动切到应急节点,恢复后又会自动切回来。',
subLabelNode: '原始节点订阅地址',
subHintNode: '这是装机脚本生成的原始订阅地址,只包含你自己的节点,不会自动切换应急节点。仅供备用或核对用,日常请使用上面的智能订阅地址。',
subEmptyNode: '还没有检测到原始订阅地址(可能是没有使用一键脚本,或者自动识别没有成功——如果你确认自己用过一键脚本,可以直接去 config.json 手动补上 node.subscriptionUrl)。',
subWarnNannyUnusable: '当前还没有可用的真实节点订阅,智能订阅暂时无法使用,请检查 config.json 里的 node.subscriptionUrl。',
subCopy: '复制',
subCopied: '已复制',
events: {
down: p => `${p.node} 端口 ${p.port} 无法连接`,
recovered: p => p.downMinutes ? `${p.node} 恢复正常,异常持续约 ${p.downMinutes} 分钟` : `${p.node} 恢复正常`,
restart: p => `已尝试自动重启服务(${p.serviceName || p.node})`,
restart_failed: p => `自动重启失败:${p.error || '未知错误'}`,
pool_activated: p => `已临时切换到流量池应急节点(共 ${p.count || 0} 个备用节点)`,
pool_deactivated: p => `自建节点已恢复,已自动切回自建订阅`,
pool_manual_activated: p => `已手动强制切换到流量池(共 ${p.count || 0} 个备用节点),不会因为自建节点恢复而自动切回`,
pool_manual_restored: () => `已手动切回自建节点订阅`,
pool_refreshed: p => `流量池已刷新,当前 ${p.count || 0} 个备用节点`,
pool_refresh_failed: p => `流量池刷新失败:${p.error || '未知错误'}`,
pool_unavailable: p => `节点异常,但流量池暂无可用备用节点`,
ai_diagnosis: p => `AI 已给出一条故障诊断建议`,
ai_diagnosis_failed: p => `AI 诊断调用失败:${p.error || '未知错误'}`,
usability_ok: p => `二层可用性检测通过:真的通过代理请求了外部地址,响应正常`,
usability_down: p => `二层可用性检测未通过:端口能连上,但代理实际请求外部地址没拿到预期结果,可能是被针对性干扰${p.detail ? '(' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_check_error: p => `二层可用性检测本身出了问题(不代表节点故障):${p.detail || '未知原因'}${kbSuggestionSuffix(p.kb)}`,
usability_restart_suggested: p => `二层可用性检测连续多次未通过——这不会触发自动重启(重启对这类情况大概率没用),仅提醒你人工看一眼${p.detail ? '(' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_auto_restart: p => `二层可用性检测持续异常,NodeNanny 已自动重启一次本地代理${p.restartOk ? '(重启命令执行成功)' : '(重启命令执行失败,需要人工检查)'}${p.detail ? '(' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
kb_sync_applied: p => `知识库远程同步已合并 ${p.count} 条内容`,
source_list_sync_applied: p => `官方节点来源列表已合并 ${p.count} 个来源,会走完整试用期检测`,
wiki_sync_applied: p => `Wiki 百科远程同步已应用:更新 ${p.downloaded} 个文件${p.removed > 0 ? `、删除 ${p.removed} 个文件` : ''}`,
info: p => p.message || ''
}
},
en: {
code: 'EN',
timeZone: 'UTC',
tzLabel: 'UTC',
nodeWaiting: 'Connecting to node…',
status: { ok: 'All calm', down: 'Trouble — tending to it', unknown: 'Waiting for first check' },
durationOk: (d,h,m) => d>0 ? `Steady for ${d}d ${h}h` : h>0 ? `Steady for ${h}h ${m}m` : `Steady for ${m}m`,
durationWaiting: 'Waiting for the first check to come in',
labelRestarts: 'Restarts today',
labelRestartsDetail: (success, attempts) => attempts > 0 ? `${success} succeeded, ${attempts - success} failed` : '',
labelLastcheck: 'Last check',
labelInterval: (n) => `Checking every ${n} min`,
navWikiLabel: 'Wiki',
navPoolEventsLabel: 'Pool details',
navTerminalLabel: 'Terminal',
logTitle: "Keeper's log",
emptyLog: 'Nothing yet — all quiet.',
footerNote: 'NodeNanny keeps watch in the background — nothing for you to do',
logout: 'Log out',
checkBtn: 'Check now',
checkingBtn: 'Checking…',
connError: "Can't reach the panel service right now (not necessarily a node problem — could be a temporary network hiccup, or an issue with the panel process itself)",
dataStale: "Can't reach the panel service — the numbers below may be out of date and may not reflect the current real status",
poolBanner: "Running on the backup pool right now — these are strangers' servers, for temporary use only. It'll switch back automatically once your own node recovers.",
poolBannerManual: "Running on the backup pool right now — these are strangers' servers, for temporary use only. You switched here manually, so it won't switch back on its own — use the toggle below when you're ready.",
poolManualToggleOn: 'Switch to backup pool',
poolManualToggleOff: 'Switch back to my node',
poolManualEmptyError: 'No backup nodes available right now — cannot switch.',
poolManualToggleError: 'Switch failed, please try again.',
poolSourcesSummary: (established, trial) => {
if (established + trial === 0) return '';
const parts = [];
if (established > 0) parts.push(`${established} established source(s)`);
if (trial > 0) parts.push(`${trial} source(s) still in trial (newly discovered, trust not yet verified)`);
return `Backup pool nodes come from: ${parts.join(', ')}`;
},
poolActiveNodesLine: (nodes) => {
if (!nodes || nodes.length === 0) return '';
const items = nodes.map((n) => {
const flag = countryCodeToFlag(n.countryCode) || '';
const tierLabel = n.tier === 'trial' ? 'trial source' : 'established source';
return `${flag ? flag + ' ' : ''}${n.sourceId} (${tierLabel})`;
});
return `Currently standing in: ${items.join(', ')}`;
},
starmapTitle: 'Starchart · backup nodes',
starmapTally: (established, trial, blacklisted) => `${established} established · ${trial} on trial · ${blacklisted} blacklisted`,
starmapLegendEstablished: 'Established',
starmapLegendTrial: 'On trial',
starmapLegendBlacklisted: 'Blacklisted',
starmapHarborLabel: 'Your node',
starmapOverflowMain: (n) => `${n} more trial source(s) not shown on the chart`,
starmapOverflowBlacklist: (n) => `${n} more blacklisted source(s) not shown`,
starmapRosterTitle: 'Node roster',
starmapTierEstablished: 'Established',
starmapTierTrial: 'On trial',
starmapTierBlacklisted: 'Blacklisted · suspected untrustworthy',
starmapStat: (passed, total) => `${passed}/${total} passed checks`,
starmapEmpty: "The backup pool is on, but there's no source data to show yet — either the first fetch hasn't run, or none of this round's candidates passed the checks. Both are normal, not a sign of trouble.",
starmapCountryUnknown: '🏳️',
aiTitle: 'AI diagnosis',
aiEmpty: 'No diagnosis yet. Runs automatically after enough consecutive failures, or trigger it manually.',
aiDisabledHint: 'AI diagnosis is off — set ai.enabled and your own API key in config.json to turn it on',
aiErrorPrefix: 'Diagnosis failed: ',
aiAt: (t) => `Diagnosed at ${t}`,
aiDiagnoseBtn: 'Diagnose now',
aiDiagnosingBtn: 'Diagnosing…',
syncTitle: 'Content Sync',
syncKbTitle: 'Knowledge Base Sync',
syncWikiTitle: 'Wiki Sync',
syncNeverChecked: 'Not checked yet',
syncCheckBtn: 'Check for updates',
syncCheckingBtn: 'Checking…',
syncApplyKbBtn: 'Confirm merge',
syncApplyWikiBtn: 'Confirm overwrite',
syncApplyingBtn: 'Applying…',
syncNotConfiguredKb: 'Knowledge base sync source not configured (kbSync.rawUrl in config.json)',
syncNotConfiguredWiki: 'Wiki sync source not configured (wikiSync.owner/repo in config.json)',
syncUpToDateKb: (n) => `Up to date — ${n} knowledge base entries`,
syncUpToDateWiki: (n) => `Up to date — tracking ${n} files`,
syncHasUpdateKb: (a,m,r) => `Update available: ${a} added, ${m} modified, ${r} removed — click below to confirm merge`,
syncHasUpdateWiki: (a,c,r) => `Update available: ${a} added, ${c} changed, ${r} removed files — click below to confirm overwrite`,
syncAppliedKb: (n) => `Merged ${n} entries`,
syncAppliedWiki: (dl,rm) => `Overwrite complete: ${dl} files updated${rm > 0 ? `, ${rm} files removed` : ''}`,
syncErrorPrefix: 'Sync error: ',
syncLastAppliedWiki: (t) => `Last synced: ${t}`,
syncSourcesTitle: 'Source list sync',
syncNotConfiguredSources: 'Source list sync is not configured (sourceListSync.rawUrl in config.json)',
syncUpToDateSources: (n) => `Up to date — ${n} candidate source(s) currently listed`,
syncHasUpdateSources: (a,m,r) => `Update available: ${a} added, ${m} modified, ${r} removed — click below to confirm merge`,
syncApplySourcesBtn: 'Confirm merge',
syncAppliedSources: (n) => `Merged ${n} source(s) — they'll go through the full trial period, not trusted automatically`,
syncSourcesConflictNote: (n) => `${n} other source(s) share an ID with one you added manually and were skipped for safety — please review them yourself`,
reportTitle: 'Failure Report',
reportHint: 'Automatically assemble current status, recent events, self-healing actions already tried, and the latest AI diagnosis (if any) into text you can paste directly into a forum post.',
reportGenerateBtn: 'Generate report',
reportGeneratingBtn: 'Generating…',
reportCopyBtn: 'Copy',
reportCopiedBtn: 'Copied',
reportErrorPrefix: 'Failed to generate: ',
subTitle: 'Subscription info',
subLead: "Import the link below into your client. It switches to a backup node automatically if your own node has trouble, and switches back once it recovers — nothing for you to do.",
subLabelNanny: 'NodeNanny smart subscription',
subBadgeRecommend: 'Recommended',
subHintNanny: "Use this address in your client's \"subscription\" field. It normally points to your own node; if your node is down and a backup is available, the next refresh switches to the backup automatically, then switches back once your node recovers.",
subLabelNode: 'Raw node subscription',
subHintNode: 'This is the raw subscription address from the install script — just your own node, with no automatic backup switching. Keep it as a fallback or for reference; use the smart subscription above for everyday use.',
subEmptyNode: "No raw subscription address detected yet (either the one-click script wasn't used, or auto-detection didn't succeed — if you're sure you used the one-click script, you can add node.subscriptionUrl to config.json by hand).",
subWarnNannyUnusable: "There's no working node subscription yet, so the smart subscription can't be used right now — check node.subscriptionUrl in config.json.",
subCopy: 'Copy',
subCopied: 'Copied',
events: {
down: p => `${p.node} lost its connection on port ${p.port}`,
recovered: p => p.downMinutes ? `${p.node} is back — was out for about ${p.downMinutes} min` : `${p.node} is back`,
restart: p => `Attempted an automatic restart (${p.serviceName || p.node})`,
restart_failed: p => `Automatic restart failed: ${p.error || 'unknown error'}`,
pool_activated: p => `Switched to the backup pool temporarily (${p.count || 0} nodes available)`,
pool_deactivated: p => `Your node is back — switched back to your own subscription`,
pool_manual_activated: p => `Manually forced to the backup pool (${p.count || 0} nodes available) — won't auto-revert even after your node recovers`,
pool_manual_restored: () => `Manually switched back to your own node's subscription`,
pool_refreshed: p => `Backup pool refreshed — ${p.count || 0} nodes now available`,
pool_refresh_failed: p => `Backup pool refresh failed: ${p.error || 'unknown error'}`,
pool_unavailable: p => `Node is down and no backup nodes are available yet`,
ai_diagnosis: p => `AI provided a diagnosis suggestion`,
ai_diagnosis_failed: p => `AI diagnosis call failed: ${p.error || 'unknown error'}`,
usability_ok: p => `Layer-2 usability check passed: a real request through the proxy got a normal response`,
usability_down: p => `Layer-2 usability check failed: the port is open, but a real request through the proxy didn't get the expected result — possibly targeted interference${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_check_error: p => `The layer-2 usability check itself had a problem (not necessarily a node failure): ${p.detail || 'unknown reason'}${kbSuggestionSuffix(p.kb)}`,
usability_restart_suggested: p => `Layer-2 usability check has failed repeatedly — this will not trigger an automatic restart (unlikely to help for this kind of issue), just a heads-up to take a manual look${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_auto_restart: p => `Layer-2 usability check has been persistently failing — NodeNanny automatically restarted the local proxy once${p.restartOk ? ' (restart command succeeded)' : ' (restart command failed, manual check needed)'}${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
kb_sync_applied: p => `Knowledge base sync merged ${p.count} ${p.count === 1 ? 'entry' : 'entries'}`,
source_list_sync_applied: p => `Official source list sync merged ${p.count} source(s) — they'll go through the full trial period`,
wiki_sync_applied: p => `Wiki sync applied: ${p.downloaded} file${p.downloaded === 1 ? '' : 's'} updated${p.removed > 0 ? `, ${p.removed} removed` : ''}`,
info: p => p.message || ''
}
},
ja: {
code: '日',
timeZone: 'Asia/Tokyo',
tzLabel: '東京時間',
nodeWaiting: 'ノードに接続中…',
status: { ok: '平穏', down: '異常、対応中', unknown: '初回検査待ち' },
durationOk: (d,h,m) => d>0 ? `${d}日${h}時間、安定稼働中` : h>0 ? `${h}時間${m}分、安定稼働中` : `${m}分、安定稼働中`,
durationWaiting: '初回の検査結果を待っています',
labelRestarts: '本日の自動再起動',
labelRestartsDetail: (success, attempts) => attempts > 0 ? `成功 ${success} 回、失敗 ${attempts - success} 回` : '',
labelLastcheck: '最終検査',
labelInterval: (n) => `${n}分ごとに検査`,
navWikiLabel: 'Wiki',
navPoolEventsLabel: 'プール明細',
navTerminalLabel: 'ターミナル',
logTitle: '見守りの記録',
emptyLog: 'まだ記録はありません。静かなものです。',
footerNote: 'NodeNannyがバックグラウンドで見守っています。何もしなくて大丈夫です',
logout: 'ログアウト',
checkBtn: '今すぐ検査',
checkingBtn: '検査中…',
connError: 'パネルが今サービスに接続できません(ノードの異常とは限りません。一時的なネットワークの問題か、パネル自体の不具合の可能性があります)',
dataStale: 'パネルに接続できません。以下の数値は古い情報の可能性があり、現在の実際の状態を反映していない場合があります',
poolBanner: '現在は予備プールの緊急ノードを使用中です——見知らぬ人のサーバーなので一時的な利用にとどめてください。自建ノードが復旧すれば自動的に戻ります',
poolBannerManual: '現在は予備プールの緊急ノードを使用中です——見知らぬ人のサーバーなので一時的な利用にとどめてください。手動で切り替えたため自動では戻りません。戻す場合は下のボタンを押してください',
poolManualToggleOn: '手動で予備プールに切替',
poolManualToggleOff: '手動で自建ノードに戻す',
poolManualEmptyError: '予備プールに利用可能なノードがないため切り替えできません',
poolManualToggleError: '切り替えに失敗しました。しばらくしてからもう一度お試しください',
poolSourcesSummary: (established, trial) => {
if (established + trial === 0) return '';
const parts = [];
if (established > 0) parts.push(`長期ソース ${established} 件`);
if (trial > 0) parts.push(`観察期間中の新規ソース ${trial} 件(信頼性を検証中)`);
return `予備プールのノードの出所:${parts.join('、')}`;
},
poolActiveNodesLine: (nodes) => {
if (!nodes || nodes.length === 0) return '';
const items = nodes.map((n) => {
const flag = countryCodeToFlag(n.countryCode) || '';
const tierLabel = n.tier === 'trial' ? '試用期ソース' : '長期ソース';
return `${flag ? flag + ' ' : ''}${n.sourceId}(${tierLabel})`;
});
return `現在代わりに使われているノード:${items.join('、')}`;
},
starmapTitle: '見守り星図・予備ノード',
starmapTally: (established, trial, blacklisted) => `長期ソース ${established} ・試用期ソース ${trial} ・ブラックリスト ${blacklisted}`,
starmapLegendEstablished: '長期ソース',
starmapLegendTrial: '試用期ソース',
starmapLegendBlacklisted: 'ブラックリスト',
starmapHarborLabel: 'あなたのノード',
starmapOverflowMain: (n) => `他に ${n} 件の試用期ソースは図に表示されていません`,
starmapOverflowBlacklist: (n) => `他に ${n} 件のブラックリストソースは表示されていません`,
starmapRosterTitle: 'ノード名簿',
starmapTierEstablished: '長期',
starmapTierTrial: '試用期',
starmapTierBlacklisted: 'ブラックリスト・信頼性に疑いあり',
starmapStat: (passed, total) => `${passed}/${total} 件が検査を通過`,
starmapEmpty: '予備プールは有効ですが、まだ表示できるソースデータがありません——初回の取得がまだ実行されていないか、今回の候補ノードが検査を通過しなかった可能性があります。どちらも正常な状態で、故障ではありません。',
starmapCountryUnknown: '🏳️',
aiTitle: 'AI診断',
aiEmpty: 'まだ診断結果はありません。連続異常が一定回数に達すると自動で診断されます。手動で診断することもできます。',
aiDisabledHint: 'AI診断は無効です。config.json の ai.enabled と自分のAPIキーを設定してください',
aiErrorPrefix: '診断失敗:',
aiAt: (t) => `診断時刻:${t}`,
aiDiagnoseBtn: '今すぐ診断',
aiDiagnosingBtn: '診断中…',
syncTitle: 'コンテンツ同期',
syncKbTitle: 'ナレッジベース同期',
syncWikiTitle: 'Wiki 同期',
syncNeverChecked: 'まだ確認していません',
syncCheckBtn: '更新を確認',
syncCheckingBtn: '確認中…',
syncApplyKbBtn: 'マージを確定',
syncApplyWikiBtn: '上書きを確定',
syncApplyingBtn: '処理中…',
syncNotConfiguredKb: 'ナレッジベース同期元が未設定です(config.json の kbSync.rawUrl)',
syncNotConfiguredWiki: 'Wiki 同期元が未設定です(config.json の wikiSync.owner/repo)',
syncUpToDateKb: (n) => `最新です。現在 ${n} 件のナレッジベース項目があります`,
syncUpToDateWiki: (n) => `最新です。現在 ${n} 個のファイルを追跡中です`,
syncHasUpdateKb: (a,m,r) => `更新が見つかりました:追加 ${a} 件、変更 ${m} 件、削除 ${r} 件。下のボタンでマージを確定してください`,
syncHasUpdateWiki: (a,c,r) => `更新が見つかりました:追加 ${a} 個、変更 ${c} 個、削除 ${r} 個のファイル。下のボタンで上書きを確定してください`,
syncAppliedKb: (n) => `${n} 件をマージしました`,
syncAppliedWiki: (dl,rm) => `上書き完了:${dl} 個のファイルを更新${rm > 0 ? `、${rm} 個のファイルを削除` : ''}`,
syncErrorPrefix: '同期エラー:',
syncLastAppliedWiki: (t) => `前回の同期:${t}`,
syncSourcesTitle: 'ノード提供元リストの同期',
syncNotConfiguredSources: 'ノード提供元の同期が未設定です(config.json の sourceListSync.rawUrl)',
syncUpToDateSources: (n) => `最新です。現在 ${n} 件の候補ソースがあります`,
syncHasUpdateSources: (a,m,r) => `更新が見つかりました:追加 ${a} 件、変更 ${m} 件、削除 ${r} 件。下のボタンでマージを確定してください`,
syncApplySourcesBtn: 'マージを確定',
syncAppliedSources: (n) => `${n} 件のソースをマージしました。自動的に信頼されるわけではなく、通常の試用期間の検証を経ます`,
syncSourcesConflictNote: (n) => `他 ${n} 件は手動で追加済みのソースとIDが重複しているため、安全のためマージされませんでした。ご自身でご確認ください`,
reportTitle: '障害レポート',
reportHint: '現在の状態・最近のイベント・これまでに試みた自己修復アクション・直近のAI診断結果(あれば)を自動でまとめ、フォーラムへの投稿にそのまま貼り付けられるテキストを生成します。',
reportGenerateBtn: 'レポートを生成',
reportGeneratingBtn: '生成中…',
reportCopyBtn: 'コピー',
reportCopiedBtn: 'コピーしました',
reportErrorPrefix: '生成に失敗:',
subTitle: '購読情報',
subLead: '下記のアドレスをクライアントに登録してください。自建ノードに異常があり予備ノードが使える場合は自動的に切り替わり、復旧すれば自動的に戻ります。何もする必要はありません。',
subLabelNanny: 'NodeNanny スマート購読',
subBadgeRecommend: '推奨',
subHintNanny: 'クライアントの「購読」欄にこのアドレスを登録することをお勧めします。普段は自分のノードを使用し、自建ノードに異常があり予備ノードが利用可能な場合は次回の更新時に自動的に予備ノードへ切り替わり、復旧後は自動的に戻ります。',
subLabelNode: '元のノード購読アドレス',
subHintNode: 'これは装機スクリプトが生成した元の購読アドレスで、自分のノードのみを含み、予備ノードへの自動切替はありません。予備または確認用としてのみ使用し、日常は上記のスマート購読アドレスをご利用ください。',
subEmptyNode: 'まだ元の購読アドレスが検出されていません(ワンクリックスクリプトを使用しなかったか、自動検出に失敗した可能性があります。ワンクリックスクリプトを使用したことが確実な場合は、config.json に node.subscriptionUrl を直接手動で追加できます)。',
subWarnNannyUnusable: '現在利用可能な実際のノード購読がないため、スマート購読は一時的に使用できません。config.json の node.subscriptionUrl を確認してください。',
subCopy: 'コピー',
subCopied: 'コピーしました',
events: {
down: p => `${p.node} がポート${p.port}で応答しません`,
recovered: p => p.downMinutes ? `${p.node} が復旧しました(約${p.downMinutes}分の異常)` : `${p.node} が復旧しました`,
restart: p => `サービスの自動再起動を試みました(${p.serviceName || p.node})`,
restart_failed: p => `自動再起動に失敗:${p.error || '不明なエラー'}`,
pool_activated: p => `予備プールの緊急ノードに一時切替しました(利用可能 ${p.count || 0} 件)`,
pool_deactivated: p => `ノードが復旧したため、自建の購読に自動的に戻しました`,
pool_manual_activated: p => `手動で予備プールに強制切替しました(利用可能 ${p.count || 0} 件)。ノードが復旧しても自動では戻りません`,
pool_manual_restored: () => `手動で自建ノードの購読に戻しました`,
pool_refreshed: p => `予備プールを更新しました(現在 ${p.count || 0} 件)`,
pool_refresh_failed: p => `予備プールの更新に失敗:${p.error || '不明なエラー'}`,
pool_unavailable: p => `ノードが異常ですが、利用可能な予備ノードがまだありません`,
ai_diagnosis: p => `AIによる診断結果が届きました`,
ai_diagnosis_failed: p => `AI診断の呼び出しに失敗:${p.error || '不明なエラー'}`,
usability_ok: p => `二層可用性チェック合格:プロキシ経由で実際にリクエストし、正常な応答を確認`,
usability_down: p => `二層可用性チェック失敗:ポートは開いているが、プロキシ経由の実リクエストで期待した応答が得られず(意図的な妨害の可能性)${p.detail ? '(' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_check_error: p => `二層可用性チェック自体に問題が発生(ノード障害とは限りません):${p.detail || '不明な原因'}${kbSuggestionSuffix(p.kb)}`,
usability_restart_suggested: p => `二層可用性チェックが連続して失敗しています——自動再起動は行われません(この種の問題には再起動はほとんど役に立たないため)。手動での確認をおすすめします${p.detail ? '(' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_auto_restart: p => `二層可用性チェックが持続的に異常です。NodeNannyがローカルのプロキシを自動的に一度再起動しました${p.restartOk ? '(再起動コマンドは成功)' : '(再起動コマンドが失敗、手動確認が必要)'}${p.detail ? '(' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
kb_sync_applied: p => `ナレッジベース同期を ${p.count} 件マージしました`,
source_list_sync_applied: p => `公式ノード提供元リストを ${p.count} 件マージしました。通常の試用期間の検証を経ます`,
wiki_sync_applied: p => `Wiki同期を適用しました:${p.downloaded} 個のファイルを更新${p.removed > 0 ? `、${p.removed} 個を削除` : ''}`,
info: p => p.message || ''
}
},
de: {
code: 'DE',
timeZone: 'Europe/Berlin',
tzLabel: 'Berliner Zeit',
nodeWaiting: 'Verbinde mit Knoten…',
status: { ok: 'Ruhig', down: 'Störung – wird behoben', unknown: 'Wartet auf ersten Check' },
durationOk: (d,h,m) => d>0 ? `Seit ${d}T ${h}Std. stabil` : h>0 ? `Seit ${h}Std. ${m}Min. stabil` : `Seit ${m}Min. stabil`,
durationWaiting: 'Wartet auf das Ergebnis der ersten Prüfung',
labelRestarts: 'Neustarts heute',
labelRestartsDetail: (success, attempts) => attempts > 0 ? `${success} erfolgreich, ${attempts - success} fehlgeschlagen` : '',
labelLastcheck: 'Letzte Prüfung',
labelInterval: (n) => `Prüfung alle ${n} Min.`,
navWikiLabel: 'Wiki',
navPoolEventsLabel: 'Pool-Details',
navTerminalLabel: 'Terminal',
logTitle: 'Wächterbuch',
emptyLog: 'Noch nichts — alles ruhig.',
footerNote: 'NodeNanny wacht im Hintergrund — du musst nichts tun',
logout: 'Abmelden',
checkBtn: 'Jetzt prüfen',
checkingBtn: 'Prüfe…',
connError: 'Panel erreicht den Dienst gerade nicht (nicht unbedingt ein Node-Problem — kann ein vorübergehendes Netzwerkproblem sein oder am Panel-Prozess selbst liegen)',
dataStale: 'Panel nicht erreichbar — die folgenden Zahlen sind möglicherweise veraltet und spiegeln nicht unbedingt den aktuellen Status wider',
poolBanner: 'Läuft gerade über den Notfall-Pool — das sind fremde Server, nur zur vorübergehenden Nutzung. Sobald dein eigener Knoten wieder da ist, wird automatisch zurückgeschaltet.',
poolBannerManual: 'Läuft gerade über den Notfall-Pool — das sind fremde Server, nur zur vorübergehenden Nutzung. Du hast manuell umgeschaltet, daher schaltet es nicht automatisch zurück — nutze unten den Schalter, wenn du bereit bist.',
poolManualToggleOn: 'Auf Notfall-Pool umschalten',
poolManualToggleOff: 'Zurück auf eigenen Knoten',
poolManualEmptyError: 'Gerade keine Backup-Knoten verfügbar — Umschalten nicht möglich.',
poolManualToggleError: 'Umschalten fehlgeschlagen, bitte später erneut versuchen.',
poolSourcesSummary: (established, trial) => {
if (established + trial === 0) return '';
const parts = [];
if (established > 0) parts.push(`${established} etablierte Quelle(n)`);
if (trial > 0) parts.push(`${trial} Quelle(n) in der Testphase (neu entdeckt, Vertrauen noch nicht bestätigt)`);
return `Knoten im Notfall-Pool stammen von: ${parts.join(', ')}`;
},
poolActiveNodesLine: (nodes) => {
if (!nodes || nodes.length === 0) return '';
const items = nodes.map((n) => {
const flag = countryCodeToFlag(n.countryCode) || '';
const tierLabel = n.tier === 'trial' ? 'Quelle in Testphase' : 'etablierte Quelle';
return `${flag ? flag + ' ' : ''}${n.sourceId} (${tierLabel})`;
});
return `Aktuell eingesprungen: ${items.join(', ')}`;
},
starmapTitle: 'Sternkarte · Ersatzknoten',
starmapTally: (established, trial, blacklisted) => `${established} etabliert · ${trial} in Testphase · ${blacklisted} gesperrt`,
starmapLegendEstablished: 'Etabliert',
starmapLegendTrial: 'In Testphase',
starmapLegendBlacklisted: 'Gesperrt',
starmapHarborLabel: 'Dein Knoten',
starmapOverflowMain: (n) => `${n} weitere Quelle(n) in Testphase nicht in der Karte angezeigt`,
starmapOverflowBlacklist: (n) => `${n} weitere gesperrte Quelle(n) nicht angezeigt`,
starmapRosterTitle: 'Knotenliste',
starmapTierEstablished: 'Etabliert',
starmapTierTrial: 'Testphase',
starmapTierBlacklisted: 'Gesperrt · vermutlich unzuverlässig',
starmapStat: (passed, total) => `${passed}/${total} Prüfungen bestanden`,
starmapEmpty: 'Der Notfall-Pool ist aktiviert, aber es gibt noch keine anzuzeigenden Quelldaten — entweder wurde der erste Abruf noch nicht ausgeführt, oder keiner der Kandidaten dieser Runde hat die Prüfung bestanden. Beides ist normal, kein Anzeichen für eine Störung.',
starmapCountryUnknown: '🏳️',
aiTitle: 'KI-Diagnose',
aiEmpty: 'Noch keine Diagnose. Läuft automatisch nach genug aufeinanderfolgenden Fehlern, oder manuell auslösen.',
aiDisabledHint: 'KI-Diagnose ist deaktiviert — ai.enabled und deinen eigenen API-Key in config.json setzen',
aiErrorPrefix: 'Diagnose fehlgeschlagen: ',
aiAt: (t) => `Diagnose um ${t}`,
aiDiagnoseBtn: 'Jetzt diagnostizieren',
aiDiagnosingBtn: 'Diagnose läuft…',
syncTitle: 'Inhalts-Synchronisierung',
syncKbTitle: 'Wissensdatenbank-Sync',
syncWikiTitle: 'Wiki-Sync',
syncNeverChecked: 'Noch nicht geprüft',
syncCheckBtn: 'Auf Updates prüfen',
syncCheckingBtn: 'Prüfe…',
syncApplyKbBtn: 'Zusammenführung bestätigen',
syncApplyWikiBtn: 'Überschreiben bestätigen',
syncApplyingBtn: 'Wird angewendet…',
syncNotConfiguredKb: 'Quelle für Wissensdatenbank-Sync nicht konfiguriert (kbSync.rawUrl in config.json)',
syncNotConfiguredWiki: 'Quelle für Wiki-Sync nicht konfiguriert (wikiSync.owner/repo in config.json)',
syncUpToDateKb: (n) => `Aktuell — ${n} Wissensdatenbank-Einträge`,
syncUpToDateWiki: (n) => `Aktuell — ${n} Dateien werden verfolgt`,
syncHasUpdateKb: (a,m,r) => `Update verfügbar: ${a} hinzugefügt, ${m} geändert, ${r} entfernt — unten bestätigen`,
syncHasUpdateWiki: (a,c,r) => `Update verfügbar: ${a} hinzugefügt, ${c} geändert, ${r} entfernte Dateien — unten bestätigen`,
syncAppliedKb: (n) => `${n} Einträge zusammengeführt`,
syncAppliedWiki: (dl,rm) => `Überschreiben abgeschlossen: ${dl} Dateien aktualisiert${rm > 0 ? `, ${rm} Dateien entfernt` : ''}`,
syncErrorPrefix: 'Sync-Fehler: ',
syncLastAppliedWiki: (t) => `Zuletzt synchronisiert: ${t}`,
syncSourcesTitle: 'Quellenlisten-Synchronisierung',
syncNotConfiguredSources: 'Die Quellenlisten-Synchronisierung ist nicht konfiguriert (sourceListSync.rawUrl in config.json)',
syncUpToDateSources: (n) => `Aktuell — derzeit ${n} Kandidatenquelle(n) gelistet`,
syncHasUpdateSources: (a,m,r) => `Update verfügbar: ${a} hinzugefügt, ${m} geändert, ${r} entfernt — unten bestätigen`,
syncApplySourcesBtn: 'Zusammenführung bestätigen',
syncAppliedSources: (n) => `${n} Quelle(n) zusammengeführt — sie durchlaufen die volle Testphase und werden nicht automatisch vertraut`,
syncSourcesConflictNote: (n) => `${n} weitere Quelle(n) teilen sich eine ID mit einer manuell hinzugefügten Quelle und wurden zur Sicherheit nicht zusammengeführt — bitte selbst prüfen`,
reportTitle: 'Ausfallbericht',
reportHint: 'Fasst den aktuellen Status, jüngste Ereignisse, bereits versuchte Selbstheilungs-Maßnahmen und die letzte KI-Diagnose (falls vorhanden) automatisch zu einem Text zusammen, den Sie direkt in einen Forenbeitrag einfügen können.',
reportGenerateBtn: 'Bericht erstellen',
reportGeneratingBtn: 'Wird erstellt…',
reportCopyBtn: 'Kopieren',
reportCopiedBtn: 'Kopiert',
reportErrorPrefix: 'Erstellung fehlgeschlagen: ',
subTitle: 'Abo-Informationen',
subLead: 'Trage die Adresse unten in deinen Client ein. Bei einer Störung deines eigenen Knotens wird automatisch auf einen Ersatzknoten umgeschaltet, sobald einer verfügbar ist, und nach der Wiederherstellung automatisch zurückgeschaltet — du musst nichts tun.',
subLabelNanny: 'NodeNanny Smart-Abo',
subBadgeRecommend: 'Empfohlen',
subHintNanny: 'Trage diese Adresse in das „Abo"-Feld deines Clients ein. Normalerweise zeigt sie auf deinen eigenen Knoten; bei einer Störung und verfügbarem Ersatzknoten schaltet der nächste Abo-Refresh automatisch um, und nach der Wiederherstellung wieder zurück.',
subLabelNode: 'Ursprüngliche Knoten-Abo-Adresse',
subHintNode: 'Dies ist die ursprüngliche Abo-Adresse aus dem Installationsskript — nur dein eigener Knoten, ohne automatische Umschaltung. Nur als Rückfalloption oder zum Abgleich, für den täglichen Gebrauch bitte das Smart-Abo oben verwenden.',
subEmptyNode: 'Noch keine ursprüngliche Abo-Adresse erkannt (entweder wurde das Ein-Klick-Skript nicht verwendet, oder die automatische Erkennung ist fehlgeschlagen — falls du sicher bist, dass du das Ein-Klick-Skript verwendet hast, kannst du node.subscriptionUrl manuell in config.json ergänzen).',
subWarnNannyUnusable: 'Es gibt noch kein funktionierendes Node-Abonnement, daher kann das Smart-Abo derzeit nicht genutzt werden — prüfe node.subscriptionUrl in config.json.',
subCopy: 'Kopieren',
subCopied: 'Kopiert',
events: {
down: p => `${p.node} nicht erreichbar (Port ${p.port})`,
recovered: p => p.downMinutes ? `${p.node} ist wieder da — ca. ${p.downMinutes} Min. ausgefallen` : `${p.node} ist wieder da`,
restart: p => `Automatischer Neustart versucht (${p.serviceName || p.node})`,
restart_failed: p => `Automatischer Neustart fehlgeschlagen: ${p.error || 'unbekannter Fehler'}`,
pool_activated: p => `Vorübergehend auf den Notfall-Pool umgeschaltet (${p.count || 0} Knoten verfügbar)`,
pool_deactivated: p => `Eigener Knoten wieder da — zurück auf dein eigenes Abo umgeschaltet`,
pool_manual_activated: p => `Manuell auf den Notfall-Pool erzwungen (${p.count || 0} Knoten verfügbar) — schaltet auch nach Wiederherstellung nicht automatisch zurück`,
pool_manual_restored: () => `Manuell zurück auf das eigene Abo umgeschaltet`,
pool_refreshed: p => `Notfall-Pool aktualisiert — jetzt ${p.count || 0} Knoten verfügbar`,
pool_refresh_failed: p => `Aktualisierung des Notfall-Pools fehlgeschlagen: ${p.error || 'unbekannter Fehler'}`,
pool_unavailable: p => `Knoten gestört, aber noch keine Ersatzknoten verfügbar`,
ai_diagnosis: p => `KI hat einen Diagnosevorschlag geliefert`,
ai_diagnosis_failed: p => `KI-Diagnoseaufruf fehlgeschlagen: ${p.error || 'unbekannter Fehler'}`,
usability_ok: p => `Layer-2-Nutzbarkeitsprüfung bestanden: eine echte Anfrage über den Proxy erhielt eine normale Antwort`,
usability_down: p => `Layer-2-Nutzbarkeitsprüfung fehlgeschlagen: Port ist offen, aber eine echte Anfrage über den Proxy lieferte nicht das erwartete Ergebnis (möglicherweise gezielte Störung)${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_check_error: p => `Die Layer-2-Prüfung selbst hatte ein Problem (nicht zwingend ein Knotenausfall): ${p.detail || 'unbekannter Grund'}${kbSuggestionSuffix(p.kb)}`,
usability_restart_suggested: p => `Die Layer-2-Nutzbarkeitsprüfung ist wiederholt fehlgeschlagen — dies löst keinen automatischen Neustart aus (bei dieser Art von Problem hilft ein Neustart wahrscheinlich nicht). Ein manueller Blick lohnt sich${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_auto_restart: p => `Die Layer-2-Nutzbarkeitsprüfung schlägt anhaltend fehl — NodeNanny hat den lokalen Proxy automatisch einmal neu gestartet${p.restartOk ? ' (Neustart-Befehl erfolgreich)' : ' (Neustart-Befehl fehlgeschlagen, manuelle Prüfung nötig)'}${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
kb_sync_applied: p => `Wissensdatenbank-Sync: ${p.count} Einträge zusammengeführt`,
source_list_sync_applied: p => `Offizielle Quellenliste synchronisiert: ${p.count} Quelle(n) zusammengeführt — durchlaufen die volle Testphase`,
wiki_sync_applied: p => `Wiki-Sync angewendet: ${p.downloaded} Datei(en) aktualisiert${p.removed > 0 ? `, ${p.removed} entfernt` : ''}`,
info: p => p.message || ''
}
},
ru: {
code: 'RU',
timeZone: 'Europe/Moscow',
tzLabel: 'Московское время',
nodeWaiting: 'Подключение к узлу…',
status: { ok: 'Спокойно', down: 'Сбой — устраняем', unknown: 'Ожидание первой проверки' },
durationOk: (d,h,m) => d>0 ? `Стабильно ${d} дн. ${h} ч.` : h>0 ? `Стабильно ${h} ч. ${m} мин.` : `Стабильно ${m} мин.`,
durationWaiting: 'Ожидаем результат первой проверки',
labelRestarts: 'Перезапуски сегодня',
labelRestartsDetail: (success, attempts) => attempts > 0 ? `успешно: ${success}, неудачно: ${attempts - success}` : '',
labelLastcheck: 'Последняя проверка',
labelInterval: (n) => `Проверка каждые ${n} мин.`,
navWikiLabel: 'Вики',
navPoolEventsLabel: 'Детали пула',
navTerminalLabel: 'Терминал',
logTitle: 'Журнал вахты',
emptyLog: 'Пока пусто — всё спокойно.',
footerNote: 'NodeNanny присматривает в фоне — тебе ничего делать не нужно',
logout: 'Выйти',
checkBtn: 'Проверить сейчас',
checkingBtn: 'Проверка…',
connError: 'Панель сейчас недоступна (это не обязательно означает неисправность узла — возможно, временный сбой сети или проблема самой панели)',
dataStale: 'Нет связи с панелью — цифры ниже могут быть устаревшими и не отражать текущее реальное состояние',
poolBanner: 'Сейчас используется резервный пул — это серверы незнакомцев, только для временного использования. Как только ваш узел восстановится, произойдёт автоматическое переключение обратно.',
poolBannerManual: 'Сейчас используется резервный пул — это серверы незнакомцев, только для временного использования. Вы переключились вручную, поэтому автоматического возврата не будет — используйте переключатель ниже, когда будете готовы.',
poolManualToggleOn: 'Переключить на резервный пул',
poolManualToggleOff: 'Вернуться на свой узел',
poolManualEmptyError: 'Сейчас нет доступных резервных узлов — переключение невозможно.',
poolManualToggleError: 'Не удалось переключить, попробуйте позже.',
poolSourcesSummary: (established, trial) => {
if (established + trial === 0) return '';
const parts = [];
if (established > 0) parts.push(`${established} проверенных источник(ов)`);
if (trial > 0) parts.push(`${trial} источник(ов) на испытательном сроке (новые, доверие ещё не подтверждено)`);
return `Узлы резервного пула получены из: ${parts.join(', ')}`;
},
poolActiveNodesLine: (nodes) => {
if (!nodes || nodes.length === 0) return '';
const items = nodes.map((n) => {
const flag = countryCodeToFlag(n.countryCode) || '';
const tierLabel = n.tier === 'trial' ? 'источник на испытании' : 'проверенный источник';
return `${flag ? flag + ' ' : ''}${n.sourceId} (${tierLabel})`;
});
return `Сейчас подменяют узел: ${items.join(', ')}`;
},
starmapTitle: 'Карта дозора · резервные узлы',
starmapTally: (established, trial, blacklisted) => `${established} проверенных · ${trial} на испытании · ${blacklisted} в чёрном списке`,
starmapLegendEstablished: 'Проверенные',
starmapLegendTrial: 'На испытании',
starmapLegendBlacklisted: 'В чёрном списке',
starmapHarborLabel: 'Ваш узел',
starmapOverflowMain: (n) => `Ещё ${n} источник(ов) на испытании не показаны на карте`,
starmapOverflowBlacklist: (n) => `Ещё ${n} источник(ов) из чёрного списка не показаны`,
starmapRosterTitle: 'Список узлов',
starmapTierEstablished: 'Проверенный',
starmapTierTrial: 'На испытании',
starmapTierBlacklisted: 'В чёрном списке · подозрение на ненадёжность',
starmapStat: (passed, total) => `${passed}/${total} проверок пройдено`,
starmapEmpty: 'Резервный пул включён, но пока нет данных об источниках — либо первая загрузка ещё не выполнена, либо ни один из кандидатов этого раунда не прошёл проверку. Оба варианта — норма, а не признак сбоя.',
starmapCountryUnknown: '🏳️',
aiTitle: 'ИИ-диагностика',
aiEmpty: 'Диагностики пока нет. Запускается автоматически после нескольких сбоев подряд, либо можно запустить вручную.',
aiDisabledHint: 'ИИ-диагностика отключена — задайте ai.enabled и свой API-ключ в config.json',
aiErrorPrefix: 'Ошибка диагностики: ',
aiAt: (t) => `Время диагностики: ${t}`,
aiDiagnoseBtn: 'Диагностировать сейчас',
aiDiagnosingBtn: 'Диагностика…',
syncTitle: 'Синхронизация контента',
syncKbTitle: 'Синхронизация базы знаний',
syncWikiTitle: 'Синхронизация вики',
syncNeverChecked: 'Ещё не проверялось',
syncCheckBtn: 'Проверить обновления',
syncCheckingBtn: 'Проверка…',
syncApplyKbBtn: 'Подтвердить слияние',
syncApplyWikiBtn: 'Подтвердить перезапись',
syncApplyingBtn: 'Применение…',
syncNotConfiguredKb: 'Источник синхронизации базы знаний не настроен (kbSync.rawUrl в config.json)',
syncNotConfiguredWiki: 'Источник синхронизации вики не настроен (wikiSync.owner/repo в config.json)',
syncUpToDateKb: (n) => `Актуально — записей базы знаний: ${n}`,
syncUpToDateWiki: (n) => `Актуально — отслеживается файлов: ${n}`,
syncHasUpdateKb: (a,m,r) => `Найдено обновление: добавлено ${a}, изменено ${m}, удалено ${r} — подтвердите слияние ниже`,
syncHasUpdateWiki: (a,c,r) => `Найдено обновление: добавлено ${a}, изменено ${c}, удалено ${r} файлов — подтвердите перезапись ниже`,
syncAppliedKb: (n) => `Объединено записей: ${n}`,
syncAppliedWiki: (dl,rm) => `Перезапись завершена: обновлено файлов ${dl}${rm > 0 ? `, удалено файлов ${rm}` : ''}`,
syncErrorPrefix: 'Ошибка синхронизации: ',
syncLastAppliedWiki: (t) => `Последняя синхронизация: ${t}`,
syncSourcesTitle: 'Синхронизация списка источников',
syncNotConfiguredSources: 'Синхронизация списка источников не настроена (sourceListSync.rawUrl в config.json)',
syncUpToDateSources: (n) => `Актуально — сейчас ${n} источник(ов)-кандидатов`,
syncHasUpdateSources: (a,m,r) => `Найдено обновление: добавлено ${a}, изменено ${m}, удалено ${r} — подтвердите слияние ниже`,
syncApplySourcesBtn: 'Подтвердить слияние',
syncAppliedSources: (n) => `Объединено источников: ${n} — они пройдут полный пробный период, а не станут доверенными автоматически`,
syncSourcesConflictNote: (n) => `Ещё ${n} источник(ов) имеют тот же ID, что и добавленные вручную, и не были объединены в целях безопасности — проверьте их самостоятельно`,
reportTitle: 'Отчёт о сбое',
reportHint: 'Автоматически собирает текущий статус, последние события, уже предпринятые действия самовосстановления и последний вывод AI-диагностики (если есть) в текст, который можно сразу вставить в сообщение на форуме.',
reportGenerateBtn: 'Сформировать отчёт',
reportGeneratingBtn: 'Формирование…',
reportCopyBtn: 'Копировать',
reportCopiedBtn: 'Скопировано',
reportErrorPrefix: 'Ошибка формирования: ',
subTitle: 'Информация о подписке',
subLead: 'Добавьте ссылку ниже в клиент. При сбое вашего узла произойдёт автоматическое переключение на резервный узел, а после восстановления — автоматическое переключение обратно. Вам ничего не нужно делать.',
subLabelNanny: 'Умная подписка NodeNanny',
subBadgeRecommend: 'Рекомендуется',
subHintNanny: 'Рекомендуем указать этот адрес в поле «подписка» вашего клиента. Обычно он указывает на ваш собственный узел; при сбое и наличии резервного узла следующее обновление подписки автоматически переключится на резерв, а после восстановления — обратно.',
subLabelNode: 'Исходный адрес подписки узла',
subHintNode: 'Это исходный адрес подписки, сгенерированный скриптом установки — только ваш собственный узел, без автоматического переключения на резерв. Используйте только как запасной вариант или для сверки, для повседневного использования — умную подписку выше.',
subEmptyNode: 'Исходный адрес подписки пока не обнаружен (либо скрипт в один клик не использовался, либо автоопределение не сработало — если вы уверены, что использовали скрипт в один клик, можно вручную добавить node.subscriptionUrl в config.json).',
subWarnNannyUnusable: 'Сейчас нет рабочей подписки узла, поэтому умная подписка временно недоступна — проверьте node.subscriptionUrl в config.json.',
subCopy: 'Копировать',
subCopied: 'Скопировано',
events: {
down: p => `${p.node} недоступен на порту ${p.port}`,
recovered: p => p.downMinutes ? `${p.node} снова в порядке — простой ~${p.downMinutes} мин.` : `${p.node} снова в порядке`,
restart: p => `Попытка автоматического перезапуска (${p.serviceName || p.node})`,
restart_failed: p => `Не удалось перезапустить: ${p.error || 'неизвестная ошибка'}`,
pool_activated: p => `Временно переключено на резервный пул (доступно узлов: ${p.count || 0})`,
pool_deactivated: p => `Узел восстановлен — переключено обратно на вашу подписку`,
pool_manual_activated: p => `Вручную принудительно переключено на резервный пул (доступно узлов: ${p.count || 0}) — не переключится обратно автоматически`,
pool_manual_restored: () => `Вручную переключено обратно на собственный узел`,
pool_refreshed: p => `Резервный пул обновлён — доступно узлов: ${p.count || 0}`,
pool_refresh_failed: p => `Не удалось обновить резервный пул: ${p.error || 'неизвестная ошибка'}`,
pool_unavailable: p => `Узел недоступен, а резервных узлов пока нет`,
ai_diagnosis: p => `ИИ предоставил рекомендацию по диагностике`,
ai_diagnosis_failed: p => `Не удалось выполнить ИИ-диагностику: ${p.error || 'неизвестная ошибка'}`,
usability_ok: p => `Проверка реальной доступности пройдена: реальный запрос через прокси получил нормальный ответ`,
usability_down: p => `Проверка реальной доступности не пройдена: порт открыт, но реальный запрос через прокси не дал ожидаемого результата (возможна целевая блокировка)${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_check_error: p => `Проблема в самой проверке реальной доступности (это не обязательно означает сбой узла): ${p.detail || 'неизвестная причина'}${kbSuggestionSuffix(p.kb)}`,
usability_restart_suggested: p => `Проверка реальной доступности не проходит несколько раз подряд — это не вызовет автоматический перезапуск (в таком случае перезапуск вряд ли поможет). Стоит проверить вручную${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
usability_auto_restart: p => `Проверка реальной доступности постоянно не проходит — NodeNanny автоматически перезапустил локальный прокси один раз${p.restartOk ? ' (команда перезапуска выполнена успешно)' : ' (команда перезапуска не выполнилась, нужна ручная проверка)'}${p.detail ? ' (' + p.detail + ')' : ''}${kbSuggestionSuffix(p.kb)}`,
kb_sync_applied: p => `Синхронизация базы знаний: объединено записей — ${p.count}`,
source_list_sync_applied: p => `Официальный список источников синхронизирован: объединено ${p.count} — пройдут полный пробный период`,
wiki_sync_applied: p => `Синхронизация вики применена: обновлено файлов — ${p.downloaded}${p.removed > 0 ? `, удалено — ${p.removed}` : ''}`,
info: p => p.message || ''
}
}
};
let lang = localStorage.getItem('nodenanny-lang') || (navigator.language || 'zh').slice(0,2);
if (!I18N[lang]) lang = 'en';
let latestData = null;
let latestStarmapData = null;
let poolActiveNow = false;
let poolManualToggleBusy = false; // 防止手快连点,切换请求进行中时按钮先禁用
function t() { return I18N[lang]; }
function buildLangSwitch() {
const el = document.getElementById('lang-switch');
el.innerHTML = Object.keys(I18N).map(code =>
`<button data-lang="${code}" class="${code === lang ? 'active' : ''}">${I18N[code].code}</button>`
).join('');
el.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', () => {
lang = btn.dataset.lang;
localStorage.setItem('nodenanny-lang', lang);
document.documentElement.lang = lang;
buildLangSwitch();
renderStatic();
if (latestData) renderData(latestData);
});
});
}
function renderStatic() {
const d = t();
document.getElementById('wiki-nav-label').textContent = d.navWikiLabel;
document.getElementById('pool-events-nav-label').textContent = d.navPoolEventsLabel;
document.getElementById('terminal-launch-label').textContent = d.navTerminalLabel;
document.getElementById('log-title-text').textContent = d.logTitle;
document.getElementById('log-tz-label').textContent = `(${d.tzLabel})`;
document.getElementById('footer-note').textContent = d.footerNote;
document.getElementById('check-btn').textContent = d.checkBtn;
document.getElementById('logout-btn').textContent = d.logout;
document.getElementById('label-restarts').textContent = d.labelRestarts;
document.getElementById('label-lastcheck').textContent = d.labelLastcheck;
document.getElementById('pool-banner-text').textContent = d.poolBanner;
document.getElementById('ai-title').textContent = d.aiTitle;
const btn = document.getElementById('ai-diagnose-btn');
if (!btn.disabled) btn.textContent = d.aiDiagnoseBtn;
document.getElementById('sub-title').textContent = d.subTitle;
document.getElementById('sub-lead').textContent = d.subLead;
document.getElementById('sub-label-nanny').textContent = d.subLabelNanny;
document.getElementById('sub-badge-recommend').textContent = d.subBadgeRecommend;
document.getElementById('sub-hint-nanny').textContent = d.subHintNanny;
document.getElementById('sub-label-node').textContent = d.subLabelNode;
document.getElementById('sub-hint-node').textContent = d.subHintNode;
document.getElementById('sub-empty-node').textContent = d.subEmptyNode;
document.getElementById('sub-warning-nanny').textContent = d.subWarnNannyUnusable;
document.getElementById('sub-copy-nanny').textContent = d.subCopy;
document.getElementById('sub-copy-node').textContent = d.subCopy;
document.getElementById('starmap-title').textContent = d.starmapTitle;
document.getElementById('starmap-legend-established').textContent = d.starmapLegendEstablished;
document.getElementById('starmap-legend-trial').textContent = d.starmapLegendTrial;
document.getElementById('starmap-legend-blacklisted').textContent = d.starmapLegendBlacklisted;
document.getElementById('starmap-roster-title').textContent = d.starmapRosterTitle;
document.getElementById('starmap-empty').textContent = d.starmapEmpty;
// 流量池检测明细已在v26拆分成独立页面(pool-events.html),这里不再渲染,
// 也不应再引用 latestPoolEventsData/renderPoolEvents——v26遗留的这两处引用是
// 真实bug(变量和函数都已不存在,会导致renderStatic()执行到这里直接抛
// ReferenceError,后面的星图重绘等逻辑全部被中断),本次已删除。
// 星图数据本身不跟着 renderData() 的轮询走(它来自单独的 /api/pool/starmap 接口),
// 但切换语言时图上的文字(港口标注/溢出提示/名册)全部是用 JS 现画的 SVG 文本和 DOM,
// 需要用最近一次拿到的数据重新画一遍,不然语言切换后星图这块还是旧语言。
if (latestStarmapData) renderStarmap(latestStarmapData);
document.getElementById('sync-title').textContent = d.syncTitle;
renderSyncKb();
renderSyncWiki();
renderSyncSources();
document.getElementById('report-title').textContent = d.reportTitle;
document.getElementById('report-hint').textContent = d.reportHint;
renderReportCard();
}
// 时区处理:不再依赖浏览器裸 locale(比如 "zh"、"en")去猜 12/24 小时制和 AM/PM,
// 而是给每种界面语言固定绑定一个具体时区(用 IANA 时区名交给 Intl 处理夏令时等换算),
// 同时固定 hour12:false,不再让引擎自己猜格式。
function localTimeOptions(extra) {
const tz = (I18N[lang] && I18N[lang].timeZone) || 'UTC';
return Object.assign({ timeZone: tz, hour12: false }, extra || {});
}
function formatClock(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleTimeString(lang, localTimeOptions({ hour: '2-digit', minute: '2-digit' }));
}
function renderLog(events) {
const d = t();
const list = document.getElementById('log-list');
if (!events || events.length === 0) {
list.innerHTML = `<p class="empty-log">${d.emptyLog}</p>`;
return;
}
const TICK = {
down: '✕', recovered: '✓', restart: '↻', restart_failed: '!',
pool_activated: '⇄', pool_deactivated: '⇄', pool_manual_activated: '⇄', pool_manual_restored: '⇄', pool_refreshed: '↻', pool_refresh_failed: '!', pool_unavailable: '!',
ai_diagnosis: '✦', ai_diagnosis_failed: '!',
usability_ok: '✓', usability_down: '⚠', usability_check_error: '!', usability_restart_suggested: '!', usability_auto_restart: '↻',
kb_sync_applied: '⇄', wiki_sync_applied: '⇄', source_list_sync_applied: '⇄',
info: '·'
};
list.innerHTML = events.map(ev => {
const time = new Date(ev.time).toLocaleString(lang, localTimeOptions({ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }));
const renderer = d.events[ev.type] || d.events.info;
const msg = renderer(ev.params || {});
return `
<div class="log-item type-${ev.type}">
<span class="log-time">${time}</span>
<span class="log-tick">${TICK[ev.type] || '·'}</span>
<span class="log-msg">${msg}</span>
</div>`;
}).join('');
}
function renderAi(aiData) {
const d = t();
const content = document.getElementById('ai-content');
const btn = document.getElementById('ai-diagnose-btn');
const enabled = !!(aiData && aiData.enabled);
btn.style.display = enabled ? 'inline-block' : 'none';
if (!enabled) {
content.innerHTML = `<p class="ai-disabled-hint">${d.aiDisabledHint}</p>`;
return;
}
const diag = aiData.diagnosis;
if (!diag) {
content.innerHTML = `<p class="ai-empty">${d.aiEmpty}</p>`;
return;
}
const time = new Date(diag.at).toLocaleString(lang, localTimeOptions({ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }));
if (diag.error) {
content.innerHTML = `<p class="ai-error"></p><p class="ai-meta">${d.aiAt(time)}</p>`;
content.querySelector('.ai-error').textContent = d.aiErrorPrefix + diag.error;
} else {
content.innerHTML = `<p class="ai-body"></p><p class="ai-meta">${d.aiAt(time)}</p>`;
content.querySelector('.ai-body').textContent = diag.text;
}
}
/* 数字滚动:数值变化时旧数字上滑消失、新数字从下方滑入;数值没变则不触发动画 */
function rollNumber(containerId, newText) {
const el = document.getElementById(containerId);
const oldSlide = el.querySelector('.slide');
const oldText = oldSlide ? oldSlide.textContent : null;
newText = String(newText);
if (oldText === newText) return;
if (!oldSlide) {
el.innerHTML = `<span class="slide">${newText}</span>`;
return;
}
const newSlide = document.createElement('span');
newSlide.className = 'slide';
newSlide.textContent = newText;
newSlide.style.position = 'absolute';
newSlide.style.top = '24px';
newSlide.style.left = '0';
el.appendChild(newSlide);
requestAnimationFrame(() => {
oldSlide.style.transform = 'translateY(-24px)';
newSlide.style.transform = 'translateY(-24px)';
});
setTimeout(() => {
if (oldSlide.parentNode) oldSlide.remove();
newSlide.style.position = '';
newSlide.style.top = '';
newSlide.style.transform = '';
}, 550);
}
function renderData(data) {
const d = t();
document.body.className = 'status-' + (data.status || 'unknown');
document.getElementById('node-label').textContent = data.nodeName || '—';
document.getElementById('status-line').textContent = d.status[data.status] || d.status.unknown;
document.getElementById('duration-line').innerHTML = data.status !== 'unknown' && data.duration
? `<strong>${d.durationOk(data.duration.days, data.duration.hours, data.duration.minutes)}</strong>`
: d.durationWaiting;
rollNumber('stat-restarts', data.restartsToday ?? 0);
document.getElementById('label-restarts-detail').textContent =
d.labelRestartsDetail(data.restartsSuccessToday ?? 0, data.restartsToday ?? 0);
rollNumber('stat-lastcheck', formatClock(data.lastCheck));
rollNumber('stat-interval', data.checkIntervalMinutes || '—');
document.getElementById('label-interval').textContent = d.labelInterval(data.checkIntervalMinutes || '—');
const poolBanner = document.getElementById('pool-banner');
const usingPool = !!(data.pool && data.pool.activeSource === 'pool');
const manualOverride = !!(data.pool && data.pool.manualOverride);
poolBanner.classList.toggle('show', usingPool);
poolBanner.classList.toggle('manual', manualOverride);
document.getElementById('pool-banner-text').textContent = manualOverride ? d.poolBannerManual : d.poolBanner;
// 手动模式的灯塔视觉是叠加在status-*之上的独立class,跟status本身是不是ok/down无关——
// 自建节点没准还是好的,只是被手动切去用池子了,这时候也要显蓝色,不能被status-ok盖过去。
document.body.classList.toggle('pool-manual', manualOverride);
// 手动切换开关按钮:流量池功能没开的话直接隐藏,不给用户一个点了也没用的按钮。
const manualToggleBtn = document.getElementById('pool-manual-toggle-btn');
const poolFeatureEnabled = !!(data.pool && data.pool.enabled);
manualToggleBtn.classList.toggle('show', poolFeatureEnabled);
manualToggleBtn.classList.toggle('active', manualOverride);
manualToggleBtn.textContent = manualOverride ? d.poolManualToggleOff : d.poolManualToggleOn;
manualToggleBtn.disabled = poolManualToggleBusy;
// 复查发现的遗漏(本轮补完,不是新需求):问题8的前端展示此前只加了容器div和五语言文案
// (poolSourcesSummary),但从没有代码真正读取data.pool.sources、算出来源统计、填进这个
// div——用户此前完全看不到"来源可信状态",跟后端已经落盘的数据是脱节的。这里补上:
// 统计"已转正/legacy"来源数(status为空或trusted)和"观察期"来源数(status为trial),
// 只统计这一轮ok:true(真的产出过节点)的来源,没有产出的来源不计入,避免把"这一轮
// 抓取失败"的来源也算进"有多少个来源在支撑池子"这个用户关心的数字里。
const sourcesLine = document.getElementById('pool-sources-line');
const poolSources = (data.pool && data.pool.sources) || [];
const established = poolSources.filter((s) => s.ok && (!s.status || s.status === 'trusted')).length;
const trial = poolSources.filter((s) => s.ok && s.status === 'trial').length;
const sourcesText = d.poolSourcesSummary(established, trial);
if (usingPool && sourcesText) {
sourcesLine.textContent = sourcesText;
sourcesLine.style.display = 'block';
} else {
sourcesLine.style.display = 'none';
}
// 批次五·第一批新增(交接文档40.6/40.7节):"当前接替的备用节点"这一行,数据来自
// /api/status新增的data.pool.activeNodes(只在activeSource==='pool'时后端才会填,
// 否则是空数组),跟上面sourcesLine是两回事——sourcesLine统计的是"这一轮刷新一共
// 有哪些来源产出过节点",这一行展示的是"眼下订阅里实际在用的是哪几个来源",多数
// 情况下两者重合,但语义不同,分开展示更准确。
const activeNodesLine = document.getElementById('pool-active-nodes-line');
const activeNodesText = d.poolActiveNodesLine(data.pool && data.pool.activeNodes);
if (usingPool && activeNodesText) {
activeNodesLine.textContent = activeNodesText;
activeNodesLine.style.display = 'block';
} else {
activeNodesLine.style.display = 'none';
}
renderLog(data.events);
renderAi(data.ai);
// 星图的"接替航线"高光要知道现在是不是正用着流量池,这个状态只有 /api/status
// 这份数据里有(星图自己的 /api/pool/starmap 接口不返回 activeSource),这里存成
// 全局变量,星图数据本身刷新较慢(6小时一轮),但高光状态跟着 30 秒轮询实时更新。
if (poolActiveNow !== usingPool) {
poolActiveNow = usingPool;
if (latestStarmapData) renderStarmap(latestStarmapData);
}
}
async function refresh() {
try {
const res = await fetch('/api/status');
if (res.status === 401) {
window.location.href = '/login.html';
return;
}
const data = await res.json();
latestData = data;
renderData(data);
// 请求成功,清除上一次失败时留下的"数据可能已过时"标记(如果有的话)。
const staleBanner = document.getElementById('stale-banner');
staleBanner.classList.remove('show');
document.querySelector('.stat-grid').classList.remove('is-stale');
document.getElementById('log-list').classList.remove('is-stale');
} catch (err) {
// 发现17 修复(批次四第三轮真机测试):以前这里只改了 status-line 一行文字,
// 下面的重启次数/最近检测时间/守望记录这些数字原样留在屏幕上、没有任何"可能已过时"的
// 提示,真机上出现过"状态行说连不上、但数字看起来像是刚发生的"这种误导画面,持续了
// 十几个小时没人发现——这跟"用户要能看懂、自己拿主意"这条核心目标直接冲突。
// 现在改成:状态行照常提示连不上,同时统计数字区和守望记录整体加上过期视觉标记(变灰变暗),
// 并且专门显示一条更醒目的"以下数字可能已过时"提示,不再只依赖那一行小字。
document.getElementById('status-line').textContent = t().connError;
const staleBanner = document.getElementById('stale-banner');
staleBanner.textContent = t().dataStale;
staleBanner.classList.add('show');
document.querySelector('.stat-grid').classList.add('is-stale');
document.getElementById('log-list').classList.add('is-stale');
}
}
/* ---------- 订阅信息(批次二新增) ---------- */
// 二维码用纯前端 qrcodejs 库(走 CDN),不新增 npm 依赖。
// 用白底方块包裹二维码,保证在深色主题下扫码时对比度仍然足够。
function renderQr(containerId, text) {
const el = document.getElementById(containerId);
el.innerHTML = '';
if (!text) return;
if (typeof QRCode === 'undefined') {
// CDN 加载失败(比如网络问题),静默跳过二维码,链接文本依然可见可复制
return;
}
new QRCode(el, {
text: text,
width: 84,
height: 84,
correctLevel: QRCode.CorrectLevel.M
});
}
function renderSubscriptionInfo(data) {
const d = t();
document.getElementById('sub-url-nanny').textContent = data.nannySubUrl || '—';
renderQr('sub-qr-nanny', data.nannySubUrl);
// 发现5修复:智能订阅当前是否真的能用,由后端 nannySubUsable 判定,
// 不再无条件显示"这不影响智能订阅地址正常使用"这句过度乐观的话。
const nannyWarning = document.getElementById('sub-warning-nanny');
if (data.nannySubUsable === false) {
nannyWarning.style.display = '';
nannyWarning.textContent = d.subWarnNannyUnusable;
} else {
nannyWarning.style.display = 'none';
}
const nodeBody = document.getElementById('sub-node-body');
const nodeEmptyHint = document.getElementById('sub-empty-node');
const nodeHint = document.getElementById('sub-hint-node');
if (data.nodeSubscriptionUrl) {
document.getElementById('sub-url-node').textContent = data.nodeSubscriptionUrl;
renderQr('sub-qr-node', data.nodeSubscriptionUrl);
nodeBody.style.display = '';
nodeHint.style.display = '';
nodeEmptyHint.style.display = 'none';
} else {
nodeBody.style.display = 'none';
nodeHint.style.display = 'none';
nodeEmptyHint.style.display = '';
nodeEmptyHint.textContent = d.subEmptyNode;
}
}
async function loadSubscriptionInfo() {
try {
const res = await fetch('/api/subscription-info');
if (res.status === 401) return; // 未登录的情况交给主 refresh() 统一跳转登录页,这里静默跳过即可
const data = await res.json();
renderSubscriptionInfo(data);
} catch (err) {
// 订阅信息拿不到不影响面板主状态展示,静默失败;真机验证留给创始人自己装机时做
}
}
/* ---------- 守望星图(批次三第三步·前端渲染) ---------- */
// 国家二字码转国旗 emoji,纯计算,不依赖任何库。查不到 countryCode 时用问号旗代替
// (跟 pool.js 里 geoip 查不出来时返回 null 的情况对应)。
function countryCodeToFlag(code) {
if (!code || code.length !== 2) return null;
const base = 127397; // 'A'.charCodeAt(0) 到区域指示符号 emoji 的偏移量
const upper = code.toUpperCase();
if (!/^[A-Z]{2}$/.test(upper)) return null;
return String.fromCodePoint(upper.charCodeAt(0) + base, upper.charCodeAt(1) + base);
}
function svgEl(tag, attrs, text) {
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const k in attrs) if (attrs[k] !== undefined && attrs[k] !== null) el.setAttribute(k, attrs[k]);
if (text != null) el.textContent = text;
return el;
}
// 灯塔仪表盘 v2(UI改版·灯塔):生成 60 格钟表式刻度,每 5 格加粗一点,呼应"像钟"的整体
// 意象,替代原来只有 4 个十字准星的简朴表盘。只在页面加载时画一次(表盘尺寸固定不变),
// 不需要跟着状态刷新重新生成,方向定稿见 lighthouse-concept-preview.html 概念稿。
function renderGaugeTicks() {
const group = document.getElementById('gauge-ticks');
if (!group) return;
const cx = 150, cy = 150;
for (let i = 0; i < 60; i++) {
const angle = (i / 60) * 360;
const isMajor = i % 5 === 0;
const r1 = isMajor ? 100 : 104;
const r2 = 112;
const rad = (angle - 90) * Math.PI / 180;
const x1 = cx + r1 * Math.cos(rad);
const y1 = cy + r1 * Math.sin(rad);
const x2 = cx + r2 * Math.cos(rad);
const y2 = cy + r2 * Math.sin(rad);
group.appendChild(svgEl('line', {
x1, y1, x2, y2,
class: `gauge-tick ${isMajor ? 'major' : 'minor'}`,
'stroke-width': isMajor ? 1.1 : 0.7
}));
}
}
renderGaugeTicks();
// data 是 /api/pool/starmap 返回的原始数据(canvasWidth/canvasHeight/harborArea/stars/
// overflowCount/blacklistOverflowCount),坐标已经由后端 core/star-layout.js 算好,
// 这里只负责画,不做任何布局计算——职责跟设计文档(三十六.6节)说的一致。
function renderStarmap(data) {
const d = t();
const card = document.getElementById('starmap-card');
const emptyBox = document.getElementById('starmap-empty');
const gridBox = document.querySelector('#starmap-card .starmap-grid');
if (!data || !data.enabled) {
// 流量池本身没开:这个功能对用户不适用,整张卡片隐藏,不留一个空壳占地方。
card.style.display = 'none';
return;
}
card.style.display = '';
const stars = data.stars || [];
if (stars.length === 0) {
// 流量池开了,但暂时没有任何来源数据(还没跑过第一轮 / 这一轮全没通过检测):
// 如实告诉用户这不代表故障,而不是干脆不显示这张卡片让用户以为功能没生效。
gridBox.style.display = 'none';
emptyBox.style.display = '';
emptyBox.textContent = d.starmapEmpty;
document.getElementById('starmap-tally').textContent = '';
return;
}
gridBox.style.display = '';
emptyBox.style.display = 'none';
const establishedStars = stars.filter((s) => s.status === 'established');
const trialStars = stars.filter((s) => s.status === 'trial');
const blacklistedStars = stars.filter((s) => s.status === 'blacklisted');
document.getElementById('starmap-tally').textContent =
d.starmapTally(establishedStars.length, trialStars.length, blacklistedStars.length);
// ---- 画图 ----
const svg = document.getElementById('starmap-svg');
svg.setAttribute('viewBox', `0 0 ${data.canvasWidth} ${data.canvasHeight}`);
svg.innerHTML = '';
// 港口图标:代表用户自己的主节点,位置由后端按画布尺寸换算好传过来。
const harbor = data.harborArea;
if (harbor) {
const cx = (harbor.xMin + harbor.xMax) / 2;
const topY = harbor.yMin;
const group = svgEl('g', { transform: `translate(${harbor.xMin},${topY})` });
group.appendChild(svgEl('path', { d: 'M0 14 L14 14 L7 0 Z', class: 'starmap-harbor-mark' }));
group.appendChild(svgEl('rect', { x: 6, y: 14, width: 2, height: 8, class: 'starmap-harbor-mark' }));
const label = svgEl('text', {
x: 7, y: 34, 'text-anchor': 'middle', class: 'starmap-harbor-label'
}, d.starmapHarborLabel);
group.appendChild(label);
svg.appendChild(group);
// 签名动效:正在用流量池应急节点时,从港口向每一个"这一轮真的贡献了通过检测节点"
// 的星星画一条黄铜色航线,呼应设计稿(pool-starchart-v2.html)的"星图导航"叙事。
// 不是只画一条线到"唯一的当前来源"——因为实际行为是把池子里所有通过检测的节点
// 同时放进订阅(见交接文档v30补充里对这个问题的说明),画全部更符合真实情况。
if (poolActiveNow) {
stars.forEach((s) => {
if (s.status === 'blacklisted' || !(s.passed > 0)) return;
const path = svgEl('path', {
d: `M${cx} ${harbor.yMax} C ${cx} ${(harbor.yMax + s.y) / 2}, ${s.x} ${(harbor.yMax + s.y) / 2}, ${s.x} ${s.y - 5}`,
class: 'starmap-route'
});
svg.appendChild(path);
});
}
}
// 背景装饰星点,纯视觉,不代表任何数据。
const bgDots = [[10,10],[70,16],[160,12],[260,28],[320,55],[45,95],[120,205],[290,180],[15,145],[220,230]];
bgDots.forEach(([x, y]) => {
if (x < data.canvasWidth && y < data.canvasHeight) {
svg.appendChild(svgEl('circle', { cx: x, cy: y, r: 1, fill: '#e4dcc8', opacity: 0.18 }));
}
});
stars.forEach((s, i) => {
if (s.status === 'blacklisted') {
const g = svgEl('g', { transform: `translate(${s.x},${s.y})`, opacity: 0.4 });
g.appendChild(svgEl('line', { x1: -3.5, y1: -3.5, x2: 3.5, y2: 3.5, stroke: 'var(--rust)', 'stroke-width': 1.2 }));
g.appendChild(svgEl('line', { x1: -3.5, y1: 3.5, x2: 3.5, y2: -3.5, stroke: 'var(--rust)', 'stroke-width': 1.2 }));
svg.appendChild(g);
} else {
const r = s.status === 'established' ? 3.4 : 2.4;
svg.appendChild(svgEl('circle', {
class: `star tier-${s.status} star-halo`, cx: s.x, cy: s.y, r,
style: `animation-delay:${(i * 0.35).toFixed(2)}s`
}));
if (poolActiveNow && s.passed > 0) {
const ring = svgEl('circle', {
cx: s.x, cy: s.y, r: 6,
style: 'fill:none;stroke:var(--brass-soft);stroke-width:1;opacity:0.85;animation:sm-ring-pulse 1.8s ease-out infinite;'
});
svg.appendChild(ring);
}
}
});
if (data.overflowCount > 0) {
svg.appendChild(svgEl('text', { x: 6, y: 12, class: 'starmap-overflow-note' }, d.starmapOverflowMain(data.overflowCount)));
}
if (data.blacklistOverflowCount > 0) {
svg.appendChild(svgEl('text', { x: 6, y: 24, class: 'starmap-overflow-note' }, d.starmapOverflowBlacklist(data.blacklistOverflowCount)));
}
// ---- 画名册 ----
const tierLabel = (status) => status === 'established' ? d.starmapTierEstablished
: status === 'trial' ? d.starmapTierTrial : d.starmapTierBlacklisted;
const orderedStars = establishedStars.concat(trialStars, blacklistedStars);
const rosterList = document.getElementById('starmap-roster-list');
rosterList.innerHTML = orderedStars.map((s) => {
const flag = countryCodeToFlag(s.countryCode) || d.starmapCountryUnknown;
const statLine = s.candidateCount > 0 ? d.starmapStat(s.passed || 0, s.candidateCount) : '';
return `
<div class="starmap-row">
<span class="starmap-flag">${flag}</span>
<span class="starmap-source">${escapeHtml(s.sourceId)}${statLine ? `<span class="starmap-source-stat">${statLine}</span>` : ''}</span>
<span class="starmap-tier-tag ${s.status}">${tierLabel(s.status)}</span>
</div>`;
}).join('');
}
function escapeHtml(str) {
return String(str == null ? '' : str).replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
}
async function loadStarmap() {
try {
const res = await fetch('/api/pool/starmap');
if (res.status === 401) return; // 同 loadSubscriptionInfo:跳转登录交给主 refresh() 统一处理
const data = await res.json();
latestStarmapData = data;
renderStarmap(data);
} catch (err) {
// 星图数据拿不到不影响面板主状态展示,静默失败,保留上一次成功渲染的画面
}
}
// ---------- 内容同步卡片(本轮新增):知识库/Wiki百科的远程同步面板入口 ----------
// 后端(core/kb-sync.js、core/wiki-sync.js、panel-server.js对应路由)此前就已经写完并有
// 单元测试覆盖,唯独面板前端完全没有入口——创始人作为非运维角色,没有UI就没法自己用,
// 只能靠开发者手动敲curl测试。这里补上这一层,纯前端状态展示+按钮,不改动任何后端逻辑。
// kb-sync走"check→看diff→确认合并(默认全量接受,不做逐条勾选,保持简单)"三步;
// wiki-sync走"check→看diff→确认覆盖"两步——跟各自后端模块文件头注释描述的流程一致。
let syncKbState = { checked: false, hasUpdate: false, applied: false, entryCount: 0, added: 0, modified: 0, removed: 0, appliedCount: 0, error: null };
let syncWikiState = { checked: false, hasUpdate: false, applied: false, trackedFileCount: 0, added: 0, changed: 0, removed: 0, downloadedCount: 0, removedCount: 0, error: null, lastAppliedAt: null };
// 官方节点来源列表同步(v39新增):走的是跟kb-sync一模一样的"check→diff→人工确认合并"三步流程,
// 合并对象是真实config.json里的pool.manualSources,见core/source-list-sync.js。
let syncSourcesState = { checked: false, hasUpdate: false, applied: false, sourceCount: 0, added: 0, modified: 0, removed: 0, pendingAcceptedIds: [], appliedCount: 0, error: null };
function renderSyncKb() {
const d = t();
const statusEl = document.getElementById('sync-kb-status');
const resultEl = document.getElementById('sync-kb-result');
const checkBtn = document.getElementById('sync-kb-check-btn');
const applyBtn = document.getElementById('sync-kb-apply-btn');
document.getElementById('sync-kb-title').textContent = d.syncKbTitle;
if (!checkBtn.disabled) checkBtn.textContent = d.syncCheckBtn;
if (!applyBtn.disabled) applyBtn.textContent = d.syncApplyKbBtn;
resultEl.classList.remove('sync-error');
if (syncKbState.error === 'kb_sync_not_configured') {
statusEl.textContent = d.syncNotConfiguredKb;
resultEl.textContent = '';
checkBtn.style.display = 'none';
applyBtn.style.display = 'none';
return;
}
checkBtn.style.display = '';
if (syncKbState.applied) {
statusEl.textContent = '';
resultEl.textContent = d.syncAppliedKb(syncKbState.appliedCount);
applyBtn.style.display = 'none';
return;
}
if (syncKbState.error) {
statusEl.textContent = '';
resultEl.textContent = d.syncErrorPrefix + syncKbState.error;
resultEl.classList.add('sync-error');
applyBtn.style.display = 'none';
return;
}
if (!syncKbState.checked) {
statusEl.textContent = d.syncNeverChecked;
resultEl.textContent = '';
applyBtn.style.display = 'none';
return;
}
if (syncKbState.hasUpdate) {
statusEl.textContent = '';
resultEl.textContent = d.syncHasUpdateKb(syncKbState.added, syncKbState.modified, syncKbState.removed);
applyBtn.style.display = '';
} else {
statusEl.textContent = d.syncUpToDateKb(syncKbState.entryCount);
resultEl.textContent = '';
applyBtn.style.display = 'none';
}
}
function renderSyncWiki() {
const d = t();
const statusEl = document.getElementById('sync-wiki-status');
const resultEl = document.getElementById('sync-wiki-result');
const checkBtn = document.getElementById('sync-wiki-check-btn');
const applyBtn = document.getElementById('sync-wiki-apply-btn');
document.getElementById('sync-wiki-title').textContent = d.syncWikiTitle;
if (!checkBtn.disabled) checkBtn.textContent = d.syncCheckBtn;
if (!applyBtn.disabled) applyBtn.textContent = d.syncApplyWikiBtn;
resultEl.classList.remove('sync-error');
if (syncWikiState.error === 'wiki_sync_not_configured') {
statusEl.textContent = d.syncNotConfiguredWiki;
resultEl.textContent = '';
checkBtn.style.display = 'none';
applyBtn.style.display = 'none';
return;
}
checkBtn.style.display = '';
if (syncWikiState.applied) {
statusEl.textContent = '';
resultEl.textContent = d.syncAppliedWiki(syncWikiState.downloadedCount, syncWikiState.removedCount);
applyBtn.style.display = 'none';
return;
}
if (syncWikiState.error) {
statusEl.textContent = '';
resultEl.textContent = d.syncErrorPrefix + syncWikiState.error;
resultEl.classList.add('sync-error');
applyBtn.style.display = 'none';
return;
}
if (!syncWikiState.checked) {
statusEl.textContent = syncWikiState.lastAppliedAt
? d.syncLastAppliedWiki(new Date(syncWikiState.lastAppliedAt).toLocaleString(lang, localTimeOptions({ month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })))
: d.syncNeverChecked;
resultEl.textContent = '';
applyBtn.style.display = 'none';
return;
}
if (syncWikiState.hasUpdate) {
statusEl.textContent = '';
resultEl.textContent = d.syncHasUpdateWiki(syncWikiState.added, syncWikiState.changed, syncWikiState.removed);
applyBtn.style.display = '';
} else {
statusEl.textContent = d.syncUpToDateWiki(syncWikiState.trackedFileCount);
resultEl.textContent = '';
applyBtn.style.display = 'none';
}
}
function renderSyncSources() {
const d = t();
const statusEl = document.getElementById('sync-sources-status');
const resultEl = document.getElementById('sync-sources-result');
const checkBtn = document.getElementById('sync-sources-check-btn');
const applyBtn = document.getElementById('sync-sources-apply-btn');
document.getElementById('sync-sources-title').textContent = d.syncSourcesTitle;
if (!checkBtn.disabled) checkBtn.textContent = d.syncCheckBtn;
if (!applyBtn.disabled) applyBtn.textContent = d.syncApplySourcesBtn;
resultEl.classList.remove('sync-error');
if (syncSourcesState.error === 'source_list_sync_not_configured') {
statusEl.textContent = d.syncNotConfiguredSources;
resultEl.textContent = '';
checkBtn.style.display = 'none';
applyBtn.style.display = 'none';
return;
}
checkBtn.style.display = '';
if (syncSourcesState.applied) {
statusEl.textContent = '';
resultEl.textContent = d.syncAppliedSources(syncSourcesState.appliedCount);
applyBtn.style.display = 'none';
return;
}
if (syncSourcesState.error) {
statusEl.textContent = '';
resultEl.textContent = d.syncErrorPrefix + syncSourcesState.error;
resultEl.classList.add('sync-error');
applyBtn.style.display = 'none';
return;
}
if (!syncSourcesState.checked) {
statusEl.textContent = d.syncNeverChecked;
resultEl.textContent = '';
applyBtn.style.display = 'none';
return;
}
if (syncSourcesState.hasUpdate) {
statusEl.textContent = '';
resultEl.textContent = d.syncHasUpdateSources(syncSourcesState.added, syncSourcesState.modified, syncSourcesState.removed);
applyBtn.style.display = '';
} else {
statusEl.textContent = d.syncUpToDateSources(syncSourcesState.sourceCount);
resultEl.textContent = '';
applyBtn.style.display = 'none';
}
}
// wiki-sync有持久化的本地状态(getStatus读data/wiki-sync-state.json),进面板时先拉一次,
// 这样"上次同步时间/追踪文件数"不用等用户手动点检查才看得到。kb-sync后端没有对应的
// 只读status接口(只有check/diff/apply),保持"还没检查过"的初始文案即可,不额外造接口。
async function loadSyncStatus() {
try {
const res = await fetch('/api/wiki-sync/status');
if (res.status === 401) return;
const data = await res.json();
if (data.ok && data.status) {
syncWikiState = Object.assign({}, syncWikiState, {
trackedFileCount: data.status.trackedFileCount,
lastAppliedAt: data.status.lastAppliedAt
});
renderSyncWiki();
}
} catch (err) {
// 拿不到初始状态不影响面板其它功能,静默失败,用户仍可手动点"检查更新"
}
}
document.getElementById('sync-kb-check-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.syncCheckingBtn;
try {
const res = await fetch('/api/kb/sync/check', { method: 'POST' });
const data = await res.json();
if (!data.ok) {
syncKbState = { checked: true, error: data.error || 'unknown_error' };
} else if (data.hasUpdate) {
const diffRes = await fetch('/api/kb/sync/diff');
const diff = await diffRes.json();
syncKbState = diff.ok
? { checked: true, hasUpdate: true, added: diff.added.length, modified: diff.modified.length, removed: diff.removed.length, entryCount: data.entryCount, error: null }
: { checked: true, error: diff.error || 'diff_failed' };
} else {
syncKbState = { checked: true, hasUpdate: false, entryCount: data.entryCount, error: null };
}
} catch (err) {
syncKbState = { checked: true, error: err.message };
} finally {
btn.disabled = false;
renderSyncKb();
}
});
document.getElementById('sync-kb-apply-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.syncApplyingBtn;
try {
const res = await fetch('/api/kb/sync/apply', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({})
});
const data = await res.json();
syncKbState = data.ok
? { checked: true, applied: true, appliedCount: data.appliedCount, error: null }
: { checked: true, error: data.error || 'apply_failed' };
if (data.ok) await refresh(); // 事件日志里会多一条kb_sync_applied,顺手刷新一下
} catch (err) {
syncKbState = { checked: true, error: err.message };
} finally {
btn.disabled = false;
renderSyncKb();
}
});
document.getElementById('sync-sources-check-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.syncCheckingBtn;
try {
const res = await fetch('/api/sources/sync/check', { method: 'POST' });
const data = await res.json();
if (!data.ok) {
syncSourcesState = { checked: true, error: data.error || 'unknown_error' };
} else if (data.hasUpdate) {
const diffRes = await fetch('/api/sources/sync/diff');
const diff = await diffRes.json();
// 2026-08-01修复:后端diffStagingAgainstCurrent()返回的字段是toAdd/toUpdate/conflicts,
// 不是added/modified/removed——这里之前一直读不存在的字段,导致点"检查更新"必现
// "undefined is not an object (evaluating 'diff.added.length')"。另外这个同步机制
// 设计上只会新增/更新本地manualSources,从不会删除founder自己已有的源,所以removed
// 恒为0,不是漏算,是这个功能本来就没有"移除"这个概念。
// 2026-08-01再修复:光算对了added/modified的数量还不够——"确认合并"按钮点击时
// 需要知道具体是哪些id要合并,所以这里把toAdd/toUpdate里的id都存进
// syncSourcesState.pendingAcceptedIds,供下面apply按钮的handler直接用。
const pendingAcceptedIds = diff.ok
? [...diff.toAdd.map((e) => e.id), ...diff.toUpdate.map((c) => c.id)]
: [];
syncSourcesState = diff.ok
? { checked: true, hasUpdate: true, added: diff.toAdd.length, modified: diff.toUpdate.length, removed: 0, sourceCount: data.sourceCount, pendingAcceptedIds, error: null }
: { checked: true, error: diff.error || 'diff_failed' };
} else {
syncSourcesState = { checked: true, hasUpdate: false, sourceCount: data.sourceCount, error: null };
}
} catch (err) {
syncSourcesState = { checked: true, error: err.message };
} finally {
btn.disabled = false;
renderSyncSources();
}
});
document.getElementById('sync-sources-apply-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.syncApplyingBtn;
try {
// 2026-08-01修复:这里之前一直发空body,后端applyStaging()收到的acceptedIds
// 永远是undefined→空数组,导致点"确认合并"实际什么都不会合并("已合并0个来源")。
// 现在把"检查更新"那一步存下来的pendingAcceptedIds(toAdd+toUpdate的id全集)传过去。
const res = await fetch('/api/sources/sync/apply', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ acceptedIds: syncSourcesState.pendingAcceptedIds || [] })
});
const data = await res.json();
syncSourcesState = data.ok
? { checked: true, applied: true, appliedCount: data.appliedCount, error: null }
: { checked: true, error: data.error || 'apply_failed' };
if (data.ok) await refresh(); // 事件日志里会多一条source_list_sync_applied,顺手刷新一下
} catch (err) {
syncSourcesState = { checked: true, error: err.message };
} finally {
btn.disabled = false;
renderSyncSources();
}
});
document.getElementById('sync-wiki-check-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.syncCheckingBtn;
try {
const res = await fetch('/api/wiki-sync/check', { method: 'POST' });
const data = await res.json();
syncWikiState = data.ok
? Object.assign({}, syncWikiState, {
checked: true, applied: false, error: null,
hasUpdate: data.hasUpdate,
added: data.added.length, changed: data.changed.length, removed: data.removed.length,
trackedFileCount: data.totalRemoteFiles
})
: Object.assign({}, syncWikiState, { checked: true, applied: false, error: data.error || 'unknown_error' });
} catch (err) {
syncWikiState = Object.assign({}, syncWikiState, { checked: true, applied: false, error: err.message });
} finally {
btn.disabled = false;
renderSyncWiki();
}
});
document.getElementById('sync-wiki-apply-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.syncApplyingBtn;
try {
const res = await fetch('/api/wiki-sync/apply', { method: 'POST' });
const data = await res.json();
if (data.ok) {
syncWikiState = Object.assign({}, syncWikiState, {
applied: true, downloadedCount: data.downloadedCount, removedCount: data.removedCount, error: null
});
await refresh(); // 事件日志里会多一条wiki_sync_applied,顺手刷新一下
} else {
const failCount = (data.failures || []).length;
syncWikiState = Object.assign({}, syncWikiState, {
applied: false, error: failCount > 0 ? `partial_failure(${failCount})` : (data.error || 'apply_failed')
});
}
} catch (err) {
syncWikiState = Object.assign({}, syncWikiState, { applied: false, error: err.message });
} finally {
btn.disabled = false;
renderSyncWiki();
}
});
// ---------- 故障报告卡片(任务清单第4项):点"生成报告"调用后端组装好的纯文本,
// 展示在只读textarea里,方便用户全选/用"复制"按钮直接复制去发论坛帖子。
// 报告内容本身跟随面板当前语言(lang)生成,不需要额外的语言选择。
let reportState = { text: null, error: null };
function renderReportCard() {
const d = t();
const generateBtn = document.getElementById('report-generate-btn');
const copyBtn = document.getElementById('report-copy-btn');
const textarea = document.getElementById('report-textarea');
const hintEl = document.getElementById('report-hint');
if (!generateBtn.disabled) generateBtn.textContent = d.reportGenerateBtn;
if (!copyBtn.disabled) copyBtn.textContent = d.reportCopyBtn;
if (reportState.error) {
hintEl.textContent = d.reportErrorPrefix + reportState.error;
textarea.style.display = 'none';
copyBtn.style.display = 'none';
return;
}
hintEl.textContent = d.reportHint;
if (reportState.text) {
textarea.style.display = '';
textarea.value = reportState.text;
copyBtn.style.display = '';
} else {
textarea.style.display = 'none';
copyBtn.style.display = 'none';
}
}
document.getElementById('report-generate-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.reportGeneratingBtn;
try {
const res = await fetch('/api/failure-report?lang=' + encodeURIComponent(lang));
const data = await res.json();
reportState = data.ok ? { text: data.text, error: null } : { text: null, error: data.error || 'unknown_error' };
} catch (err) {
reportState = { text: null, error: err.message };
} finally {
btn.disabled = false;
renderReportCard();
}
});
document.getElementById('report-copy-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
if (!reportState.text) return;
try {
await navigator.clipboard.writeText(reportState.text);
btn.textContent = d.reportCopiedBtn;
setTimeout(() => { btn.textContent = d.reportCopyBtn; }, 1500);
} catch (err) {
// Clipboard API 不可用时静默失败,用户仍可以在textarea里手动全选复制
}
});
document.querySelectorAll('.sub-copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const targetEl = document.getElementById(btn.dataset.target);
const text = targetEl ? targetEl.textContent : '';
if (!text || text === '—') return;
try {
await navigator.clipboard.writeText(text);
const original = t().subCopy;
btn.textContent = t().subCopied;
setTimeout(() => { btn.textContent = original; }, 1500);
} catch (err) {
// Clipboard API 在非 HTTPS/无权限环境下可能不可用,静默失败,用户仍可手动选中文本复制
}
});
});
document.getElementById('check-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.checkingBtn;
try {
await fetch('/api/check', { method: 'POST' });
await refresh();
} finally {
btn.disabled = false;
btn.textContent = d.checkBtn;
}
});
document.getElementById('logout-btn').addEventListener('click', async () => {
try {
await fetch('/api/logout', { method: 'POST' });
} finally {
window.location.href = '/login.html';
}
});
// 手动切换备用池开关:点一下调切换接口,切换完刷新一次面板数据,让灯塔视觉/横幅/
// 按钮文案立刻反映最新状态,不用等下一轮30秒轮询。
async function handlePoolManualToggle() {
if (poolManualToggleBusy) return;
poolManualToggleBusy = true;
const btn = document.getElementById('pool-manual-toggle-btn');
btn.disabled = true;
const d = t();
try {
const res = await fetch('/api/pool/manual-toggle', { method: 'POST' });
const result = await res.json().catch(() => ({}));
if (!res.ok || !result.ok) {
if (result.error === 'pool_empty') {
alert(d.poolManualEmptyError);
} else {
alert(d.poolManualToggleError);
}
return;
}
await refresh();
} catch (err) {
alert(d.poolManualToggleError);
} finally {
poolManualToggleBusy = false;
btn.disabled = false;
}
}
document.getElementById('ai-diagnose-btn').addEventListener('click', async (e) => {
const btn = e.currentTarget;
const d = t();
btn.disabled = true;
btn.textContent = d.aiDiagnosingBtn;
try {
await fetch('/api/ai/diagnose', { method: 'POST' });
await refresh();
} finally {
btn.disabled = false;
btn.textContent = t().aiDiagnoseBtn;
}
});
/* ---------- 终端悬浮层:开关逻辑 ----------
iframe.src默认是空的,只有第一次点开时才赋值成/terminal.html——避免
还没点开就提前建立WebSocket连接。关闭时把src重置为about:blank,
让iframe内的页面被卸载,WebSocket随之干净断开,不会挂在后台占用会话。 */
const terminalOverlay = document.getElementById('terminal-overlay');
const terminalFrame = document.getElementById('terminal-overlay-frame');
function openTerminalOverlay() {
if (!terminalFrame.src || terminalFrame.src === 'about:blank') {
terminalFrame.src = '/terminal.html?embedded=1';
}
terminalOverlay.classList.add('open');
terminalOverlay.setAttribute('aria-hidden', 'false');
}
function closeTerminalOverlay() {
terminalOverlay.classList.remove('open');
terminalOverlay.setAttribute('aria-hidden', 'true');
terminalFrame.src = 'about:blank';
}
document.getElementById('terminal-launch-btn').addEventListener('click', openTerminalOverlay);
document.getElementById('terminal-overlay-close').addEventListener('click', closeTerminalOverlay);
document.getElementById('terminal-overlay-backdrop').addEventListener('click', closeTerminalOverlay);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && terminalOverlay.classList.contains('open')) closeTerminalOverlay();
});
// terminal.html嵌入在悬浮层里时,面包屑"返回主面板"不再跳转页面本身,
// 而是通过postMessage告诉外层关闭悬浮层——这里监听那条消息。
window.addEventListener('message', (e) => {
if (e.origin !== window.location.origin) return;
if (e.data === 'nodenanny:terminal:close') closeTerminalOverlay();
});
document.documentElement.lang = lang;
buildLangSwitch();
renderStatic();
refresh();
loadSubscriptionInfo();
loadStarmap();
loadSyncStatus();
setInterval(refresh, 30000);
// 星图数据(来源信任状态、国家码)不像主状态那样秒级变化,后端本身也只在流量池
// 刷新周期(默认 6 小时)里才会有新内容,60 秒轮询一次足够及时,不需要跟主状态
// 一样 30 秒——两者独立轮询,互不影响。
setInterval(loadStarmap, 60000);
</script>
</body>
</html>