feat: 全站亮色/暗色主题切换

实现方案: CSS自定义属性 + Tailwind arbitrary values

新增文件:
- web/app/vendor/theme.css — CSS变量定义(亮色+暗色)

修改文件 (15个HTML + 2个JS + 2个Python):
- layout.js — 侧边栏🌙主题切换按钮 + loadTheme/toggleTheme
- settings.py — MUTABLE_KEYS加theme/editor_theme
- 15个HTML页面 — bg-slate-*替换为bg-[var(--bg-*)] + 引入theme.css

替换统计:
- index.html: 48处
- servers.html: 183处
- files.html: 94处
- push.html: 157处
- scripts.html: 83处
- credentials.html: 100处
- schedules.html: 59处
- retries.html: 40处
- commands.html: 57处
- alerts.html: 47处
- audit.html: 32处
- settings.html: 125处
- terminal.html: 89处
- install.html: 72处
- login.html: 14处
总计: 1199处替换

功能:
- 侧边栏底部🌙/☀️按钮切换亮色/暗色
- 主题偏好保存到MySQL(settings表)
- 页面加载时自动恢复主题
- Monaco编辑器主题独立控制(⚙️菜单)
This commit is contained in:
Your Name
2026-05-31 02:55:45 +08:00
parent 94fbdf7b2d
commit 293a0d64fe
20 changed files with 966 additions and 757 deletions
+22
View File
@@ -212,6 +212,28 @@ class FileWrite(BaseModel):
content: str = Field(..., max_length=5_000_000)
class FileChmod(BaseModel):
"""Change file permissions on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
mode: str = Field(..., min_length=3, max_length=4, pattern=r"^[0-7]{3,4}$")
class FileCompress(BaseModel):
"""Compress files into an archive on a remote server."""
server_id: int = Field(..., ge=1)
paths: list[str] = Field(..., min_length=1, max_length=100)
dest: str = Field(..., min_length=1, max_length=4096)
format: str = Field("tar.gz", pattern=r"^(tar\.gz|zip)$")
class FileDecompress(BaseModel):
"""Decompress an archive on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1, max_length=4096)
dest: str = Field(".", min_length=1, max_length=4096)
# ── Script ──
class ScriptCreate(BaseModel):
+1
View File
@@ -46,6 +46,7 @@ MUTABLE_KEYS: set[str] = {
"notify_recovery", "notify_time_drift",
"notify_system_redis", "notify_system_mysql", "notify_restart",
"editor_theme", # Monaco editor theme preference
"theme", # 全站主题 (dark/light)
}
# S-02: Per-key validation rules: (type, min, max) for int settings
+107 -1
View File
@@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite, FileChmod, FileCompress, FileDecompress
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
@@ -1197,3 +1197,109 @@ async def write_file(
)
return {"success": True, "path": payload.path, "size": len(payload.content)}
@router.post("/chmod")
async def chmod_file(
payload: FileChmod,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Change file permissions on a remote server."""
import shlex
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
cmd = f"chmod {shlex.quote(payload.mode)} {shlex.quote(payload.path)}"
result = await exec_ssh_command(server, cmd, timeout=10)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"chmod 失败: {result['stderr'][:200]}")
await _audit_sync(
"file_chmod", "server", payload.server_id,
f"chmod {payload.mode} {payload.path}",
admin.username, request,
)
return {"success": True, "path": payload.path, "mode": payload.mode}
@router.post("/compress")
async def compress_files(
payload: FileCompress,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Compress files into tar.gz or zip archive."""
import shlex
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
quoted_paths = " ".join(shlex.quote(p) for p in payload.paths)
dest = shlex.quote(payload.dest)
if payload.format == "tar.gz":
cmd = f"tar czf {dest} {quoted_paths}"
else:
cmd = f"zip -r {dest} {quoted_paths}"
result = await exec_ssh_command(server, cmd, timeout=120)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"压缩失败: {result['stderr'][:300]}")
await _audit_sync(
"file_compress", "server", payload.server_id,
f"压缩 {len(payload.paths)} 个文件 → {payload.dest}",
admin.username, request,
)
return {"success": True, "dest": payload.dest, "format": payload.format}
@router.post("/decompress")
async def decompress_file(
payload: FileDecompress,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""Decompress a tar.gz or zip archive."""
import shlex
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
path = shlex.quote(payload.path)
dest = shlex.quote(payload.dest)
if payload.path.endswith(".tar.gz") or payload.path.endswith(".tgz"):
cmd = f"tar xzf {path} -C {dest}"
elif payload.path.endswith(".zip"):
cmd = f"unzip -o {path} -d {dest}"
else:
raise HTTPException(status_code=400, detail="仅支持 .tar.gz/.tgz 和 .zip 格式")
result = await exec_ssh_command(server, cmd, timeout=120)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"解压失败: {result['stderr'][:300]}")
await _audit_sync(
"file_decompress", "server", payload.server_id,
f"解压 {payload.path}{payload.dest}",
admin.username, request,
)
return {"success": True, "path": payload.path, "dest": payload.dest}
+36 -34
View File
@@ -1,17 +1,19 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 告警中心</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 告警中心</title><script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<h1 class="text-white font-semibold">告警中心</h1>
</div>
<div class="flex items-center gap-2 flex-wrap">
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="serverFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部服务器</option>
</select>
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="typeFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部类型</option>
<option value="cpu">CPU</option>
<option value="mem">内存</option>
@@ -19,15 +21,15 @@
<option value="time_drift">时钟偏差</option>
<option value="system">系统</option>
</select>
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="kindFilter" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">告警+恢复</option>
<option value="alert">仅告警</option>
<option value="recovery">仅恢复</option>
</select>
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<span class="text-slate-600 text-xs"></span>
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<span class="text-[var(--text-dim)] text-xs"></span>
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6 space-y-6">
@@ -36,15 +38,15 @@
<div id="statsSection" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"></div>
<!-- Top alerting servers -->
<div id="topServersSection" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
<div id="topServersSection" class="hidden bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<h2 class="text-white font-medium text-sm mb-3">告警最多的服务器(近 7 天)</h2>
<div id="topServersList" class="space-y-2"></div>
</div>
<!-- Alert list -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table class="w-full text-sm min-w-[700px]">
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<tr>
<th class="text-left px-4 py-3">状态</th>
<th class="text-left px-4 py-3">类型</th>
@@ -54,17 +56,17 @@
</tr>
</thead>
<tbody id="alertsTbody" class="divide-y divide-slate-800">
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="pagination" class="hidden flex items-center justify-between text-sm">
<span id="pageInfo" class="text-slate-500 text-xs"></span>
<span id="pageInfo" class="text-[var(--text-muted)] text-xs"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
</div>
</div>
</main>
@@ -77,7 +79,7 @@
let _offset = 0, _total = 0;
const TYPE_LABEL = {cpu:'CPU',mem:'内存',disk:'磁盘',time_drift:'时钟偏差',system:'系统'};
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-slate-400'};
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-[var(--text-secondary)]'};
async function loadServers(){
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
@@ -94,8 +96,8 @@
document.getElementById('topServersSection').classList.remove('hidden');
document.getElementById('topServersList').innerHTML=d.top_servers.map((s,i)=>`
<div class="flex items-center gap-3">
<span class="w-5 text-slate-600 text-xs text-right">${i+1}</span>
<div class="flex-1 bg-slate-800 rounded-full h-2 overflow-hidden">
<span class="w-5 text-[var(--text-dim)] text-xs text-right">${i+1}</span>
<div class="flex-1 bg-[var(--bg-surface)] rounded-full h-2 overflow-hidden">
<div class="h-full bg-red-500/70 rounded-full transition-all" style="width:${Math.round(s.count/d.top_servers[0].count*100)}%"></div>
</div>
<span class="text-slate-300 text-xs w-32 truncate">${esc(s.server_name)}</span>
@@ -106,20 +108,20 @@
const totalAlerts=(d.daily||[]).reduce((s,r)=>s+r.alerts,0);
const totalRecoveries=(d.daily||[]).reduce((s,r)=>s+r.recoveries,0);
document.getElementById('statsSection').innerHTML=`
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs mb-1">近 7 天告警次数</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天告警次数</div>
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs mb-1">近 7 天恢复次数</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天恢复次数</div>
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs mb-1">告警最多的类型</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的类型</div>
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs mb-1">告警最多的服务器</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的服务器</div>
<div class="text-white text-base font-bold truncate">${(d.top_servers||[])[0]?.server_name||'--'}</div>
</div>`;
}
@@ -153,20 +155,20 @@
const logs=data.items||[];
const tbody=document.getElementById('alertsTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无告警记录</td></tr>';document.getElementById('pagination').classList.add('hidden');return}
tbody.innerHTML=logs.map(l=>{
const isRec=l.is_recovery;
const statusBadge=isRec
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/30 text-green-400 border border-green-500/30">🟢 恢复</span>'
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-red-900/30 text-red-400 border border-red-500/30">🔴 告警</span>';
const typeColor=TYPE_COLOR[l.alert_type]||'text-slate-400';
const typeColor=TYPE_COLOR[l.alert_type]||'text-[var(--text-secondary)]';
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-2.5">${statusBadge}</td>
<td class="px-4 py-2.5 ${typeColor} text-xs font-medium">${esc(TYPE_LABEL[l.alert_type]||l.alert_type)}</td>
<td class="px-4 py-2.5 text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono">${esc(l.value||'--')}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono">${esc(l.value||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
+25 -23
View File
@@ -1,11 +1,13 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 审计日志</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 审计日志</title><script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">审计日志</h1></div>
<div class="flex items-center gap-2 flex-wrap">
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部操作</option>
<optgroup label="服务器">
<option value="create_server">添加服务器</option>
@@ -54,26 +56,26 @@
</optgroup>
</select>
<input id="adminFilter" type="text" placeholder="操作人" onchange="_offset=0;loadAudit()"
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm w-28 placeholder-slate-500">
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm w-28 placeholder-slate-500">
<input id="dateFrom" type="date" onchange="_offset=0;loadAudit()"
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<span class="text-slate-600 text-xs"></span>
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<span class="text-[var(--text-dim)] text-xs"></span>
<input id="dateTo" type="date" onchange="_offset=0;loadAudit()"
class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<button onclick="clearFilters()" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded-lg transition" title="清除日期过滤"></button>
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<button onclick="clearFilters()" class="px-2 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition" title="清除日期过滤"></button>
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="auditTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
<div id="auditTable" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase"><tr><th class="text-left px-4 py-3">操作人</th><th class="text-left px-4 py-3">操作</th><th class="text-left px-4 py-3">目标</th><th class="text-left px-4 py-3">详情</th><th class="text-left px-4 py-3">IP</th><th class="text-right px-4 py-3">时间</th></tr></thead><tbody id="auditTbody" class="divide-y divide-slate-800"><tr><td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
</div>
<!-- Pagination -->
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
<span id="pageInfo" class="text-slate-500 text-sm"></span>
<span id="pageInfo" class="text-[var(--text-muted)] text-sm"></span>
<div class="flex items-center gap-2">
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">上一页</button>
<button onclick="nextPage()" id="nextBtn" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
</div>
</div>
</main>
@@ -142,17 +144,17 @@
_total=data.total||0;
const logs=data.items||[];
const tbody=document.getElementById('auditTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">暂无日志</td></tr>'}else{
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无日志</td></tr>'}else{
tbody.innerHTML=logs.map(l=>{
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-slate-800 text-slate-300';
const badge=l.action.includes('login')?'bg-green-400/10 text-green-400':l.action.includes('delete')?'bg-red-400/10 text-red-400':l.action.includes('create')||l.action.includes('install')?'bg-blue-400/10 text-blue-400':'bg-[var(--bg-surface)] text-slate-300';
const actionName=ACTION_NAMES[l.action]||l.action;
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-3 text-white">${esc(l.admin_username||'--')}</td>
<td class="px-4 py-3"><span class="px-2 py-0.5 rounded text-xs ${badge}">${esc(actionName)}</span></td>
<td class="px-4 py-3 text-slate-400 text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
<td class="px-4 py-3 text-slate-500 text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
<td class="px-4 py-3 text-slate-600 text-xs">${esc(l.ip_address||'--')}</td>
<td class="px-4 py-3 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
<td class="px-4 py-3 text-[var(--text-secondary)] text-xs">${esc(TARGET_NAMES[l.target_type]||l.target_type||'--')} ${l.target_id?'#'+l.target_id:''}</td>
<td class="px-4 py-3 text-[var(--text-muted)] text-xs max-w-xs truncate" title="${esc(l.detail||'')}">${esc((l.detail||'').substring(0,80))}</td>
<td class="px-4 py-3 text-[var(--text-dim)] text-xs">${esc(l.ip_address||'--')}</td>
<td class="px-4 py-3 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`}).join('');
}
updatePagination();
+49 -47
View File
@@ -1,28 +1,30 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 操作日志</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 操作日志</title><script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">操作日志</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">操作日志</h1></div>
<div class="flex items-center gap-3">
<!-- View selector -->
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="commands">SSH 命令记录</option>
<option value="sessions">SSH 会话</option>
<option value="push">推送日志</option>
</select>
<!-- Server filter (all views) -->
<select id="serverFilter" onchange="onFilterChange()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm max-w-[160px]">
<select id="serverFilter" onchange="onFilterChange()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm max-w-[160px]">
<option value="">全部服务器</option>
</select>
<!-- Push-specific filters (only shown for push view) -->
<select id="pushStatusFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="pushStatusFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="running">运行中</option>
</select>
<select id="pushModeFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="pushModeFilter" onchange="onFilterChange()" class="hidden px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部模式</option>
<option value="incremental">增量</option>
<option value="full">全量</option>
@@ -30,20 +32,20 @@
<option value="checksum">校验和</option>
<option value="command">命令</option>
</select>
<button onclick="loadData()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="loadData()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- Commands View -->
<div id="commandsView">
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table class="w-full text-sm min-w-[640px]">
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<tr><th class="text-left px-4 py-3">命令</th><th class="text-left px-4 py-3 whitespace-nowrap">服务器</th><th class="text-left px-4 py-3">会话</th><th class="text-left px-4 py-3">来源IP</th><th class="text-right px-4 py-3 whitespace-nowrap">时间</th></tr>
</thead>
<tbody id="commandsTbody" class="divide-y divide-slate-800">
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -51,13 +53,13 @@
<!-- Sessions View -->
<div id="sessionsView" class="hidden">
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table class="w-full text-sm min-w-[700px]">
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<tr><th class="text-left px-4 py-3">会话ID</th><th class="text-left px-4 py-3">服务器</th><th class="text-left px-4 py-3">来源IP</th><th class="text-left px-4 py-3">状态</th><th class="text-left px-4 py-3 whitespace-nowrap">开始时间</th><th class="text-left px-4 py-3 whitespace-nowrap">结束时间</th><th class="text-right px-4 py-3">操作</th></tr>
</thead>
<tbody id="sessionsTbody" class="divide-y divide-slate-800">
<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
<tr><td colspan="7" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -65,9 +67,9 @@
<!-- Push Logs View -->
<div id="pushView" class="hidden space-y-3">
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-x-auto">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table class="w-full text-sm min-w-[800px]">
<thead class="bg-slate-800/50 text-slate-400 text-xs uppercase">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<tr>
<th class="text-left px-4 py-3">状态</th>
<th class="text-left px-4 py-3">服务器</th>
@@ -82,17 +84,17 @@
</tr>
</thead>
<tbody id="pushTbody" class="divide-y divide-slate-800">
<tr><td colspan="10" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr>
<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="pushPagination" class="hidden flex items-center justify-between text-sm">
<span id="pushTotal" class="text-slate-500 text-xs"></span>
<span id="pushTotal" class="text-[var(--text-muted)] text-xs"></span>
<div class="flex items-center gap-2">
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
<span id="pushPageInfo" class="text-slate-400 text-xs"></span>
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">← 上一页</button>
<span id="pushPageInfo" class="text-[var(--text-secondary)] text-xs"></span>
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
</div>
</div>
</div>
@@ -158,16 +160,16 @@
if (!r) return;
const logs = await r.json();
const tbody = document.getElementById('commandsTbody');
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">暂无命令日志</td></tr>'; return; }
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无命令日志</td></tr>'; return; }
tbody.innerHTML = logs.map(l => {
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
const isDanger = /rm\s|chmod\s|chown\s|dd\s|mkfs|fdisk|:()\s*\{.*&\s*\}|wget\s.*\|\s*(ba)?sh|curl\s.*\|\s*(ba)?sh/i.test(l.command || '');
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-2.5 font-mono text-xs ${isDanger?'text-red-400':'text-slate-200'} max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5"><a href="#" onclick="filterBySession('${l.session_id||''}');return false" class="text-brand-light hover:underline text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</a></td>
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
}
@@ -180,18 +182,18 @@
if (!r) return;
const sessions = await r.json();
const tbody = document.getElementById('sessionsTbody');
if (!sessions.length) { tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-slate-500">暂无SSH会话</td></tr>'; return; }
if (!sessions.length) { tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无SSH会话</td></tr>'; return; }
tbody.innerHTML = sessions.map(s => {
const badge = s.status==='active'
?'<span class="px-2 py-0.5 rounded text-xs bg-green-400/10 text-green-400">在线</span>'
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-slate-400">已断开</span>';
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-[var(--text-secondary)]">已断开</span>';
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-2.5 font-mono text-xs text-brand-light">${esc((s.id||'').substring(0,8))}...</td>
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(s.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(s.remote_addr||'--')}</td>
<td class="px-4 py-2.5">${badge}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs whitespace-nowrap">${fmt(s.closed_at)}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs whitespace-nowrap">${fmt(s.closed_at)}</td>
<td class="px-4 py-2.5 text-right"><button onclick="viewSessionCommands('${s.id}')" class="text-brand-light hover:text-white text-xs transition">查看命令</button></td>
</tr>`;
}).join('');
@@ -216,13 +218,13 @@
const tbody = document.getElementById('pushTbody');
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-slate-500">暂无推送记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无推送记录</td></tr>';
document.getElementById('pushPagination').classList.add('hidden');
return;
}
tbody.innerHTML = items.map(l => {
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-slate-400';
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-[var(--text-secondary)]';
const scDot = l.status==='success'?'bg-green-400':l.status==='failed'?'bg-red-400':l.status==='running'?'bg-yellow-400 animate-pulse':'bg-slate-500';
const modeBadge = {incremental:'增量',full:'全量',overwrite:'覆盖',checksum:'校验和',command:'命令'}[l.sync_mode]||l.sync_mode||'--';
const triggerBadge = {manual:'手动',schedule:'定时',retry:'重试',batch:'批量'}[l.trigger_type]||l.trigger_type||'--';
@@ -235,14 +237,14 @@
</span>
</td>
<td class="px-4 py-2.5 text-slate-300 text-xs whitespace-nowrap">${esc(l.server_name||_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">${esc(modeBadge)}</span></td>
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(triggerBadge)}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono max-w-[140px] truncate" title="${esc(l.source_path||'')}">${esc(l.source_path||'--')}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs font-mono max-w-[140px] truncate" title="${esc(l.target_path||'')}">${esc(l.target_path||'--')}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.operator||'--')}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${esc(modeBadge)}</span></td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(triggerBadge)}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono max-w-[140px] truncate" title="${esc(l.source_path||'')}">${esc(l.source_path||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs font-mono max-w-[140px] truncate" title="${esc(l.target_path||'')}">${esc(l.target_path||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.operator||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
</tr>`;
}).join('');
@@ -271,15 +273,15 @@
if (!r) return;
const logs = await r.json();
const tbody = document.getElementById('commandsTbody');
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">该会话无命令记录</td></tr>'; return; }
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">该会话无命令记录</td></tr>'; return; }
tbody.innerHTML = logs.map(l => {
const cmdShort = (l.command || '').length > 120 ? l.command.substring(0, 120) + '...' : l.command;
return `<tr class="hover:bg-slate-800/30 transition">
<td class="px-4 py-2.5 font-mono text-xs text-slate-200 max-w-xs truncate" title="${esc(l.command||'')}">${esc(cmdShort)}</td>
<td class="px-4 py-2.5 text-slate-400 text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5 text-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5 text-brand-light text-xs font-mono">${esc((l.session_id||'').substring(0,8)||'--')}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs">${esc(l.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-slate-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs">${esc(l.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-[var(--text-muted)] text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
}
+57 -55
View File
@@ -1,99 +1,101 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 凭据管理</title><script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true,tab:'db'}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">凭据管理</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">凭据管理</h1></div>
<button onclick="showAddCred()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
</header>
<!-- Tabs -->
<div class="bg-slate-900 border-b border-slate-800 px-6 flex">
<div class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 flex">
<button onclick="switchTab('db')" id="tabDB" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">数据库凭据</button>
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">密码预设</button>
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH密钥预设</button>
<button onclick="switchTab('preset')" id="tabPreset" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">密码预设</button>
<button onclick="switchTab('sshkey')" id="tabSSHKey" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">SSH密钥预设</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<!-- DB Credentials -->
<div id="dbSection">
<div id="credsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="credsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
</div>
<!-- Password Presets -->
<div id="presetSection" class="hidden">
<div id="presetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="presetsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
</div>
<!-- SSH Key Presets -->
<div id="sshKeySection" class="hidden">
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="sshKeyPresetsList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
</div>
<!-- Add/Edit DB Credential Modal -->
<div id="credModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-md space-y-3">
<h2 id="credModalTitle" class="text-white font-semibold text-lg">新建数据库凭据</h2>
<input type="hidden" id="credEditId" value="">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="credName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="prod-mysql"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
<div><label class="block text-slate-400 text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">类型</label><select id="credType" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="mysql">MySQL</option><option value="postgresql">PostgreSQL</option><option value="mariadb">MariaDB</option><option value="mongodb">MongoDB</option><option value="redis">Redis</option></select></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">端口</label><input id="credPort" type="number" value="3306" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">主机</label><input id="credHost" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="root"></div>
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">用户名</label><input id="credUser" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="root"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="credPass" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
</div>
<div><label class="block text-slate-400 text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
<div class="flex gap-2 pt-2"><button onclick="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">数据库</label><input id="credDb" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="可选"></div>
<div class="flex gap-2 pt-2"><button onclick="saveCred()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideCredModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- Add/Edit Password Preset Modal -->
<div id="presetModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-md space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-md space-y-3">
<h2 id="presetModalTitle" class="text-white font-semibold text-lg">新建密码预设</h2>
<input type="hidden" id="presetEditId" value="">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
<div><label class="block text-slate-400 text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="presetName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="生产环境root密码"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="presetPass" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="编辑时留空则不修改"></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-3 text-xs text-[var(--text-muted)]">密码将使用 AES-256 加密后存储,不可逆向查看。推送时自动解密使用。</div>
<div class="flex gap-2 pt-2"><button onclick="savePreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hidePresetModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- Add/Edit SSH Key Preset Modal -->
<div id="sshKeyModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 id="sshKeyModalTitle" class="text-white font-semibold text-lg">新建SSH密钥预设</h2>
<input type="hidden" id="sshKeyEditId" value="">
<div><label class="block text-slate-400 text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
<div><label class="block text-slate-400 text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
<div><label class="block text-slate-400 text-xs mb-1">公钥内容 <span class="text-slate-600">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-500">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称</label><input id="sshKeyName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="生产环境RSA密钥"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">私钥内容</label><textarea id="sshKeyPrivate" rows="6" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(编辑时留空则不修改)"></textarea></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">公钥内容 <span class="text-[var(--text-dim)]">(可选)</span></label><textarea id="sshKeyPublic" rows="2" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="ssh-rsa AAAA... (可选)"></textarea></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-3 text-xs text-[var(--text-muted)]">私钥将使用 Fernet 加密后存储,不可逆向查看。添加服务器选择此预设时自动解密使用。</div>
<div class="flex gap-2 pt-2"><button onclick="saveSSHKeyPreset()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideSSHKeyModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- Reveal SSH Key Modal (re-auth required) -->
<div id="sshKeyRevealModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">查看密钥 — 身份验证</h2>
<div><label class="block text-slate-400 text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入当前密码以解密查看私钥"></div>
<div class="flex gap-2 pt-2"><button onclick="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">当前登录密码</label><input id="sshKeyRevealPwd" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="输入当前密码以解密查看私钥"></div>
<div class="flex gap-2 pt-2"><button onclick="doRevealSSHKey()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">验证并查看</button><button onclick="hideSSHKeyRevealModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- Revealed SSH Key Content Modal -->
<div id="sshKeyContentModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 id="sshKeyContentTitle" class="text-white font-semibold text-lg">密钥内容</h2>
<div>
<label class="block text-slate-400 text-xs mb-1">私钥</label>
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
<label class="block text-[var(--text-secondary)] text-xs mb-1">私钥</label>
<textarea id="sshKeyRevealedPrivate" rows="8" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" readonly></textarea>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">公钥</label>
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" readonly></textarea>
<label class="block text-[var(--text-secondary)] text-xs mb-1">公钥</label>
<textarea id="sshKeyRevealedPublic" rows="2" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" readonly></textarea>
</div>
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3 text-xs text-amber-400">⚠️ 私钥仅在本次查看中显示,关闭后需重新验证身份才能再次查看。</div>
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
<div class="flex gap-2 pt-2"><button onclick="hideSSHKeyContentModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
</div>
</div>
</main>
@@ -106,9 +108,9 @@
const DB_PORTS={mysql:3306,postgresql:5432,mariadb:3306,mongodb:27017,redis:6379};
function switchTab(t){
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-slate-400 hover:text-white')+' transition';
document.getElementById('tabDB').className='px-4 py-3 text-sm border-b-2 '+(t==='db'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
document.getElementById('tabPreset').className='px-4 py-3 text-sm border-b-2 '+(t==='preset'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
document.getElementById('tabSSHKey').className='px-4 py-3 text-sm border-b-2 '+(t==='sshkey'?'border-brand text-brand-light':'border-transparent text-[var(--text-secondary)] hover:text-white')+' transition';
document.getElementById('dbSection').classList.toggle('hidden',t!=='db');
document.getElementById('presetSection').classList.toggle('hidden',t!=='preset');
document.getElementById('sshKeySection').classList.toggle('hidden',t!=='sshkey');
@@ -121,21 +123,21 @@
try{
const r=await apiFetch(API+'/scripts/credentials');if(!r)return;
const creds=await r.json();
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
document.getElementById('credsList').innerHTML=creds.length?creds.map(c=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-lg">${DB_ICONS[c.db_type]||'🗄'}</span>
<div>
<span class="text-white font-medium">${esc(c.name)}</span>
<div class="text-slate-500 text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
<div class="text-[var(--text-muted)] text-xs mt-0.5">${esc(c.db_type)} · ${esc(c.host)}:${c.port} · ${esc(c.username)}${c.database?' / '+esc(c.database):''}</div>
</div>
</div>
<div class="flex gap-2">
<button onclick="editCred(${c.id})" class="text-slate-500 hover:text-white text-xs">编辑</button>
<button onclick="editCred(${c.id})" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
<button onclick="deleteCred(${c.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无数据库凭据</div>';
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无数据库凭据</div>';
}catch(e){document.getElementById('credsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
@@ -237,21 +239,21 @@
try{
const r=await apiFetch(API+'/presets/');if(!r)return;
const presets=await r.json();
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
document.getElementById('presetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-lg">🔑</span>
<div>
<span class="text-white font-medium">${esc(p.name)}</span>
<div class="text-slate-500 text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
<div class="text-[var(--text-muted)] text-xs mt-0.5">加密存储 · 创建于 ${fmtTime(p.created_at)}</div>
</div>
</div>
<div class="flex gap-2">
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-slate-500 hover:text-white text-xs">编辑</button>
<button onclick="editPreset(${p.id},this.dataset.name)" data-name="${esc(p.name)}" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
<button onclick="deletePreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无密码预设</div>';
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无密码预设</div>';
}catch(e){document.getElementById('presetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
@@ -287,22 +289,22 @@
try{
const r=await apiFetch(API+'/ssh-key-presets/');if(!r)return;
const presets=await r.json();
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
document.getElementById('sshKeyPresetsList').innerHTML=presets.length?presets.map(p=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-lg">🔐</span>
<div>
<span class="text-white font-medium">${esc(p.name)}</span>
<div class="text-slate-500 text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
<div class="text-[var(--text-muted)] text-xs mt-0.5">${p.public_key?esc(p.public_key.substring(0,40))+'...':'无公钥'} · 创建于 ${fmtTime(p.created_at)}</div>
</div>
</div>
<div class="flex gap-2">
<button onclick="revealSSHKey(${p.id})" class="text-brand-light hover:text-white text-xs" title="查看密钥内容(需验证身份)">查看</button>
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-slate-500 hover:text-white text-xs">编辑</button>
<button onclick="editSSHKeyPreset(${p.id},this.dataset.name,this.dataset.pubkey)" data-name="${esc(p.name)}" data-pubkey="${esc(p.public_key||'')}" class="text-[var(--text-muted)] hover:text-white text-xs">编辑</button>
<button onclick="deleteSSHKeyPreset(${p.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>
</div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无SSH密钥预设</div>';
</div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无SSH密钥预设</div>';
}catch(e){document.getElementById('sshKeyPresetsList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
+60 -60
View File
@@ -1,18 +1,18 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between flex-wrap gap-2">
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-4">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<h1 class="text-white font-semibold">文件管理</h1>
</div>
<div class="flex items-center gap-2 flex-wrap">
<select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange="onServerChange()">
<select id="serverSelect" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange="onServerChange()">
<option value="">-- 选择服务器 --</option>
</select>
<button id="refreshServersBtn" class="px-2 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="刷新服务器列表" onclick="loadServers()">🔄</button>
<input id="dirPath" type="text" value="/www/wwwroot" placeholder="/www/wwwroot" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<input id="dirPath" type="text" value="/www/wwwroot" placeholder="/www/wwwroot" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button id="browseBtn" onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
<button onclick="doNewFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建文件">📄+</button>
@@ -22,36 +22,36 @@
</div>
</header>
<!-- Upload Progress Bar -->
<div id="uploadProgress" class="hidden bg-slate-900/80 border-b border-slate-800 px-6 py-2">
<div id="uploadProgress" class="hidden bg-[var(--bg-card)]/80 border-b border-[var(--border)] px-6 py-2">
<div class="flex items-center gap-3 text-sm">
<span id="uploadFileName" class="text-slate-300 truncate flex-1"></span>
<span id="uploadPercent" class="text-brand-light tabular-nums w-12 text-right"></span>
</div>
<div class="mt-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div class="mt-1 h-1.5 bg-[var(--bg-surface)] rounded-full overflow-hidden">
<div id="uploadBar" class="h-full bg-brand rounded-full transition-all" style="width:0%"></div>
</div>
</div>
<!-- Breadcrumb + Search -->
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-2 hidden">
<span class="text-slate-500 text-sm shrink-0">路径:</span>
<div id="breadcrumb" class="bg-[var(--bg-card)]/50 border-b border-[var(--border)] px-6 py-2 flex items-center gap-2 hidden">
<span class="text-[var(--text-muted)] text-sm shrink-0">路径:</span>
<div id="breadcrumbPath" class="flex items-center gap-1 flex-1 overflow-x-auto"></div>
<input id="fileSearch" placeholder="🔍 搜索..." oninput="filterFiles(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand">
<select onchange="setSort(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
<input id="fileSearch" placeholder="🔍 搜索..." oninput="filterFiles(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand">
<select onchange="setSort(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-white text-xs focus:outline-none">
<option value="name">名称↑</option><option value="size">大小</option><option value="time">时间</option>
</select>
<select onchange="filterByExt(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
<select onchange="filterByExt(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-white text-xs focus:outline-none">
<option value="">全部</option><option value="conf">.conf</option><option value="log">.log</option><option value="sh">.sh</option><option value="py">.py</option><option value="json">.json</option><option value="yml">.yml</option><option value="txt">.txt</option>
</select>
</div>
<!-- Batch Action Bar -->
<div id="batchBar" class="hidden bg-slate-800 border-b border-slate-700 px-6 py-2 flex items-center gap-3">
<div id="batchBar" class="hidden bg-[var(--bg-surface)] border-b border-[var(--border-input)] px-6 py-2 flex items-center gap-3">
<span id="batchCount" class="text-slate-300 text-sm"></span>
<button onclick="batchDelete()" class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition">🗑 批量删除</button>
<button onclick="clearSelection()" class="px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white text-xs rounded-lg transition">取消选择</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-4 py-12 text-center text-slate-500">
<div id="fileList" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div class="px-4 py-12 text-center text-[var(--text-muted)]">
<div class="text-4xl mb-3">📂</div>
<p>选择服务器并输入目录路径开始浏览</p>
</div>
@@ -62,24 +62,24 @@
<!-- Floating Editor Panel (draggable + resizable + file tree) -->
<div id="editorPanel" class="hidden fixed z-40 flex flex-col" style="right:24px;bottom:60px;width:75vw;height:65vh;min-width:500px;min-height:300px;border-radius:12px;overflow:hidden;box-shadow:0 25px 50px -12px rgba(0,0,0,0.5);border:1px solid rgb(51 65 85);background:rgb(15 23 42)">
<!-- Drag handle + tabs + buttons -->
<div id="editorHeader" class="flex items-center bg-slate-900 border-b border-slate-800 shrink-0 select-none">
<div id="editorHeader" class="flex items-center bg-[var(--bg-card)] border-b border-[var(--border)] shrink-0 select-none">
<!-- Drag grip icon -->
<div class="px-1.5 py-2 text-slate-600 cursor-grab active:cursor-grabbing shrink-0" title="拖拽移动"></div>
<div class="px-1.5 py-2 text-[var(--text-dim)] cursor-grab active:cursor-grabbing shrink-0" title="拖拽移动"></div>
<!-- File tree toggle -->
<button onclick="toggleEditorTree()" id="treeToggleBtn" class="text-slate-500 hover:text-white px-2.5 py-2 text-sm hover:bg-slate-700 shrink-0" title="文件树">🌳</button>
<button onclick="toggleEditorTree()" id="treeToggleBtn" class="text-[var(--text-muted)] hover:text-white px-2.5 py-2 text-sm hover:bg-slate-700 shrink-0" title="文件树">🌳</button>
<!-- Tabs (clickable, not draggable) -->
<div id="editorTabBar" class="flex flex-1 overflow-x-auto min-w-0"></div>
<!-- Right buttons -->
<div class="flex items-center shrink-0 relative">
<!-- Settings menu -->
<button onclick="toggleEditorMenu(event)" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="设置">⚙️</button>
<button onclick="toggleEditorSize()" id="sizeBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最大化/还原"></button>
<button onclick="toggleEditorFullscreen()" id="fsBtn" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="全屏"></button>
<button onclick="minimizeEditor()" class="text-slate-500 hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最小化"></button>
<button onclick="closeAllTabs()" class="text-slate-500 hover:text-red-400 px-2 py-2 text-sm hover:bg-red-900/30" title="关闭全部"></button>
<button onclick="toggleEditorMenu(event)" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="设置">⚙️</button>
<button onclick="toggleEditorSize()" id="sizeBtn" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最大化/还原"></button>
<button onclick="toggleEditorFullscreen()" id="fsBtn" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="全屏"></button>
<button onclick="minimizeEditor()" class="text-[var(--text-muted)] hover:text-white px-2 py-2 text-sm hover:bg-slate-700" title="最小化"></button>
<button onclick="closeAllTabs()" class="text-[var(--text-muted)] hover:text-red-400 px-2 py-2 text-sm hover:bg-red-900/30" title="关闭全部"></button>
<!-- Settings dropdown menu -->
<div id="editorMenu" class="hidden absolute right-0 top-full mt-1 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-2 min-w-[200px] z-50">
<div class="px-4 py-2 text-xs text-slate-500 font-medium border-b border-slate-700 mb-1">编辑器设置</div>
<div id="editorMenu" class="hidden absolute right-0 top-full mt-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg shadow-xl py-2 min-w-[200px] z-50">
<div class="px-4 py-2 text-xs text-[var(--text-muted)] font-medium border-b border-[var(--border-input)] mb-1">编辑器设置</div>
<button onclick="toggleEditorTheme()" class="w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 flex items-center gap-2">
<span id="themeIcon">☀️</span> <span id="themeLabel">亮色主题</span>
</button>
@@ -89,36 +89,36 @@
<!-- Body: file tree + editor -->
<div class="flex flex-1 min-h-0">
<!-- File tree sidebar -->
<div id="editorTree" class="w-52 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0 overflow-hidden">
<div class="px-3 py-2 border-b border-slate-800 text-xs text-slate-500 font-medium">📁 文件列表</div>
<div id="editorTree" class="w-52 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0 overflow-hidden">
<div class="px-3 py-2 border-b border-[var(--border)] text-xs text-[var(--text-muted)] font-medium">📁 文件列表</div>
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
</div>
<!-- Monaco container -->
<div id="editorMonacoWrap" class="flex-1 relative min-w-0"></div>
</div>
<!-- Status bar -->
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-800 border-t border-slate-700 text-xs text-slate-500 shrink-0">
<div class="flex items-center gap-4 px-4 py-1.5 bg-[var(--bg-surface)] border-t border-[var(--border-input)] text-xs text-[var(--text-muted)] shrink-0">
<span id="editorPos">Ln 1, Col 1</span>
<span id="editorLang">plaintext</span>
<span>UTF-8</span>
<span id="editorMod" class="text-amber-400 hidden">● 未保存</span>
<div class="flex-1"></div>
<span id="editorFileName" class="text-slate-400 truncate max-w-xs"></span>
<span id="editorFileName" class="text-[var(--text-secondary)] truncate max-w-xs"></span>
</div>
<!-- Resize handle (bottom-right corner) -->
<div id="editorResizeHandle" class="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize z-10" style="background:linear-gradient(135deg,transparent 50%,rgb(100 116 139) 50%)"></div>
</div>
<!-- Minimized editor bar -->
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-slate-900 border-t border-slate-700 px-4 py-1.5 flex items-center gap-2">
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-[var(--bg-card)] border-t border-[var(--border-input)] px-4 py-1.5 flex items-center gap-2">
<div id="editorMinTabs" class="flex-1 flex items-center gap-1 overflow-x-auto"></div>
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-900/30 rounded"></button>
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:text-red-400 hover:bg-red-900/30 rounded"></button>
</div>
<!-- Modal (for non-editor operations: rename/delete/mkdir/chmod/compress) -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div id="modalDialog" class="bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700">
<div id="modalDialog" class="bg-[var(--bg-surface)] rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-[var(--border-input)]">
<h3 id="modalTitle" class="text-white font-medium mb-4 text-lg"></h3>
<p id="modalText" class="text-slate-300 text-sm mb-4 hidden whitespace-pre-line"></p>
<input id="modalInput" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 focus:outline-none focus:ring-2 focus:ring-brand text-sm" autocomplete="off">
@@ -130,17 +130,17 @@
</div>
<!-- F-1: Right-click context menu -->
<div id="contextMenu" class="hidden fixed z-50 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]">
<div id="contextMenu" class="hidden fixed z-50 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg shadow-xl py-1 min-w-[180px]">
<button data-ctx="preview" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">👁 预览/编辑</button>
<button data-ctx="download" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">⬇ 下载</button>
<button data-ctx="rename" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">✏ 重命名</button>
<button data-ctx="chmod" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🔐 修改权限</button>
<button data-ctx="copyPath" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📋 复制路径</button>
<button data-ctx="terminal" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🖥 在此打开终端</button>
<div class="border-t border-slate-700 my-1"></div>
<div class="border-t border-[var(--border-input)] my-1"></div>
<button data-ctx="compress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📦 压缩为 tar.gz</button>
<button data-ctx="decompress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📂 解压到当前目录</button>
<div class="border-t border-slate-700 my-1"></div>
<div class="border-t border-[var(--border-input)] my-1"></div>
<button data-ctx="delete" class="ctx-item w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-900/30 transition">🗑 删除</button>
</div>
@@ -187,7 +187,7 @@
pdf:'📕',doc:'📘',docx:'📘',xls:'📗',xlsx:'📗',
mp3:'🎵',mp4:'🎬',avi:'🎬',mkv:'🎬',
env:'🔒',key:'🔒',pem:'🔒',crt:'🔒',cert:'🔒'};
return{icon:m[ext]||'📄',color:'text-slate-400'};
return{icon:m[ext]||'📄',color:'text-[var(--text-secondary)]'};
}
// ── P2-2/P2-3: Event delegation ──
@@ -349,7 +349,7 @@
function onServerChange(){
document.getElementById('dirPath').value='/www/wwwroot';
document.getElementById('breadcrumb').classList.add('hidden');
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-4xl mb-3">📂</div><p>点击「浏览」查看目录</p></div>';
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-[var(--text-muted)]"><div class="text-4xl mb-3">📂</div><p>点击「浏览」查看目录</p></div>';
_fileCache={};
}
@@ -403,7 +403,7 @@
if(!sid){toast('warning','请先选择服务器');return}
_currentServerId=parseInt(sid);_selectedPaths.clear();updateBatchBar();
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-2xl mb-2 animate-spin"></div>加载中...</div>';
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-[var(--text-muted)]"><div class="text-2xl mb-2 animate-spin"></div>加载中...</div>';
try{
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
if(!r){fileListEl.innerHTML='<div class="px-4 py-12 text-center text-red-400">认证失败</div>';return}
@@ -426,13 +426,13 @@
// Parent directory
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" data-action="browse" data-path="${esc(parentPath)}">
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-[var(--border)] hover:bg-[var(--bg-surface)]/50 cursor-pointer" data-action="browse" data-path="${esc(parentPath)}">
<span class="text-[var(--text-muted)]">📂</span><span class="text-[var(--text-secondary)] text-sm">..</span><span class="text-[var(--text-dim)] text-xs">返回上级目录</span>
</div>`;
}
// P2-10: Select all checkbox
if(entries.length){
html+=`<div class="flex items-center gap-3 px-4 py-1.5 border-b border-slate-800 bg-slate-800/30 text-xs text-slate-500">
html+=`<div class="flex items-center gap-3 px-4 py-1.5 border-b border-[var(--border)] bg-[var(--bg-surface)]/30 text-xs text-[var(--text-muted)]">
<input type="checkbox" data-action="select-all" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand cursor-pointer">
<span class="flex-1">全选</span><span class="w-16 text-right">大小</span><span class="w-20 hidden sm:block">权限</span><span class="w-16 hidden md:block">所有者</span><span class="w-20"></span>
</div>`;
@@ -444,24 +444,24 @@
const fi=fileIcon(e.name,e.is_dir,e.is_link);
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
const linkHint=e.is_link?`title="→ ${esc(e.link_target||'')}"`:'';
return `<div class="file-row group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50" data-name="${esc(e.name.toLowerCase())}">
return `<div class="file-row group flex items-center gap-3 px-4 py-2.5 border-b border-[var(--border)] hover:bg-[var(--bg-surface)]/50" data-name="${esc(e.name.toLowerCase())}">
<input type="checkbox" data-action="select" data-path="${esc(fullPath)}" class="file-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand shrink-0">
<span class="${fi.color} text-sm shrink-0" ${linkHint}>${fi.icon}</span>
<span class="text-white text-sm flex-1 truncate ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`data-action="browse" data-path="${esc(fullPath)}"`:''} ${linkHint}>${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':formatSize(e.size)}</span>
<span class="text-slate-600 text-xs shrink-0 w-20 hidden sm:block">${esc(e.perms)}</span>
<span class="text-slate-600 text-xs shrink-0 w-16 hidden md:block truncate">${esc(e.owner||'')}</span>
<span class="text-[var(--text-muted)] text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':formatSize(e.size)}</span>
<span class="text-[var(--text-dim)] text-xs shrink-0 w-20 hidden sm:block">${esc(e.perms)}</span>
<span class="text-[var(--text-dim)] text-xs shrink-0 w-16 hidden md:block truncate">${esc(e.owner||'')}</span>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 w-20 justify-end">
${!e.is_dir?`<button data-action="preview" data-path="${esc(fullPath)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="预览">👁</button>`:''}
${!e.is_dir?`<button data-action="download" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="下载"></button>`:''}
<button data-action="rename" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="重命名"></button>
${!e.is_dir?`<button data-action="preview" data-path="${esc(fullPath)}" class="px-1.5 py-1 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded" title="预览">👁</button>`:''}
${!e.is_dir?`<button data-action="download" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded" title="下载"></button>`:''}
<button data-action="rename" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-[var(--text-secondary)] hover:text-white hover:bg-slate-700 rounded" title="重命名"></button>
<button data-action="delete" data-path="${esc(fullPath)}" data-is-dir="${e.is_dir}" class="px-1.5 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
</div>
</div>`;
}).join('');
// P3-3: Empty state
if(!entries.length){
html+=`<div class="px-4 py-12 text-center"><div class="text-4xl mb-3">📂</div><p class="text-slate-400 mb-3">空目录</p><button onclick="pickUploadFile()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">上传文件</button></div>`;
html+=`<div class="px-4 py-12 text-center"><div class="text-4xl mb-3">📂</div><p class="text-[var(--text-secondary)] mb-3">空目录</p><button onclick="pickUploadFile()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">上传文件</button></div>`;
}
fileListEl.innerHTML=html;
document.getElementById('fileSearch').value='';
@@ -525,7 +525,7 @@
let accumulated='';
segments.forEach((seg,i)=>{
accumulated+='/'+seg;const p=accumulated;
const sep=i===0?'':'<span class="text-slate-600">/</span>';
const sep=i===0?'':'<span class="text-[var(--text-dim)]">/</span>';
if(i<segments.length-1){
html+=`${sep}<span class="cursor-pointer text-brand-light hover:underline" data-action="browse" data-path="${esc(p)}">${esc(seg)}</span>`;
}else{
@@ -833,17 +833,17 @@
function renderFileTree(){
const list=document.getElementById('editorTreeList');
if(!list)return;
let html=`<div class="px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
let html=`<div class="px-3 py-1.5 text-xs text-[var(--text-dim)] cursor-pointer hover:bg-[var(--bg-surface)]" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
const sorted=sortEntries(_allEntries);
sorted.forEach(e=>{
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(e.is_dir){
html+=`<div class="px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
html+=`<div class="px-3 py-1 text-xs text-[var(--text-secondary)] cursor-pointer hover:bg-[var(--bg-surface)] truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
}else{
const fi=fileIcon(e.name,false,e.is_link);
const inTab=_openTabs.findIndex(t=>t.path===fp);
const active=inTab===_activeTab;
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-[var(--bg-surface)] truncate ${active?'bg-[var(--bg-surface)] text-white':'text-[var(--text-secondary)]'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
${fi.icon} ${esc(e.name)}${inTab>=0?'<span class="text-brand-light ml-1"></span>':''}
</div>`;
}
@@ -929,9 +929,9 @@
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
const mod=t.modified?'● ':'';
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-slate-800 shrink-0 ${active?'bg-slate-800 text-white':'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}" onclick="switchTab(${i})">
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-[var(--border)] shrink-0 ${active?'bg-[var(--bg-surface)] text-white':'text-[var(--text-secondary)] hover:text-slate-200 hover:bg-[var(--bg-surface)]/50'}" onclick="switchTab(${i})">
<span class="${t.modified?'text-amber-400':''}">${mod}${esc(t.name)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-[var(--text-dim)] hover:text-red-400">×</button>
</div>`;
}).join('');
}
@@ -940,9 +940,9 @@
const bar=document.getElementById('editorMinTabs');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-slate-400 hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-[var(--text-secondary)] hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
<span class="${t.modified?'text-amber-400':''}">${t.modified?'● ':''}${esc(t.name)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-[var(--text-dim)] hover:text-red-400">×</button>
</div>`;
}).join('');
}
+37 -36
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus — 仪表盘</title>
<script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script>
<style type="text/tailwindcss">
@theme {
@@ -14,21 +15,21 @@
}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<button @click="sidebarOpen = !sidebarOpen" class="text-slate-400 hover:text-white transition">
<button @click="sidebarOpen = !sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<h1 class="text-white font-semibold">仪表盘</h1>
</div>
<div class="flex items-center gap-3">
<button id="refreshBtn" onclick="refreshAll()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition flex items-center gap-1.5">刷新</button>
<span id="headerUser" class="text-slate-400 text-sm">...</span>
<button id="refreshBtn" onclick="refreshAll()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition flex items-center gap-1.5">刷新</button>
<span id="headerUser" class="text-[var(--text-secondary)] text-sm">...</span>
</div>
</header>
@@ -36,20 +37,20 @@
<!-- Stats Cards -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="text-slate-400 text-sm mb-1">服务器总数</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<div class="text-[var(--text-secondary)] text-sm mb-1">服务器总数</div>
<div class="text-3xl font-bold text-white" id="statTotal">--</div>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="text-slate-400 text-sm mb-1">在线</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<div class="text-[var(--text-secondary)] text-sm mb-1">在线</div>
<div class="text-3xl font-bold text-green-400" id="statOnline">--</div>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="text-slate-400 text-sm mb-1">离线</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<div class="text-[var(--text-secondary)] text-sm mb-1">离线</div>
<div class="text-3xl font-bold text-red-400" id="statOffline">--</div>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="text-slate-400 text-sm mb-1">告警中</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<div class="text-[var(--text-secondary)] text-sm mb-1">告警中</div>
<div class="text-3xl font-bold text-yellow-400" id="statAlerts">--</div>
</div>
</div>
@@ -57,20 +58,20 @@
<!-- Run status + Category Pie -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-2">
<div id="statusPanel" class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div id="statusPanel" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<h2 class="text-white font-semibold text-sm mb-2">运行状态</h2>
<div id="statusSummary" class="flex items-center gap-2 text-slate-500 text-sm">
<div id="statusSummary" class="flex items-center gap-2 text-[var(--text-muted)] text-sm">
<span id="statusDot" class="inline-block w-2 h-2 rounded-full bg-slate-600 shrink-0"></span>
<span id="statusText">加载中...</span>
</div>
<p class="text-slate-600 text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
<p class="text-[var(--text-dim)] text-xs mt-2">告警详情通过 Telegram 推送;此处仅展示汇总状态。</p>
</div>
</div>
<!-- Category Breakdown -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<h2 class="text-white font-semibold mb-4 text-sm">分类分布</h2>
<div id="categoryBars" class="space-y-3">
<div class="text-slate-500 text-sm">加载中...</div>
<div class="text-[var(--text-muted)] text-sm">加载中...</div>
</div>
</div>
</div>
@@ -78,20 +79,20 @@
<!-- Recent Sync + Recent Audit -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Recent Sync Logs -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white font-semibold text-sm">最近同步</h2>
<a href="/app/push.html" class="text-brand-light text-xs hover:underline">查看全部</a>
</div>
<div id="recentSyncs" class="text-slate-400 text-sm">加载中...</div>
<div id="recentSyncs" class="text-[var(--text-secondary)] text-sm">加载中...</div>
</div>
<!-- Recent Audit -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-white font-semibold text-sm">最近活动</h2>
<a href="/app/audit.html" class="text-brand-light text-xs hover:underline">查看全部</a>
</div>
<div id="recentActivity" class="text-slate-400 text-sm">加载中...</div>
<div id="recentActivity" class="text-[var(--text-secondary)] text-sm">加载中...</div>
</div>
</div>
</main>
@@ -127,14 +128,14 @@
const text = document.getElementById('statusText');
if (!panel || !dot || !text) return;
if (n > 0) {
panel.className = 'bg-slate-900 rounded-xl border border-yellow-700/40 p-4';
panel.className = 'bg-[var(--bg-card)] rounded-xl border border-yellow-700/40 p-4';
dot.className = 'inline-block w-2 h-2 rounded-full bg-yellow-400 shrink-0';
text.className = 'text-yellow-200/90 text-sm';
text.textContent = `当前有 ${n} 台服务器处于告警状态(详见 Telegram)`;
} else {
panel.className = 'bg-slate-900 rounded-xl border border-slate-800 p-4';
panel.className = 'bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4';
dot.className = 'inline-block w-2 h-2 rounded-full bg-green-400 shrink-0';
text.className = 'text-slate-400 text-sm';
text.className = 'text-[var(--text-secondary)] text-sm';
text.textContent = '所有服务器运行正常';
}
}
@@ -142,7 +143,7 @@
function renderCategories(categories) {
const entries = Object.entries(categories);
if (!entries.length) {
document.getElementById('categoryBars').innerHTML = '<div class="text-slate-500 text-sm">暂无分类数据</div>';
document.getElementById('categoryBars').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无分类数据</div>';
return;
}
const total = entries.reduce((s,[,v]) => s+v, 0);
@@ -150,8 +151,8 @@
document.getElementById('categoryBars').innerHTML = entries.map(([cat,count],i) => {
const pct = Math.round(count/total*100);
return `<div>
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-slate-500">${count}台 (${pct}%)</span></div>
<div class="w-full bg-slate-800 rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
<div class="flex justify-between text-xs mb-1"><span class="text-slate-300">${esc(cat||'未分类')}</span><span class="text-[var(--text-muted)]">${count}台 (${pct}%)</span></div>
<div class="w-full bg-[var(--bg-surface)] rounded-full h-2"><div class="${colors[i%colors.length]} h-2 rounded-full transition-all" style="width:${pct}%"></div></div>
</div>`;
}).join('');
}
@@ -163,15 +164,15 @@
if (!res) return;
const logs = await res.json();
if (!logs.length) {
document.getElementById('recentSyncs').innerHTML = '<div class="text-slate-500 text-sm">暂无同步记录</div>';
document.getElementById('recentSyncs').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无同步记录</div>';
return;
}
document.getElementById('recentSyncs').innerHTML = logs.map(l => {
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
const icon = l.status==='success'?'✅':l.status==='failed'?'❌':'⏳';
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
return `<div class="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-slate-300 text-xs">${esc(l.server_name||'#'+l.server_id)}</span><span class="${sc} text-xs">${esc(l.status)}</span></div>
<span class="text-slate-600 text-xs">${fmtTime(l.started_at)}</span>
<span class="text-[var(--text-dim)] text-xs">${fmtTime(l.started_at)}</span>
</div>`;
}).join('');
} catch(e) {
@@ -187,14 +188,14 @@
const data = await res.json();
const audits = data.items || data;
if (!audits.length) {
document.getElementById('recentActivity').innerHTML = '<div class="text-slate-500 text-sm">暂无活动</div>';
document.getElementById('recentActivity').innerHTML = '<div class="text-[var(--text-muted)] text-sm">暂无活动</div>';
return;
}
document.getElementById('recentActivity').innerHTML = audits.map(a => {
const icon = a.action.includes('delete')?'🔴':a.action.includes('create')?'🔵':a.action.includes('login')?'🟢':'⚪';
return `<div class="flex items-center justify-between py-2 border-b border-slate-800 last:border-0">
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-slate-500 text-xs">${esc(a.action)}</span></div>
<span class="text-slate-600 text-xs">${fmtTime(a.created_at)}</span>
return `<div class="flex items-center justify-between py-2 border-b border-[var(--border)] last:border-0">
<div class="flex items-center gap-2"><span class="text-xs">${icon}</span><span class="text-brand-light text-xs">${esc(a.admin_username||'system')}</span><span class="text-[var(--text-muted)] text-xs">${esc(a.action)}</span></div>
<span class="text-[var(--text-dim)] text-xs">${fmtTime(a.created_at)}</span>
</div>`;
}).join('');
} catch(e) {
+38 -37
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus 6.0 安装向导</title>
<script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script>
<style type="text/tailwindcss">
@theme {
@@ -29,7 +30,7 @@
.copy-btn.copied { background: #22c55e; color: #fff; border-color: #22c55e; }
</style>
</head>
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
<div class="installer-card w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
@@ -44,7 +45,7 @@
<template x-for="i in 5" :key="i">
<div class="flex items-center">
<div class="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-slate-400'"
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-[var(--text-secondary)]'"
x-text="step > i ? '✓' : i"></div>
<div x-show="i < 5" class="w-8 h-0.5 mx-0.5 transition-all"
:class="step > i ? 'bg-green-500' : 'bg-slate-200'"></div>
@@ -64,7 +65,7 @@
<div class="text-center py-8">
<div class="text-5xl mb-4">⚠️</div>
<h2 class="text-xl font-bold text-red-600 mb-2">系统已安装</h2>
<p class="text-slate-500 mb-4">检测到 .env 配置文件,系统已完成安装。</p>
<p class="text-[var(--text-muted)] mb-4">检测到 .env 配置文件,系统已完成安装。</p>
<a href="/app/login.html" class="inline-flex items-center gap-2 bg-blue-500 hover:bg-blue-600 text-white px-5 py-2.5 rounded-lg font-medium transition">前往登录 →</a>
</div>
</template>
@@ -73,7 +74,7 @@
<template x-if="step === 1 && !installed">
<div class="text-center py-4">
<h2 class="text-xl font-bold text-slate-800 mb-4">欢迎使用 Nexus 6.0</h2>
<p class="text-slate-500 mb-6 leading-relaxed">
<p class="text-[var(--text-muted)] mb-6 leading-relaxed">
本向导将引导您完成系统安装配置。<br>
安装完成后将自动生成:<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">.env</code> (Python后端) +
<code class="bg-slate-100 px-1.5 py-0.5 rounded text-sm">config.json</code> (共享配置) +
@@ -82,7 +83,7 @@
</p>
<div class="bg-slate-50 rounded-lg p-5 text-left mb-6">
<h3 class="font-semibold text-slate-700 mb-3">📋 安装前准备</h3>
<ul class="text-slate-600 text-sm ml-5 list-disc space-y-1">
<ul class="text-[var(--text-dim)] text-sm ml-5 list-disc space-y-1">
<li>Python 3.12+ (FastAPI + uvicorn)</li>
<li>MySQL 8.0+ (需提前创建数据库和用户)</li>
<li>Redis 6+ (心跳缓冲和实时数据)</li>
@@ -100,7 +101,7 @@
<div>
<h2 class="text-lg font-bold text-slate-800 mb-4">🔍 环境检测</h2>
<div x-show="envLoading" class="text-center py-8 text-slate-400">
<div x-show="envLoading" class="text-center py-8 text-[var(--text-secondary)]">
<div class="animate-spin inline-block w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full mb-2"></div>
<p>正在检测环境...</p>
</div>
@@ -110,7 +111,7 @@
<div class="flex items-center justify-between px-4 py-3 border-b border-slate-100 last:border-0">
<div>
<span class="font-medium text-slate-700" x-text="c.name"></span>
<span class="text-slate-400 text-xs ml-2" x-text="'需要: ' + c.required"></span>
<span class="text-[var(--text-secondary)] text-xs ml-2" x-text="'需要: ' + c.required"></span>
</div>
<span class="font-semibold text-sm" :class="c.pass ? 'text-green-500' : 'text-red-500'"
x-text="c.pass ? '✓ 通过' : '✗ ' + c.current"></span>
@@ -208,13 +209,13 @@
<h3 class="font-bold text-slate-700 mb-3">🌐 网站地址 + API 服务 + 时区</h3>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">网站地址</label>
<p class="text-xs text-slate-400 mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
<p class="text-xs text-[var(--text-secondary)] mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
<input x-model="form.site_url" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div class="grid grid-cols-2 gap-4 mt-3">
<div>
<label class="block font-semibold text-sm text-slate-700 mb-1">Python API 端口</label>
<p class="text-xs text-slate-400 mb-1">uvicorn 监听端口</p>
<p class="text-xs text-[var(--text-secondary)] mb-1">uvicorn 监听端口</p>
<input x-model="form.api_port" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div>
@@ -268,7 +269,7 @@
<h3 class="font-bold text-slate-700 mb-3">系统品牌</h3>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">系统名称</label>
<p class="text-xs text-slate-400 mb-1">显示在浏览器标题和后台标题栏</p>
<p class="text-xs text-[var(--text-secondary)] mb-1">显示在浏览器标题和后台标题栏</p>
<input x-model="adminForm.system_name" class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
<div class="mt-3">
@@ -286,7 +287,7 @@
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员密码</label>
<p class="text-xs text-slate-400 mb-1">至少6位字符</p>
<p class="text-xs text-[var(--text-secondary)] mb-1">至少6位字符</p>
<input x-model="adminForm.admin_password" type="password" required minlength="6" placeholder="请输入安全密码"
class="w-full px-3 py-2 border-2 border-slate-200 rounded-lg text-sm focus:border-blue-500 focus:outline-none transition">
</div>
@@ -313,8 +314,8 @@
<div class="text-center">
<div class="w-16 h-16 rounded-full bg-green-100 text-green-500 text-3xl flex items-center justify-center mx-auto mb-4"></div>
<h2 class="text-xl font-bold text-slate-800 mb-2">安装完成!</h2>
<p class="text-slate-500 mb-1">Nexus 6.0 已成功安装。</p>
<p class="text-slate-400 text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
<p class="text-[var(--text-muted)] mb-1">Nexus 6.0 已成功安装。</p>
<p class="text-[var(--text-secondary)] text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
<!-- Guardian results -->
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
@@ -336,12 +337,12 @@
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">1</span>
Supervisor 进程守护
</div>
<div x-show="!guardianAllOk" class="text-xs text-slate-500 space-y-1">
<div x-show="!guardianAllOk" class="text-xs text-[var(--text-muted)] space-y-1">
<!-- BT Panel mode -->
<template x-if="btPanel">
<div>
<p>宝塔面板 → 软件商店 → Supervisor管理器 → 添加守护进程</p>
<p>或手动写入配置: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/www/server/panel/plugin/supervisor/profile/nexus.ini</code></p>
<p>或手动写入配置: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">/www/server/panel/plugin/supervisor/profile/nexus.ini</code></p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf')">复制配置</button><pre id="supervisorConf">[program:nexus]
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
directory=<span x-text="installDir"></span>
@@ -357,14 +358,14 @@
stdout_logfile=/www/wwwlogs/nexus_access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB</pre></div>
<p>重载: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">supervisorctl reread && supervisorctl update && supervisorctl start nexus</code></p>
<p>重载: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">supervisorctl reread && supervisorctl update && supervisorctl start nexus</code></p>
</div>
</template>
<!-- Standard mode -->
<template x-if="!btPanel">
<div>
<p>安装: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
<p>配置文件复制到: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
<p>安装: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
<p>配置文件复制到: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">/etc/supervisor/conf.d/nexus.conf</code></p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'supervisorConf2')">复制配置</button><pre id="supervisorConf2">[program:nexus]
command=<span x-text="installDir"></span>/venv/bin/uvicorn server.main:app --host 0.0.0.0 --port <span x-text="form.api_port"></span>
directory=<span x-text="installDir"></span>
@@ -380,7 +381,7 @@
stdout_logfile=/var/log/nexus/access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB</pre></div>
<p>启动: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
<p>启动: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo supervisorctl reread && sudo supervisorctl update && sudo supervisorctl start nexus</code></p>
</div>
</template>
</div>
@@ -393,11 +394,11 @@
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">2</span>
Python 虚拟环境 + 依赖
</div>
<div class="text-xs text-slate-500 space-y-1">
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
<div class="text-xs text-[var(--text-muted)] space-y-1">
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
</div>
</div>
@@ -407,11 +408,11 @@
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">3</span>
Nginx 反向代理 (API + WebSocket)
</div>
<div class="text-xs text-slate-500">
<div class="text-xs text-[var(--text-muted)]">
<!-- BT Panel mode -->
<template x-if="btPanel">
<div>
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">#PHP-INFO-END</code> 后面粘贴:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf')">复制配置</button><pre id="nginxConf"> # Nexus Python API + WebSocket
location ^~ /api/ {
proxy_pass http://127.0.0.1:<span x-text="form.api_port"></span>;
@@ -436,7 +437,7 @@
<!-- Standard mode -->
<template x-if="!btPanel">
<div>
<p class="mb-1">将以下配置写入 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">/etc/nginx/sites-available/nexus</code> 并创建符号链接到 sites-enabled</p>
<p class="mb-1">将以下配置写入 <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">/etc/nginx/sites-available/nexus</code> 并创建符号链接到 sites-enabled</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxConf2')">复制配置</button><pre id="nginxConf2">upstream nexus_api {
server 127.0.0.1:<span x-text="form.api_port"></span>;
keepalive 32;
@@ -465,7 +466,7 @@ server {
return 404;
}
}</pre></div>
<p>启用: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo ln -sf /etc/nginx/sites-available/nexus /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx</code></p>
<p>启用: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo ln -sf /etc/nginx/sites-available/nexus /etc/nginx/sites-enabled/ && sudo nginx -t && sudo systemctl reload nginx</code></p>
</div>
</template>
</div>
@@ -477,7 +478,7 @@ server {
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">4</span>
Nginx 伪静态
</div>
<div class="text-xs text-slate-500">
<div class="text-xs text-[var(--text-muted)]">
<p class="mb-1">宝塔 → 网站 → 伪静态 → 粘贴:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'nginxRewrite')">复制</button><pre id="nginxRewrite">location /app/ {
try_files $uri $uri/ /app/index.html;
@@ -494,13 +495,13 @@ location / {
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">5</span>
SSL证书 (强制HTTPS)
</div>
<div class="text-xs text-slate-500">
<div class="text-xs text-[var(--text-muted)]">
<template x-if="btPanel">
<div>宝塔 → 网站 → SSL → Let's Encrypt → 一键申请 → 开启强制HTTPS</div>
</template>
<template x-if="!btPanel">
<div>安装certbot: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y certbot python3-certbot-nginx</code><br>
申请证书: <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">sudo certbot --nginx -d YOUR_DOMAIN</code></div>
<div>安装certbot: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y certbot python3-certbot-nginx</code><br>
申请证书: <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">sudo certbot --nginx -d YOUR_DOMAIN</code></div>
</template>
</div>
</div>
@@ -511,12 +512,12 @@ location / {
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">6</span>
Shell 健康检查 (外部守护 + Telegram告警)
</div>
<div x-show="!guardianAllOk" class="text-xs text-slate-500">
<div x-show="!guardianAllOk" class="text-xs text-[var(--text-muted)]">
<p>配置 crontab:</p>
<div class="code-block"><button class="copy-btn" @click="copyCode($event, 'crontab')">复制</button><pre id="crontab">* * * * * <span x-text="installDir"></span>/deploy/health_monitor.sh</pre></div>
</div>
<div x-show="guardianAllOk" class="text-xs text-green-600">✓ 已自动配置 — health_monitor.sh INSTALL_DIR 已更新,Crontab 已添加</div>
<div class="text-xs text-slate-500 mt-2">
<div class="text-xs text-[var(--text-muted)] mt-2">
<b>3层守护机制:</b><br>
Layer 1: Supervisor — Python崩溃自动重启<br>
Layer 2: Python self_monitor — 每30s检查Redis/MySQL/WebSocket<br>
@@ -530,9 +531,9 @@ location / {
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold mr-2">7</span>
安全收尾
</div>
<div class="text-xs text-slate-500 space-y-1">
<p>✓ 安装向导已锁定 — 删除 <code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 文件可重新安装</p>
<p><code class="bg-slate-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
<div class="text-xs text-[var(--text-muted)] space-y-1">
<p>✓ 安装向导已锁定 — 删除 <code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">.env</code> 文件可重新安装</p>
<p><code class="bg-[var(--bg-surface)] text-slate-200 px-1.5 py-0.5 rounded">.env</code> 包含 SECRET_KEY/API_KEY — <b>安装后不可修改</b>(加密一致性)</p>
<p>✓ 防火墙限制端口 <span x-text="form.api_port"></span> (仅允许本机和子服务器IP)</p>
<p>✓ 修改管理员默认密码</p>
</div>
+36 -1
View File
@@ -63,13 +63,17 @@ function initLayout(activeNav) {
</div>
<div class="flex items-center justify-between">
<span id="sidebarUser" class="text-slate-400 text-sm">...</span>
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
<div class="flex items-center gap-2">
<button onclick="toggleTheme()" id="themeToggle" class="text-slate-500 hover:text-white text-sm transition" title="切换主题">🌙</button>
<button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs transition">退出</button>
</div>
</div>
</div>
`;
// Load user info
loadLayoutUser();
loadTheme();
// ── Mobile Responsive ──
// On mobile: collapse sidebar by default, add overlay + backdrop
@@ -138,6 +142,37 @@ async function loadLayoutUser() {
} catch(e) { console.error('Failed to load user info:', e); }
}
// ── Theme System ──
async function loadTheme() {
try {
const r = await apiFetch(API + '/settings/theme');
if (r && r.ok) {
const d = await r.json();
if (d.value === 'light' || d.value === 'dark') {
document.documentElement.dataset.theme = d.value;
}
}
} catch(e) {}
updateThemeIcon();
}
function updateThemeIcon() {
const el = document.getElementById('themeToggle');
if (el) el.textContent = document.documentElement.dataset.theme === 'light' ? '☀️' : '🌙';
}
function toggleTheme() {
const current = document.documentElement.dataset.theme || 'dark';
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.dataset.theme = next;
updateThemeIcon();
// Save to MySQL
apiFetch(API + '/settings/theme', {
method: 'PUT', headers: apiHeadersJSON(),
body: JSON.stringify({ value: next })
});
}
// ── Global Search ──
let _searchTimer = null;
+11 -10
View File
@@ -7,6 +7,7 @@
<!-- Tailwind CSS v4 CDN -->
<script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<style type="text/tailwindcss">
@theme {
@@ -17,7 +18,7 @@
}
</style>
</head>
<body class="bg-slate-950 min-h-screen flex items-center justify-center p-4">
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
@@ -29,11 +30,11 @@
</svg>
</div>
<h1 class="text-2xl font-bold text-white">Nexus</h1>
<p class="text-slate-400 text-sm mt-1">服务器运维管理平台</p>
<p class="text-[var(--text-secondary)] text-sm mt-1">服务器运维管理平台</p>
</div>
<!-- Login Card -->
<div id="loginCard" class="bg-slate-900 rounded-2xl border border-slate-800 p-8 shadow-xl">
<div id="loginCard" class="bg-[var(--bg-card)] rounded-2xl border border-[var(--border)] p-8 shadow-xl">
<!-- Step 1: Password -->
<div id="stepPassword">
@@ -43,7 +44,7 @@
<div>
<label class="block text-sm font-medium text-slate-300 mb-2">用户名</label>
<input type="text" id="username" name="username" required autocomplete="username"
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="请输入用户名">
</div>
@@ -51,10 +52,10 @@
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
<div class="relative">
<input type="password" id="password" name="password" required autocomplete="current-password"
class="w-full px-4 py-3 pr-12 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
class="w-full px-4 py-3 pr-12 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="••••••••">
<button type="button" id="togglePwd" tabindex="-1"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition p-1">
class="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-muted)] hover:text-slate-300 transition p-1">
<!-- Eye icon (show) -->
<svg id="eyeShow" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -78,12 +79,12 @@
<!-- Step 2: TOTP (hidden by default) -->
<div id="stepTotp" class="hidden">
<h2 class="text-lg font-semibold text-white mb-2">两步验证</h2>
<p class="text-slate-400 text-sm mb-6">请输入验证器应用中的 6 位数字</p>
<p class="text-[var(--text-secondary)] text-sm mb-6">请输入验证器应用中的 6 位数字</p>
<form id="totpForm" class="space-y-5">
<div>
<input type="text" id="totpCode" name="totp_code" required maxlength="6" inputmode="numeric" pattern="[0-9]{6}" autocomplete="one-time-code"
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-center text-2xl tracking-[0.5em] placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="000000">
</div>
@@ -93,7 +94,7 @@
</button>
<button type="button" id="backBtn"
class="w-full py-2 text-slate-400 hover:text-white text-sm transition">
class="w-full py-2 text-[var(--text-secondary)] hover:text-white text-sm transition">
← 返回
</button>
</form>
@@ -107,7 +108,7 @@
</div>
<!-- Footer -->
<p class="text-center text-slate-600 text-xs mt-6">Nexus v6.0 · 安全连接</p>
<p class="text-center text-[var(--text-dim)] text-xs mt-6">Nexus v6.0 · 安全连接</p>
</div>
<script>
+109 -109
View File
@@ -1,53 +1,53 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 推送</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div>
</header>
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
<!-- Push Form -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<!-- Template selector -->
<div class="flex items-center gap-2">
<select id="templateSelect" onchange="applyTemplate()" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-slate-300">
<select id="templateSelect" onchange="applyTemplate()" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-sm text-slate-300">
<option value="">📋 推送模板...</option>
</select>
<button onclick="saveTemplate()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition whitespace-nowrap" title="保存当前配置为模板">💾 保存</button>
<button onclick="deleteTemplate()" class="px-3 py-2 bg-slate-800 hover:bg-red-900/50 text-slate-400 text-sm rounded-lg transition" title="删除选中模板">🗑</button>
<button onclick="saveTemplate()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition whitespace-nowrap" title="保存当前配置为模板">💾 保存</button>
<button onclick="deleteTemplate()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-red-900/50 text-[var(--text-secondary)] text-sm rounded-lg transition" title="删除选中模板">🗑</button>
</div>
<!-- Source: ZIP upload (drag & drop + click) -->
<div>
<label class="text-sm text-slate-300 mb-2 block">上传 ZIP</label>
<div id="sourceUpload" class="space-y-2">
<div id="dropZone" class="relative flex items-center justify-center gap-3 px-6 py-8 border-2 border-dashed border-slate-700 rounded-xl cursor-pointer transition hover:border-brand hover:bg-brand/5"
<div id="dropZone" class="relative flex items-center justify-center gap-3 px-6 py-8 border-2 border-dashed border-[var(--border-input)] rounded-xl cursor-pointer transition hover:border-brand hover:bg-brand/5"
onclick="document.getElementById('zipFile').click()"
ondragover="event.preventDefault();this.classList.add('border-brand','bg-brand/5')"
ondragleave="this.classList.remove('border-brand','bg-brand/5')"
ondrop="event.preventDefault();this.classList.remove('border-brand','bg-brand/5');handleDrop(event)">
<input id="zipFile" type="file" accept=".zip" class="hidden" onchange="uploadZip()">
<svg class="w-8 h-8 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/></svg>
<div>
<p class="text-sm text-slate-300">拖拽 ZIP 文件到此处,或 <span class="text-brand-light hover:underline">点击选择</span></p>
<p class="text-xs text-slate-500 mt-1">仅支持 .zip 格式</p>
<p class="text-xs text-[var(--text-muted)] mt-1">仅支持 .zip 格式</p>
</div>
</div>
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-slate-400">
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<div class="animate-spin w-4 h-4 border-2 border-brand border-t-transparent rounded-full"></div>
上传并解压中...
</div>
<div id="uploadResult" class="hidden space-y-2">
<div class="flex items-center gap-3 px-3 py-2 bg-green-900/20 border border-green-700/30 rounded-lg">
<span class="text-green-400 text-sm"></span>
<span id="uploadedInfo" class="text-xs text-slate-400"></span>
<button onclick="clearUpload()" class="text-xs text-slate-500 hover:text-slate-300 ml-auto">✕ 清除</button>
<span id="uploadedInfo" class="text-xs text-[var(--text-secondary)]"></span>
<button onclick="clearUpload()" class="text-xs text-[var(--text-muted)] hover:text-slate-300 ml-auto">✕ 清除</button>
</div>
<!-- File Manager -->
<div id="fileManager" class="bg-slate-800/50 border border-slate-700/50 rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-slate-800 border-b border-slate-700/50">
<span class="text-xs text-slate-400">📁</span>
<div id="fileManager" class="bg-[var(--bg-surface)]/50 border border-[var(--border-input)]/50 rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border-b border-[var(--border-input)]/50">
<span class="text-xs text-[var(--text-secondary)]">📁</span>
<span id="fmBreadcrumb" class="text-xs text-slate-300 flex-1 truncate"></span>
<span id="fmCount" class="text-xs text-slate-500"></span>
<span id="fmCount" class="text-xs text-[var(--text-muted)]"></span>
</div>
<div id="fmEntries" class="max-h-64 overflow-y-auto"></div>
</div>
@@ -56,41 +56,41 @@
</div>
<!-- H5: Source path direct input -->
<div class="flex items-center gap-2">
<span class="text-xs text-slate-500 shrink-0">或输入源路径:</span>
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs placeholder-slate-600">
<button onclick="validateSourcePath()" id="validatePathBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition whitespace-nowrap">验证路径</button>
<span class="text-xs text-[var(--text-muted)] shrink-0">或输入源路径:</span>
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-xs placeholder-slate-600">
<button onclick="validateSourcePath()" id="validatePathBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition whitespace-nowrap">验证路径</button>
<span id="sourcePathResult" class="hidden text-xs"></span>
</div>
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label>
<div class="flex flex-wrap items-center gap-2 mb-2">
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300">
<option value="">全部分类</option>
</select>
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300">
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300">
<option value="">全部平台</option>
</select>
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
<button onclick="deselectAllServers()" class="text-xs text-[var(--text-secondary)] hover:underline">全不选</button>
<button onclick="selectFilteredServers()" class="text-xs text-brand-light hover:underline">选中当前筛选</button>
</div>
<input id="serverSearch" placeholder="搜索服务器名称/域名..." oninput="filterServerOptions()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-xs text-slate-300 placeholder-slate-600 mb-1">
<select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-40"></select>
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
<input id="serverSearch" placeholder="搜索服务器名称/域名..." oninput="filterServerOptions()" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300 placeholder-slate-600 mb-1">
<select id="targetServers" multiple class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm h-40"></select>
<div id="serverCount" class="text-[var(--text-muted)] text-xs mt-1">已选择 0 台服务器</div>
</div>
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label>
<div class="grid grid-cols-3 gap-3">
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="syncMode" value="incremental" checked class="accent-brand" onchange="onSyncModeChange()"> <span class="text-sm">增量同步</span>
</label>
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="syncMode" value="full" class="accent-brand" onchange="onSyncModeChange()"> <span class="text-sm">全量同步</span>
</label>
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="syncMode" value="checksum" class="accent-brand" onchange="onSyncModeChange()"> <span class="text-sm">校验和</span>
</label>
</div>
<div id="syncModeDesc" class="text-xs text-slate-500 mt-2 px-1">仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。</div>
<div id="syncModeDesc" class="text-xs text-[var(--text-muted)] mt-2 px-1">仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。</div>
</div>
<!-- Full sync warning + preview -->
@@ -108,56 +108,56 @@
</button>
</div>
<div id="lightPreview" class="flex items-center justify-end">
<button onclick="doPreview()" class="text-xs text-slate-500 hover:text-slate-300 transition">预览首台变动 →</button>
<button onclick="doPreview()" class="text-xs text-[var(--text-muted)] hover:text-slate-300 transition">预览首台变动 →</button>
</div>
<!-- Preview result -->
<div id="previewResult" class="hidden rounded-xl border border-slate-700 bg-slate-800/50 p-4 space-y-3">
<div id="previewResult" class="hidden rounded-xl border border-[var(--border-input)] bg-[var(--bg-surface)]/50 p-4 space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-slate-200" id="previewServerName">预览中...</span>
<button onclick="clearPreview()" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
<button onclick="clearPreview()" class="text-xs text-[var(--text-muted)] hover:text-slate-300">✕ 关闭</button>
</div>
<div id="previewStats" class="grid grid-cols-2 gap-2 text-sm"></div>
<div id="previewVerboseToggle" class="hidden">
<button onclick="doPreview(true)" class="text-xs text-brand-light hover:underline">显示详细文件列表 ▼</button>
</div>
<div id="previewFileList" class="hidden">
<pre id="previewFiles" class="text-xs text-slate-400 bg-slate-900 rounded-lg p-3 max-h-56 overflow-y-auto whitespace-pre-wrap"></pre>
<p id="previewTruncated" class="hidden text-xs text-slate-500 mt-1"></p>
<pre id="previewFiles" class="text-xs text-[var(--text-secondary)] bg-[var(--bg-card)] rounded-lg p-3 max-h-56 overflow-y-auto whitespace-pre-wrap"></pre>
<p id="previewTruncated" class="hidden text-xs text-[var(--text-muted)] mt-1"></p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm"></div>
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm"></div>
</div>
<!-- Action buttons -->
<div class="flex gap-3">
<button onclick="doPush()" id="pushBtn" class="flex-1 py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
<button onclick="showScheduleModal()" class="px-4 py-3 bg-slate-800 hover:bg-slate-700 text-slate-300 font-medium rounded-xl transition text-sm whitespace-nowrap">⏰ 定时推送</button>
<button onclick="showScheduleModal()" class="px-4 py-3 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 font-medium rounded-xl transition text-sm whitespace-nowrap">⏰ 定时推送</button>
</div>
<label class="flex items-center gap-2 text-sm text-slate-400 -mt-2">
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)] -mt-2">
<input type="checkbox" id="verifyAfterPush" class="accent-brand"> 推送后校验(md5sum 对比)
</label>
</div>
<!-- Progress Section -->
<div id="progressSection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div id="progressSection" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-white font-semibold">推送进度</h2>
<div class="flex items-center gap-3">
<span id="progressStats" class="text-sm text-slate-400">0/0</span>
<span id="progressStats" class="text-sm text-[var(--text-secondary)]">0/0</span>
<button id="cancelPushBtn" onclick="cancelPush()" class="hidden px-3 py-1 bg-red-500/20 hover:bg-red-500/30 border border-red-500/50 text-red-400 text-xs rounded-lg transition">取消推送</button>
</div>
</div>
<div class="w-full bg-slate-800 rounded-full h-3 overflow-hidden">
<div class="w-full bg-[var(--bg-surface)] rounded-full h-3 overflow-hidden">
<div id="progressBar" class="h-full bg-brand rounded-full transition-all duration-500" style="width:0%"></div>
</div>
<div class="flex gap-4 text-sm">
<span class="text-green-400">✓ 成功: <span id="countSuccess">0</span></span>
<span class="text-red-400">✗ 失败: <span id="countFailed">0</span></span>
<span class="text-slate-400">◌ 等待: <span id="countPending">0</span></span>
<span class="text-[var(--text-secondary)]">◌ 等待: <span id="countPending">0</span></span>
<button id="retryAllBtn" class="hidden text-xs text-brand-light hover:underline ml-2" onclick="retryAllFailed()">🔄 重试全部失败</button>
</div>
<div id="serverResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
@@ -168,42 +168,42 @@
<div class="flex items-center justify-between mb-3">
<h2 class="text-white font-semibold">推送记录</h2>
<div class="flex items-center gap-2">
<select id="historyStatus" onchange="loadHistory(1)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300">
<select id="historyStatus" onchange="loadHistory(1)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-xs text-slate-300">
<option value="">全部状态</option>
<option value="success">成功</option>
<option value="failed">失败</option>
<option value="cancelled">已取消</option>
</select>
<input id="historyServer" placeholder="服务器ID" class="w-20 px-2 py-1 bg-slate-800 border border-slate-700 rounded text-xs text-slate-300" onchange="loadHistory(1)">
<input id="historyServer" placeholder="服务器ID" class="w-20 px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-xs text-slate-300" onchange="loadHistory(1)">
</div>
</div>
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
<div id="historyPager" class="hidden flex items-center justify-center gap-2 mt-3 text-xs text-slate-400">
<button onclick="loadHistory(_histPage-1)" id="histPrev" class="px-2 py-1 bg-slate-800 rounded hover:bg-slate-700 disabled:opacity-30">上一页</button>
<div id="pushHistory" class="space-y-2"><div class="text-[var(--text-muted)] text-center py-6 text-sm">加载中...</div></div>
<div id="historyPager" class="hidden flex items-center justify-center gap-2 mt-3 text-xs text-[var(--text-secondary)]">
<button onclick="loadHistory(_histPage-1)" id="histPrev" class="px-2 py-1 bg-[var(--bg-surface)] rounded hover:bg-slate-700 disabled:opacity-30">上一页</button>
<span id="histPageInfo">1 / 1</span>
<button onclick="loadHistory(_histPage+1)" id="histNext" class="px-2 py-1 bg-slate-800 rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
<button onclick="loadHistory(_histPage+1)" id="histNext" class="px-2 py-1 bg-[var(--bg-surface)] rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
</div>
</div>
<!-- Verify Result Section -->
<div id="verifySection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div id="verifySection" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-white font-semibold">推送校验结果</h2>
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-slate-500 hover:text-slate-300">✕ 关闭</button>
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-[var(--text-muted)] hover:text-slate-300">✕ 关闭</button>
</div>
<div id="verifyResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
</div>
<!-- File Preview Modal -->
<div id="filePreviewModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-[640px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-[640px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
<div class="flex items-center gap-2">
<span class="text-slate-400">📄</span>
<span class="text-[var(--text-secondary)]">📄</span>
<span id="previewFileName" class="text-sm text-slate-200 font-medium"></span>
<span id="previewFileSize" class="text-xs text-slate-500"></span>
<span id="previewFileSize" class="text-xs text-[var(--text-muted)]"></span>
</div>
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">&times;</button>
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">&times;</button>
</div>
<pre id="previewFileContent" class="flex-1 overflow-auto px-5 py-4 text-xs text-slate-300 font-mono whitespace-pre-wrap break-all"></pre>
</div>
@@ -211,43 +211,43 @@
<!-- Schedule Modal -->
<div id="scheduleModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-md p-6 space-y-4">
<h2 class="text-white font-semibold text-lg">⏰ 定时推送</h2>
<p class="text-sm text-slate-400">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
<p class="text-sm text-[var(--text-secondary)]">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
<div>
<label class="block text-slate-400 text-xs mb-1">执行时间 *</label>
<input id="schedFireAt" type="datetime-local" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行时间 *</label>
<input id="schedFireAt" type="datetime-local" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
</div>
<div id="schedSummary" class="text-xs text-slate-500 bg-slate-800/50 rounded-lg px-3 py-2"></div>
<div id="schedSummary" class="text-xs text-[var(--text-muted)] bg-[var(--bg-surface)]/50 rounded-lg px-3 py-2"></div>
<div class="flex gap-2">
<button onclick="doSchedulePush()" class="flex-1 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">确认调度</button>
<button onclick="document.getElementById('scheduleModal').classList.add('hidden')" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
<button onclick="document.getElementById('scheduleModal').classList.add('hidden')" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
</div>
</div>
</div>
<!-- H4: Diagnose Modal -->
<div id="diagModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-md p-6 space-y-4">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-md p-6 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-white font-semibold">🔍 推送诊断</h2>
<button onclick="document.getElementById('diagModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">&times;</button>
<button onclick="document.getElementById('diagModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">&times;</button>
</div>
<div id="diagServerName" class="text-sm text-slate-400"></div>
<div id="diagServerName" class="text-sm text-[var(--text-secondary)]"></div>
<div id="diagResults" class="space-y-3"></div>
</div>
</div>
<!-- H3: File Diff Modal -->
<div id="diffModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-[720px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-800">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-[720px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-[var(--border)]">
<div class="flex items-center gap-2">
<span class="text-slate-400">📄</span>
<span class="text-[var(--text-secondary)]">📄</span>
<span id="diffFileName" class="text-sm text-slate-200 font-medium"></span>
<span id="diffFileInfo" class="text-xs text-slate-500"></span>
<span id="diffFileInfo" class="text-xs text-[var(--text-muted)]"></span>
</div>
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-slate-500 hover:text-white text-lg">&times;</button>
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">&times;</button>
</div>
<div id="diffContent" class="flex-1 overflow-auto px-3 py-3 font-mono text-xs leading-5"></div>
</div>
@@ -351,9 +351,9 @@
if(retryEl)retryEl.classList.add('hidden');
if(diagEl)diagEl.classList.add('hidden');
}else if(msg.status==='cancelled'){
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-400';
statusEl.textContent='已取消';statusEl.className='text-xs text-slate-400';
detailEl.textContent='推送已取消';detailEl.classList.remove('hidden');detailEl.className='text-xs text-slate-500';
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-[var(--text-secondary)]';
statusEl.textContent='已取消';statusEl.className='text-xs text-[var(--text-secondary)]';
detailEl.textContent='推送已取消';detailEl.classList.remove('hidden');detailEl.className='text-xs text-[var(--text-muted)]';
if(retryEl)retryEl.classList.add('hidden');
if(diagEl)diagEl.classList.add('hidden');
}else{
@@ -420,7 +420,7 @@
_fmCurrentPath=path;
const rel=path.replace(_uploadSourcePath,'').replace(/^\//,'');
document.getElementById('fmBreadcrumb').textContent=rel?'📁 '+rel:'📁 /';
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-slate-500">加载中...</div>';
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-[var(--text-muted)]">加载中...</div>';
try{
const r=await apiFetch(API+'/sync/browse-local',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({path})});
if(!r){toast('error','浏览失败');return}
@@ -437,17 +437,17 @@
let html='';
if(d.path!==_uploadSourcePath){
const parent=d.path.substring(0,d.path.lastIndexOf('/'))||_uploadSourcePath;
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')"><span class="text-slate-400">📁</span><span class="text-slate-300">..</span></div>`;
html+=`<div class="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(parent)}')"><span class="text-[var(--text-secondary)]">📁</span><span class="text-slate-300">..</span></div>`;
}
for(const e of dirs){
const fullPath=d.path+'/'+e.name;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')"><span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名"></button><button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/30 cursor-pointer text-xs" onclick="browseLocal('${esc(fullPath)}')"><span class="text-brand-light">📁</span><span class="text-slate-200 flex-1 truncate">${esc(e.name)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="event.stopPropagation();renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-slate-300 px-1" title="重命名"></button><button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
}
for(const e of files){
const fullPath=d.path+'/'+e.name;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs"><span class="text-slate-500">📄</span><span class="text-slate-300 flex-1 truncate cursor-pointer hover:text-brand-light transition" onclick="previewLocalFile('${esc(fullPath)}','${esc(e.name)}')" title="点击预览">${esc(e.name)}</span><span class="text-slate-600 shrink-0">${fmtBytes(e.size)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-slate-300 px-1" title="重命名"></button><button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-slate-500 hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs"><span class="text-[var(--text-muted)]">📄</span><span class="text-slate-300 flex-1 truncate cursor-pointer hover:text-brand-light transition" onclick="previewLocalFile('${esc(fullPath)}','${esc(e.name)}')" title="点击预览">${esc(e.name)}</span><span class="text-[var(--text-dim)] shrink-0">${fmtBytes(e.size)}</span><span class="hidden group-hover:flex items-center gap-1 shrink-0"><button onclick="renameLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-slate-300 px-1" title="重命名"></button><button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-[var(--text-muted)] hover:text-red-400 px-1" title="删除">🗑</button></span></div>`;
}
if(!dirs.length&&!files.length)html='<div class="px-3 py-2 text-xs text-slate-500">空目录</div>';
if(!dirs.length&&!files.length)html='<div class="px-3 py-2 text-xs text-[var(--text-muted)]">空目录</div>';
document.getElementById('fmEntries').innerHTML=html;
}
@@ -588,13 +588,13 @@
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
document.getElementById('serverResults').innerHTML=ids.map(id=>{
const s=serversMap[id];
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-slate-800/50 rounded-lg">
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-slate-400" id="srv_icon_${id}"></span>
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-[var(--bg-surface)]/50 rounded-lg">
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-[var(--text-secondary)]" id="srv_icon_${id}"></span>
<span class="text-sm text-slate-300 flex-1">${esc(s?.name||'服务器#'+id)}</span>
<span class="text-xs text-slate-500" id="srv_status_${id}">等待中</span>
<span class="text-xs text-slate-600 hidden" id="srv_detail_${id}"></span>
<span class="text-xs text-[var(--text-muted)]" id="srv_status_${id}">等待中</span>
<span class="text-xs text-[var(--text-dim)] hidden" id="srv_detail_${id}"></span>
<button id="srv_retry_${id}" class="hidden text-xs text-brand-light hover:underline shrink-0" onclick="retryServer(this)">🔄 重试</button>
<button id="srv_diag_${id}" class="hidden text-xs text-slate-400 hover:text-amber-400 shrink-0" onclick="diagnoseServer(${id})">🔍 诊断</button>
<button id="srv_diag_${id}" class="hidden text-xs text-[var(--text-secondary)] hover:text-amber-400 shrink-0" onclick="diagnoseServer(${id})">🔍 诊断</button>
</div>`;
}).join('');
}
@@ -617,8 +617,8 @@
if(log.duration_seconds){detailEl.textContent=log.duration_seconds+'s';detailEl.classList.remove('hidden')}
if(diagEl)diagEl.classList.add('hidden');
}else if(log.status==='cancelled'){
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-400';
statusEl.textContent='已取消';statusEl.className='text-xs text-slate-400';
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-[var(--text-secondary)]';
statusEl.textContent='已取消';statusEl.className='text-xs text-[var(--text-secondary)]';
if(diagEl)diagEl.classList.add('hidden');
}else{
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
@@ -689,7 +689,7 @@
const targetPath=document.getElementById('destPath').value||null;
const modal=document.getElementById('diagModal');
document.getElementById('diagServerName').textContent='服务器 #'+serverId;
document.getElementById('diagResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">诊断中...</div>';
document.getElementById('diagResults').innerHTML='<div class="text-[var(--text-muted)] text-sm text-center py-4">诊断中...</div>';
modal.classList.remove('hidden');
try{
const body={server_id:serverId,target_path:targetPath};
@@ -706,7 +706,7 @@
document.getElementById('diagResults').innerHTML=checks.map(c=>`
<div class="flex items-start gap-3 px-3 py-2 ${c.ok?'bg-green-900/10 border border-green-700/20':'bg-red-900/10 border border-red-700/20'} rounded-lg">
<span class="${c.ok?'text-green-400':'text-red-400'} text-sm mt-0.5">${c.ok?'✓':'✗'}</span>
<div><div class="text-sm text-slate-200">${esc(c.label)}</div><div class="text-xs ${c.ok?'text-slate-400':'text-red-400/80'}">${esc(c.detail)}</div></div>
<div><div class="text-sm text-slate-200">${esc(c.label)}</div><div class="text-xs ${c.ok?'text-[var(--text-secondary)]':'text-red-400/80'}">${esc(c.detail)}</div></div>
</div>`).join('');
}catch(e){document.getElementById('diagResults').innerHTML=`<div class="text-red-400 text-sm">诊断异常: ${esc(e.message)}</div>`}
}
@@ -732,12 +732,12 @@
if(d.truncated)info+=' (截断至100KB)';
document.getElementById('diffFileInfo').textContent=info;
const lines=d.diff_lines||[];
if(!lines.length){document.getElementById('diffContent').innerHTML='<div class="text-slate-500 text-center py-4">文件内容相同,无差异</div>';return}
if(!lines.length){document.getElementById('diffContent').innerHTML='<div class="text-[var(--text-muted)] text-center py-4">文件内容相同,无差异</div>';return}
document.getElementById('diffContent').innerHTML=lines.map(l=>{
if(l.type==='header')return `<div class="text-cyan-400 font-bold">${esc(l.content)}</div>`;
if(l.type==='add')return `<div class="bg-green-900/30 text-green-300 pl-2">${esc(l.content)}</div>`;
if(l.type==='del')return `<div class="bg-red-900/30 text-red-300 pl-2">${esc(l.content)}</div>`;
return `<div class="text-slate-500 pl-2">${esc(l.content)}</div>`;
return `<div class="text-[var(--text-muted)] pl-2">${esc(l.content)}</div>`;
}).join('');
}catch(e){toast('error','对比异常: '+e.message)}
}
@@ -775,7 +775,7 @@
// ── Post-Push Verify (md5sum) ──
async function doVerify(ids,sourcePath,targetPath){
const section=document.getElementById('verifySection');section.classList.remove('hidden');
document.getElementById('verifyResults').innerHTML='<div class="text-slate-500 text-sm text-center py-4">校验中...</div>';
document.getElementById('verifyResults').innerHTML='<div class="text-[var(--text-muted)] text-sm text-center py-4">校验中...</div>';
try{
const body={server_ids:ids,source_path:sourcePath,target_path:targetPath||null};
const r=await apiFetch(API+'/sync/verify',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
@@ -783,12 +783,12 @@
const d=await r.json();if(!r.ok){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">${esc(d.detail||'校验失败')}</div>`;return}
const results=d.results||{};
document.getElementById('verifyResults').innerHTML=Object.values(results).map(v=>{
if(v.error)return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500"><span class="text-red-400 text-sm"></span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
if(v.error)return `<div class="bg-[var(--bg-surface)]/50 rounded-lg px-4 py-3 border-l-2 border-l-red-500"><span class="text-red-400 text-sm"></span> <span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-red-400 ml-2">${esc(v.error)}</span></div>`;
const allOk=v.missing===0&&v.mismatched===0;const border=allOk?'border-l-green-500':'border-l-amber-500';
let detail='';
if(v.missing>0)detail+=`<div class="text-xs text-amber-400 mt-1">缺失 ${v.missing} 文件: ${v.missing_files.slice(0,5).map(esc).join(', ')}${v.missing>5?' ...':''}</div>`;
if(v.mismatched>0)detail+=`<div class="text-xs text-red-400 mt-1">不一致 ${v.mismatched} 文件: ${v.mismatched_files.slice(0,5).map(esc).join(', ')}${v.mismatched>5?' ...':''}</div>`;
return `<div class="bg-slate-800/50 rounded-lg px-4 py-3 border-l-2 ${border}"><span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span><span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-slate-500 ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>${detail}</div>`;
return `<div class="bg-[var(--bg-surface)]/50 rounded-lg px-4 py-3 border-l-2 ${border}"><span class="${allOk?'text-green-400':'text-amber-400'} text-sm">${allOk?'✓':'⚠'}</span><span class="text-sm text-slate-300">${esc(v.server_name||'#'+v.server_id)}</span><span class="text-xs text-[var(--text-muted)] ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>${detail}</div>`;
}).join('');
}catch(e){document.getElementById('verifyResults').innerHTML=`<div class="text-red-400 text-sm">校验异常: ${esc(e.message)}</div>`}
}
@@ -810,18 +810,18 @@
const border=cancelled?'border-l-2 border-l-slate-500':ok?'border-l-2 border-l-green-500':'border-l-2 border-l-red-500';
let summary=`${esc(l.source_path||'')} → ${esc(l.target_path||'')}`;if(l.duration_seconds!=null)summary+=` · ${l.duration_seconds}s`;
let detail='';
if(l.files_transferred!=null)detail+=`<div class="text-xs text-slate-400">传输: ${l.files_transferred} 文件</div>`;
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-slate-400">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
if(l.files_transferred!=null)detail+=`<div class="text-xs text-[var(--text-secondary)]">传输: ${l.files_transferred} 文件</div>`;
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-[var(--text-secondary)]">大小: ${fmtBytes(l.bytes_transferred)}</div>`;
if(l.error_message)detail+=`<div class="text-xs text-red-400/80 break-all">${esc(l.error_message.substring(0,300))}</div>`;
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-slate-500 cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-slate-500 bg-slate-900 rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-[var(--text-muted)] cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-[var(--text-muted)] bg-[var(--bg-card)] rounded p-2 mt-1 max-h-40 overflow-y-auto whitespace-pre-wrap">${esc(l.diff_summary.substring(0,2000))}</pre></details>`;
const statusLabel=cancelled?'⊘ 已取消':ok?'✓':'✗';
const statusCls=cancelled?'text-slate-400':ok?'text-green-400':'text-red-400';
return `<div class="bg-slate-900 rounded-lg border border-slate-800 ${border} px-4 py-3 cursor-pointer hover:bg-slate-800/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
<div class="flex items-center justify-between"><div class="flex items-center gap-3"><span class="${statusCls} text-sm">${statusLabel}</span><div><span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span><span class="text-slate-600 mx-1">·</span><span class="text-xs text-slate-500">${esc(l.operator||'system')}</span><span class="text-slate-600 mx-1">·</span><span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span></div></div><div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div></div>
<div class="text-xs text-slate-500 mt-1 truncate">${summary}</div>
<div class="hist-detail hidden mt-2 space-y-1 border-t border-slate-700/50 pt-2">${detail}</div>
const statusCls=cancelled?'text-[var(--text-secondary)]':ok?'text-green-400':'text-red-400';
return `<div class="bg-[var(--bg-card)] rounded-lg border border-[var(--border)] ${border} px-4 py-3 cursor-pointer hover:bg-[var(--bg-surface)]/50 transition" onclick="this.querySelector('.hist-detail')?.classList.toggle('hidden')">
<div class="flex items-center justify-between"><div class="flex items-center gap-3"><span class="${statusCls} text-sm">${statusLabel}</span><div><span class="text-sm text-slate-300">${esc(l.server_name||'#'+l.server_id)}</span><span class="text-[var(--text-dim)] mx-1">·</span><span class="text-xs text-[var(--text-muted)]">${esc(l.operator||'system')}</span><span class="text-[var(--text-dim)] mx-1">·</span><span class="text-xs text-[var(--text-muted)]">${esc(l.sync_mode||'file')}</span></div></div><div class="text-xs text-[var(--text-muted)]">${fmtTime(l.started_at)}</div></div>
<div class="text-xs text-[var(--text-muted)] mt-1 truncate">${summary}</div>
<div class="hist-detail hidden mt-2 space-y-1 border-t border-[var(--border-input)]/50 pt-2">${detail}</div>
</div>`;
}).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
}).join(''):'<div class="text-[var(--text-muted)] text-center py-6 text-sm">暂无推送记录</div>';
// Pagination
const pager=document.getElementById('historyPager');
if(pages>1){
@@ -830,7 +830,7 @@
document.getElementById('histPrev').disabled=page<=1;
document.getElementById('histNext').disabled=page>=pages;
}else{pager.classList.add('hidden')}
}catch(e){document.getElementById('pushHistory').innerHTML='<div class="text-slate-500 text-center py-6 text-sm">加载失败</div>'}
}catch(e){document.getElementById('pushHistory').innerHTML='<div class="text-[var(--text-muted)] text-center py-6 text-sm">加载失败</div>'}
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML.replace(/'/g,'&#39;').replace(/"/g,'&quot;')}
@@ -857,7 +857,7 @@
const btn=document.getElementById('previewBtn');const serverName=_serversCache.find(s=>s.id===firstServerId)?.name||('#'+firstServerId);
const resultEl=document.getElementById('previewResult');resultEl.classList.remove('hidden');
document.getElementById('previewServerName').textContent=`预览中:${esc(serverName)}...`;
document.getElementById('previewStats').innerHTML='<span class="text-slate-500 text-xs col-span-2">正在运行 rsync --dry-run...</span>';
document.getElementById('previewStats').innerHTML='<span class="text-[var(--text-muted)] text-xs col-span-2">正在运行 rsync --dry-run...</span>';
document.getElementById('previewVerboseToggle').classList.add('hidden');document.getElementById('previewFileList').classList.add('hidden');
if(btn)btn.disabled=true;
try{
@@ -873,9 +873,9 @@
document.getElementById('previewServerName').textContent=`预览结果:${esc(serverName)}`;
const s=d.stats||{};const isDestructive=(d.sync_mode==='full'||d.sync_mode==='overwrite');
document.getElementById('previewStats').innerHTML=`
<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">待传输文件</div><div class="text-white font-semibold text-lg">${s.files_transferred??'—'}</div>${s.transfer_size_bytes!=null?`<div class="text-slate-500 text-xs">${fmtBytes(s.transfer_size_bytes)}</div>`:''}</div>
<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">新增文件</div><div class="text-green-400 font-semibold text-lg">${s.files_created??'—'}</div>${s.files_total!=null?`<div class="text-slate-500 text-xs">共 ${s.files_total} 个文件</div>`:''}</div>
${isDestructive&&s.files_deleted!=null?`<div class="bg-red-900/30 border border-red-500/30 rounded-lg px-3 py-2 col-span-2"><div class="text-red-400 text-xs mb-1">⚠ 将删除文件(--delete</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted} 个文件</div></div>`:(s.files_deleted?`<div class="bg-slate-900 rounded-lg px-3 py-2"><div class="text-slate-400 text-xs mb-1">删除文件</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted}</div></div>`:'')}`;
<div class="bg-[var(--bg-card)] rounded-lg px-3 py-2"><div class="text-[var(--text-secondary)] text-xs mb-1">待传输文件</div><div class="text-white font-semibold text-lg">${s.files_transferred??'—'}</div>${s.transfer_size_bytes!=null?`<div class="text-[var(--text-muted)] text-xs">${fmtBytes(s.transfer_size_bytes)}</div>`:''}</div>
<div class="bg-[var(--bg-card)] rounded-lg px-3 py-2"><div class="text-[var(--text-secondary)] text-xs mb-1">新增文件</div><div class="text-green-400 font-semibold text-lg">${s.files_created??'—'}</div>${s.files_total!=null?`<div class="text-[var(--text-muted)] text-xs">共 ${s.files_total} 个文件</div>`:''}</div>
${isDestructive&&s.files_deleted!=null?`<div class="bg-red-900/30 border border-red-500/30 rounded-lg px-3 py-2 col-span-2"><div class="text-red-400 text-xs mb-1">⚠ 将删除文件(--delete</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted} 个文件</div></div>`:(s.files_deleted?`<div class="bg-[var(--bg-card)] rounded-lg px-3 py-2"><div class="text-[var(--text-secondary)] text-xs mb-1">删除文件</div><div class="text-red-400 font-semibold text-lg">${s.files_deleted}</div></div>`:'')}`;
if(!verbose)document.getElementById('previewVerboseToggle').classList.remove('hidden');
if(verbose&&d.files&&d.files.length>0){document.getElementById('previewFileList').classList.remove('hidden');
document.getElementById('previewFiles').innerHTML=d.files.map(f=>`<div class="flex items-center gap-2"><span class="flex-1 truncate">${esc(f)}</span><button onclick="showFileDiff(${_previewServerId},'${esc(f)}')" class="text-xs text-brand-light hover:underline shrink-0">📄 diff</button></div>`).join('\n');
+17 -15
View File
@@ -1,22 +1,24 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 重试队列</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 重试队列</title><script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">重试队列</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">重试队列</h1></div>
<div class="flex items-center gap-3">
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部状态</option>
<option value="pending">等待中</option>
<option value="running">执行中</option>
<option value="failed">失败</option>
<option value="success">成功</option>
</select>
<button onclick="loadRetries()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="loadRetries()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="retryList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="retryList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
</main>
</div>
<script src="/app/api.js"></script>
@@ -40,7 +42,7 @@
const statusIcon=j.status==='pending'?'⏳':j.status==='running'?'🔄':j.status==='failed'?'❌':'✅';
const retryPct=Math.min((j.retry_count/j.max_retries)*100,100);
const canRetry=j.status==='failed'||j.status==='pending';
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
return `<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm">${statusIcon}</span>
@@ -48,17 +50,17 @@
<span class="${statusColor} text-xs px-2 py-0.5 rounded ${j.status==='failed'?'bg-red-400/10':j.status==='pending'?'bg-yellow-400/10':j.status==='success'?'bg-green-400/10':'bg-blue-400/10'}">${esc(j.status)}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-slate-400">重试 ${j.retry_count}/${j.max_retries}</span>
<span class="text-xs text-[var(--text-secondary)]">重试 ${j.retry_count}/${j.max_retries}</span>
${canRetry?`<button onclick="retryJob(${j.id})" class="text-brand-light text-xs hover:underline px-2 py-1 bg-brand/10 rounded">重试</button>`:''}
<button onclick="deleteJob(${j.id})" class="text-red-400 text-xs hover:underline px-2 py-1 bg-red-400/10 rounded">删除</button>
</div>
</div>
<div class="mt-2 text-xs text-slate-500">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
${j.operator?`<div class="mt-1 text-xs text-slate-600">操作人: ${esc(j.operator)}</div>`:''}
<div class="mt-2 text-xs text-[var(--text-muted)]">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
${j.operator?`<div class="mt-1 text-xs text-[var(--text-dim)]">操作人: ${esc(j.operator)}</div>`:''}
${j.last_error?`<div class="mt-1 text-xs text-red-400/70 truncate" title="${esc(j.last_error)}">${esc(j.last_error.substring(0,100))}</div>`:''}
<div class="mt-2 w-full bg-slate-800 rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
${j.next_retry_at?`<div class="mt-1 text-xs text-slate-600">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无重试任务</div>';
<div class="mt-2 w-full bg-[var(--bg-surface)] rounded-full h-1"><div class="h-1 rounded-full ${retryPct>=80?'bg-red-400':retryPct>=50?'bg-yellow-400':'bg-brand'}" style="width:${retryPct}%"></div></div>
${j.next_retry_at?`<div class="mt-1 text-xs text-[var(--text-dim)]">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
</div>`}).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无重试任务</div>';
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
+38 -36
View File
@@ -1,32 +1,34 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 定时调度</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 定时调度</title><script src="/app/vendor/tailwindcss-browser.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">定时调度</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">定时调度</h1></div>
<button onclick="showAddSched()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 新建</button>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="schedList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="schedList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
<!-- Modal -->
<div id="schedModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 py-6 overflow-y-auto">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-4 my-auto">
<h2 id="schedModalTitle" class="text-white font-semibold text-lg">新建调度</h2>
<input type="hidden" id="editSchedId">
<!-- Basic -->
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="sName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="每日更新"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称 *</label><input id="sName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="每日更新"></div>
<!-- Run mode selector -->
<div>
<label class="block text-slate-400 text-xs mb-1">执行方式 *</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行方式 *</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="sRunMode" value="once" checked class="accent-brand" onchange="onRunModeChange()">
<span class="text-sm">🕐 一次性</span>
</label>
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="sRunMode" value="cron" class="accent-brand" onchange="onRunModeChange()">
<span class="text-sm">🔁 循环 (Cron)</span>
</label>
@@ -35,26 +37,26 @@
<!-- Once: fire_at -->
<div id="onceFields">
<label class="block text-slate-400 text-xs mb-1">执行时间 *</label>
<input id="sFireAt" type="datetime-local" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<label class="block text-[var(--text-secondary)] text-xs mb-1">执行时间 *</label>
<input id="sFireAt" type="datetime-local" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
</div>
<!-- Cron: expression -->
<div id="cronFields" class="hidden">
<label class="block text-slate-400 text-xs mb-1">Cron 表达式 *</label>
<input id="sCron" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *">
<div class="text-slate-600 text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div>
<label class="block text-[var(--text-secondary)] text-xs mb-1">Cron 表达式 *</label>
<input id="sCron" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="0 2 * * *">
<div class="text-[var(--text-dim)] text-xs mt-1">分 时 日 月 周 · 0 2 * * * = 每天凌晨 2 点 · 最小精度 1 分钟</div>
</div>
<!-- Type selector -->
<div>
<label class="block text-slate-400 text-xs mb-1">调度类型 *</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">调度类型 *</label>
<div class="grid grid-cols-2 gap-2">
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="sType" value="push" checked class="accent-brand" onchange="onTypeChange()">
<span class="text-sm">📁 文件推送 (rsync)</span>
</label>
<label class="flex items-center gap-2 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<label class="flex items-center gap-2 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
<input type="radio" name="sType" value="script" class="accent-brand" onchange="onTypeChange()">
<span class="text-sm">⚡ 脚本执行</span>
</label>
@@ -63,24 +65,24 @@
<!-- Push fields -->
<div id="pushFields" class="space-y-3">
<div><label class="block text-slate-400 text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
<div><label class="block text-slate-400 text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete</option><option value="checksum">校验和</option></select></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">源路径 *</label><input id="sPath" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/home/deploy/project/"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">同步模式</label><select id="sSyncMode" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步(--delete</option><option value="checksum">校验和</option></select></div>
</div>
<!-- Script fields -->
<div id="scriptFields" class="hidden space-y-3">
<div>
<label class="block text-slate-400 text-xs mb-1">脚本库选择(可选)</label>
<select id="sScriptId" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<label class="block text-[var(--text-secondary)] text-xs mb-1">脚本库选择(可选)</label>
<select id="sScriptId" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">不使用脚本库(使用下方命令)</option>
</select>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">命令内容(脚本库为空时生效)</label>
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task'&#10;uptime" class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
<label class="block text-[var(--text-secondary)] text-xs mb-1">命令内容(脚本库为空时生效)</label>
<textarea id="sScriptContent" rows="4" placeholder="echo 'scheduled task'&#10;uptime" class="w-full px-3 py-2 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono resize-y"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">超时(秒)</label><input id="sTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
<div class="flex items-center pt-5"><label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" id="sLongTask" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"><span class="text-sm text-slate-300">长任务(nohup</span></label></div>
</div>
</div>
@@ -88,15 +90,15 @@
<!-- Common: server selector -->
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-slate-400 text-xs">目标服务器 *</label>
<label class="block text-[var(--text-secondary)] text-xs">目标服务器 *</label>
<button onclick="Array.from(document.getElementById('sServers').options).forEach(o=>o.selected=true)" class="text-brand-light text-xs hover:underline">全选</button>
</div>
<select id="sServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select>
<select id="sServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-32"></select>
</div>
<div class="flex gap-2 pt-1">
<button onclick="saveSched()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm font-medium">保存</button>
<button onclick="hideSchedModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
<button onclick="hideSchedModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
</div>
</div>
</div>
@@ -134,8 +136,8 @@
const scheduleDesc=(s.run_mode||'once')==='cron'
?`<span class="font-mono">${esc(s.cron_expr)}</span>`
:`一次性 ${s.fire_at?fmtTime(s.fire_at):'(未设置)'}`;
const doneBadge=(s.run_mode==='once'&&s.last_run_at)?' <span class="text-slate-600 text-[10px]">✓ 已执行</span>':'';
return `<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
const doneBadge=(s.run_mode==='once'&&s.last_run_at)?' <span class="text-[var(--text-dim)] text-[10px]">✓ 已执行</span>':'';
return `<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3 min-w-0">
<button onclick="toggleSched(${s.id},${!s.enabled})" class="w-10 h-6 rounded-full transition-colors ${s.enabled?'bg-brand':'bg-slate-700'} relative shrink-0" title="${s.enabled?'点击禁用':'点击启用'}">
@@ -144,21 +146,21 @@
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="text-white font-medium">${esc(s.name)}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-400">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-slate-800 text-slate-500">${runModeLabel}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${TYPE_LABEL[s.schedule_type||'push']||'推送'}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-muted)]">${runModeLabel}</span>
</div>
<div class="text-slate-500 text-xs mt-0.5 truncate">
<div class="text-[var(--text-muted)] text-xs mt-0.5 truncate">
${scheduleDesc} · ${detail}${s.last_run_at&&s.run_mode==='cron'?' · 上次: '+fmtTime(s.last_run_at):''}${doneBadge}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0 ml-3">
<button onclick="showEditSched(${s.id})" class="text-slate-400 hover:text-white text-xs transition">编辑</button>
<button onclick="showEditSched(${s.id})" class="text-[var(--text-secondary)] hover:text-white text-xs transition">编辑</button>
<button onclick="deleteSched(${s.id})" class="text-red-400 text-xs hover:underline">删除</button>
</div>
</div>
</div>`}).join(''):'<div class="text-slate-500 text-center py-8">暂无调度</div>';
</div>`}).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无调度</div>';
}catch(e){document.getElementById('schedList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
+45 -45
View File
@@ -1,51 +1,51 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><div class="flex items-center gap-2"><a href="/app/script-executions.html" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg">执行历史</a><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></div></header>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 脚本库</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">脚本库</h1></div><div class="flex items-center gap-2"><a href="/app/script-executions.html" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg">执行历史</a><button onclick="showNewScript()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg">+ 新建脚本</button></div></div></header>
<main class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- ── 直接执行(无需保存到脚本库)── -->
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
<button onclick="toggleQuickExec()" id="quickExecToggleBtn"
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-slate-800/50 transition group">
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-[var(--bg-surface)]/50 transition group">
<div class="flex items-center gap-2">
<span class="text-brand-light font-mono text-sm"></span>
<span class="text-white font-medium text-sm">直接执行命令</span>
<span class="text-slate-500 text-xs">(临时命令,不保存到脚本库)</span>
<span class="text-[var(--text-muted)] text-xs">(临时命令,不保存到脚本库)</span>
</div>
<svg id="quickExecChevron" class="w-4 h-4 text-slate-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg id="quickExecChevron" class="w-4 h-4 text-[var(--text-muted)] transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="quickExecPanel" class="hidden border-t border-slate-800 p-5 space-y-4">
<div id="quickExecPanel" class="hidden border-t border-[var(--border)] p-5 space-y-4">
<!-- Command input -->
<div>
<label class="block text-slate-400 text-xs mb-1.5">命令内容 <span class="text-slate-600">(支持多行 Shell 脚本)</span></label>
<label class="block text-[var(--text-secondary)] text-xs mb-1.5">命令内容 <span class="text-[var(--text-dim)]">(支持多行 Shell 脚本)</span></label>
<textarea id="quickCmd" rows="4" placeholder="echo 'hello world'&#10;df -h&#10;uptime"
class="w-full px-4 py-3 bg-slate-950 border border-slate-700 rounded-xl text-green-400 text-sm font-mono
class="w-full px-4 py-3 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-xl text-green-400 text-sm font-mono
focus:outline-none focus:ring-2 focus:ring-brand resize-y placeholder-slate-700"
onkeydown="if(event.ctrlKey&&event.key==='Enter'){event.preventDefault();doQuickExec()}"></textarea>
<p class="text-slate-600 text-xs mt-1">Ctrl + Enter 快捷执行</p>
<p class="text-[var(--text-dim)] text-xs mt-1">Ctrl + Enter 快捷执行</p>
</div>
<!-- Servers + options row -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="flex items-center justify-between mb-1.5">
<label class="text-slate-400 text-xs">目标服务器</label>
<label class="text-[var(--text-secondary)] text-xs">目标服务器</label>
<button onclick="selectAllQuickServers()" class="text-brand-light text-xs hover:underline">全选</button>
</div>
<select id="quickServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-28"></select>
<p id="quickBatchHint" class="text-slate-600 text-xs mt-1"></p>
<select id="quickServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-28"></select>
<p id="quickBatchHint" class="text-[var(--text-dim)] text-xs mt-1"></p>
</div>
<div class="space-y-3">
<div>
<label class="block text-slate-400 text-xs mb-1">超时 (秒)</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">超时 (秒)</label>
<input id="quickTimeout" type="number" value="60" min="10" max="600"
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">DB 凭据(可选)</label>
<select id="quickCredential" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<label class="block text-[var(--text-secondary)] text-xs mb-1">DB 凭据(可选)</label>
<select id="quickCredential" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">不使用</option>
</select>
</div>
@@ -61,55 +61,55 @@
class="px-5 py-2.5 bg-brand hover:bg-brand-dark text-white text-sm font-medium rounded-lg transition disabled:opacity-50">
⚡ 执行
</button>
<button onclick="document.getElementById('quickCmd').value=''" class="px-3 py-2.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded-lg transition">
<button onclick="document.getElementById('quickCmd').value=''" class="px-3 py-2.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition">
清空
</button>
<span class="text-slate-600 text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
<span class="text-[var(--text-dim)] text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
</div>
</div>
</div>
<!-- ── 执行状态 ── -->
<div id="execStatusPanel" class="hidden bg-slate-900 rounded-xl border border-slate-800 p-4">
<div id="execStatusPanel" class="hidden bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<h2 class="text-white font-semibold text-sm mb-3">批量执行状态</h2>
<div id="execStatusList" class="space-y-2"></div>
</div>
<!-- ── 脚本库 ── -->
<div id="scriptList" class="space-y-3"><div class="text-slate-500 text-center py-8">加载中...</div></div>
<div id="scriptList" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-8">加载中...</div></div>
<!-- Add/Edit Script Form -->
<div id="scriptForm" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-3">
<div id="scriptForm" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-3">
<input type="hidden" id="editScriptId">
<h2 id="scriptFormTitle" class="text-white font-semibold">新建脚本</h2>
<input id="scriptName" placeholder="脚本名称" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
<input id="scriptCategory" placeholder="分类 (ops/deploy/check)" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm">
<textarea id="scriptContent" rows="10" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm font-mono"></textarea>
<div class="flex gap-2"><button onclick="saveScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideScriptForm()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
<input id="scriptName" placeholder="脚本名称" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm">
<input id="scriptCategory" placeholder="分类 (ops/deploy/check)" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm">
<textarea id="scriptContent" rows="10" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl text-white text-sm font-mono"></textarea>
<div class="flex gap-2"><button onclick="saveScript()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideScriptForm()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
<!-- Execute Modal -->
<div id="execModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">执行脚本</h2>
<p id="execScriptName" class="text-slate-400 text-sm"></p>
<p id="execScriptName" class="text-[var(--text-secondary)] text-sm"></p>
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-slate-400 text-xs">选择服务器</label>
<label class="block text-[var(--text-secondary)] text-xs">选择服务器</label>
<button type="button" onclick="selectAllExecServers()" class="text-brand-light text-xs hover:underline">全选</button>
</div>
<select id="execServers" multiple class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm h-32"></select>
<p id="execBatchHint" class="text-slate-500 text-xs mt-1"></p>
<select id="execServers" multiple class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm h-32"></select>
<p id="execBatchHint" class="text-[var(--text-muted)] text-xs mt-1"></p>
</div>
<div><label class="block text-slate-400 text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><p class="text-slate-500 text-xs mt-1">仅限制「点火」命令;长任务勾选后 60~120 秒即可</p></div>
<div><label class="block text-slate-400 text-xs mb-1">DB 凭据(可选,替换命令中的 $DB_*)</label><select id="execCredential" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">不使用</option></select></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">超时(秒)</label><input id="execTimeout" type="number" value="60" min="10" max="600" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><p class="text-[var(--text-muted)] text-xs mt-1">仅限制「点火」命令;长任务勾选后 60~120 秒即可</p></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">DB 凭据(可选,替换命令中的 $DB_*)</label><select id="execCredential" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="">不使用</option></select></div>
<label class="flex items-start gap-2 cursor-pointer select-none">
<input type="checkbox" id="execLongTask" class="mt-1 rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand" onchange="updateLongTaskHint()">
<span class="text-sm text-slate-300">长任务(后台 <code class="text-slate-400">nohup</code>,日志在子机 <code class="text-slate-400">/var/log/nexus-job/</code></span>
<span class="text-sm text-slate-300">长任务(后台 <code class="text-[var(--text-secondary)]">nohup</code>,日志在子机 <code class="text-[var(--text-secondary)]">/var/log/nexus-job/</code></span>
</label>
<p id="execLongHint" class="hidden text-amber-400/90 text-xs pl-6">主站 nohup 包装并注册任务;子机脚本结束后自动 curl 主站回传结果(需配置 NEXUS_API_BASE_URL</p>
<p class="text-slate-500 text-xs">提交后在本页「批量执行状态」查看进度,可关闭此窗口</p>
<div class="flex gap-2"><button onclick="doExec()" id="execBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
<p class="text-[var(--text-muted)] text-xs">提交后在本页「批量执行状态」查看进度,可关闭此窗口</p>
<div class="flex gap-2"><button onclick="doExec()" id="execBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">执行</button><button onclick="hideExecModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
</div>
</div>
</main>
@@ -413,24 +413,24 @@
const label=EXEC_STATUS_LABEL[st]||'执行中';
const cls=EXEC_STATUS_CLASS[st]||EXEC_STATUS_CLASS.running;
const ids=t.executionIds?.length?`#${t.executionIds.join(',#')}`:'';
const logBlock=t.logExpanded?`<pre class="mt-2 p-3 bg-slate-900 rounded-lg text-xs text-slate-300 font-mono max-h-72 overflow-auto whitespace-pre-wrap border border-slate-800">${esc(t.logText||'(空)')}</pre>`:'';
const logBlock=t.logExpanded?`<pre class="mt-2 p-3 bg-[var(--bg-card)] rounded-lg text-xs text-slate-300 font-mono max-h-72 overflow-auto whitespace-pre-wrap border border-[var(--border)]">${esc(t.logText||'(空)')}</pre>`:'';
const btnDis=t.logLoading||!t.executionIds?.length?'disabled':'';
const showStop=jobHasPending(t)&&!t.submitting;
const showRetry=jobCanRetry(t)&&!t.submitting&&!t.logLoading;
const showStuck=(t.data?.status==='running'||jobHasPending(t))&&!t.submitting;
return `<div class="rounded-lg bg-slate-950 border border-slate-800 p-3">
return `<div class="rounded-lg bg-[var(--bg-page)] border border-[var(--border)] p-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="text-white text-sm truncate">${esc(t.scriptName||'脚本')}</div>
<div class="text-slate-500 text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-slate-600">${esc(ids)}</span>`:''}</div>
<div class="text-[var(--text-muted)] text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-[var(--text-dim)]">${esc(ids)}</span>`:''}</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
${showStuck?`<button type="button" onclick="markStuckJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-600 text-slate-400 hover:bg-slate-800">标记卡住</button>`:''}
${showStuck?`<button type="button" onclick="markStuckJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-600 text-[var(--text-secondary)] hover:bg-[var(--bg-surface)]">标记卡住</button>`:''}
${showStop?`<button type="button" onclick="stopJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-red-500/50 text-red-300 hover:bg-red-500/10">停止</button>`:''}
${showRetry?`<button type="button" onclick="retryJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-amber-500/50 text-amber-200 hover:bg-amber-500/10">重试</button>`:''}
<button type="button" onclick="refreshJobStatusLogs('${t.id}',false)" ${btnDis} class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-40">刷新状态</button>
<button type="button" onclick="refreshJobStatusLogs('${t.id}',false)" ${btnDis} class="text-xs px-2 py-1 rounded border border-[var(--border-input)] text-slate-300 hover:bg-[var(--bg-surface)] disabled:opacity-40">刷新状态</button>
<button type="button" onclick="refreshJobStatusLogs('${t.id}',true)" ${btnDis} class="text-xs px-2 py-1 rounded border border-brand/50 text-brand-light hover:bg-brand/10 disabled:opacity-40">${t.logLoading?'拉取中…':'状态+日志'}</button>
${t.logText?`<button type="button" onclick="toggleJobLog('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-700 text-slate-400 hover:bg-slate-800">${t.logExpanded?'收起':'展开'}</button>`:''}
${t.logText?`<button type="button" onclick="toggleJobLog('${t.id}')" class="text-xs px-2 py-1 rounded border border-[var(--border-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-surface)]">${t.logExpanded?'收起':'展开'}</button>`:''}
<span class="text-xs px-2.5 py-1 rounded-full border ${cls}">${label}</span>
</div>
</div>${logBlock}
@@ -509,7 +509,7 @@
const t=document.getElementById('execTimeout');
if(on&&parseInt(t.value,10)<90)t.value='120';
}
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-slate-900 rounded-xl border border-slate-800 p-4"><div class="flex items-center justify-between"><span class="text-white font-medium cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-slate-800 text-slate-400">${esc(s.category||'ops')}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="showEditScript(${s.id})" class="text-slate-400 text-xs hover:underline">编辑</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-slate-400 font-mono bg-slate-950 rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-slate-500 text-center py-8">暂无脚本</div>'}catch(e){}}
async function loadScripts(){try{const r=await apiFetch(API+'/scripts/');if(!r)return;const scripts=await r.json();_scriptsCache=scripts;document.getElementById('scriptList').innerHTML=scripts.length?scripts.map(s=>`<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4"><div class="flex items-center justify-between"><span class="text-white font-medium cursor-pointer hover:text-brand-light transition" onclick="showEditScript(${s.id})">${esc(s.name)}</span><div class="flex items-center gap-2"><span class="text-xs px-2 py-0.5 rounded bg-[var(--bg-surface)] text-[var(--text-secondary)]">${esc(s.category||'ops')}</span><button onclick="showExec(${s.id})" class="text-brand-light text-xs hover:underline">执行</button><button onclick="showEditScript(${s.id})" class="text-[var(--text-secondary)] text-xs hover:underline">编辑</button><button onclick="deleteScript(${s.id})" class="text-red-400 text-xs hover:underline">删除</button></div></div><pre class="mt-2 text-sm text-[var(--text-secondary)] font-mono bg-[var(--bg-page)] rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-[var(--text-muted)] text-center py-8">暂无脚本</div>'}catch(e){}}
function showNewScript(){
document.getElementById('editScriptId').value='';
+102 -102
View File
@@ -1,45 +1,45 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 服务器</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style>
<style>
.detail-panel{max-height:0;overflow:hidden;transition:max-height .3s ease}
.detail-panel.open{max-height:800px}
</style>
</head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">服务器列表</h1></div>
<div class="flex items-center gap-2 flex-wrap">
<select id="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部分类</option>
</select>
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">全部状态</option>
<option value="online">在线</option>
<option value="offline">离线</option>
</select>
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button id="refreshToggle" onclick="toggleAutoRefresh()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
<button onclick="loadServers()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="exportServersCSV()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
<button onclick="showImportModal()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↓ 导入</button>
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" class="px-3 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button id="refreshToggle" onclick="toggleAutoRefresh()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
<button onclick="loadServers()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="exportServersCSV()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
<button onclick="showImportModal()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">↓ 导入</button>
<button onclick="showAddServer()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">+ 添加</button>
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition" title="快捷键">?</button>
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition" title="快捷键">?</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<div id="serversTable" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-slate-400 text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('status')">Agent 状态 <span id="sort-status" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('name')">名称 <span id="sort-name" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('domain')">地址 <span id="sort-domain" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('category')">分类 <span id="sort-category" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('agent_version')">Agent <span id="sort-agent_version" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('heartbeat')">最后心跳 <span id="sort-heartbeat" class="text-[10px]"></span></th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-slate-500">加载中...</td></tr></tbody></table>
<div id="serversTable" class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table class="w-full text-sm"><thead class="bg-[var(--bg-surface)]/50 text-[var(--text-secondary)] text-xs uppercase"><tr><th class="px-4 py-3 w-10"><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand"></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('status')">Agent 状态 <span id="sort-status" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('name')">名称 <span id="sort-name" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('domain')">地址 <span id="sort-domain" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('category')">分类 <span id="sort-category" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('agent_version')">Agent <span id="sort-agent_version" class="text-[10px]"></span></th><th class="text-left px-4 py-3 cursor-pointer select-none hover:text-brand-light" onclick="toggleSort('heartbeat')">最后心跳 <span id="sort-heartbeat" class="text-[10px]"></span></th><th class="text-right px-4 py-3">操作</th></tr></thead><tbody id="serversTbody" class="divide-y divide-slate-800"><tr><td colspan="8" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
</div>
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-slate-500">
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-[var(--text-muted)]">
<span id="serverCount">共 -- 台</span>
<div class="flex items-center gap-2">
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">首页</button>
<button onclick="goToPage(_currentPage-1)" id="prevBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40"></button>
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">首页</button>
<button onclick="goToPage(_currentPage-1)" id="prevBtn" class="px-3 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40"></button>
<span id="pageNumbers" class="flex gap-1"></span>
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40"></button>
<button onclick="goToPage(_totalPages)" id="lastBtn" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">末页</button>
<select id="perPageSelect" onchange="changePerPage(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded-lg text-white text-xs">
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40"></button>
<button onclick="goToPage(_totalPages)" id="lastBtn" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">末页</button>
<select id="perPageSelect" onchange="changePerPage(this.value)" class="px-2 py-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-xs">
<option value="50">50条/页</option>
<option value="100">100条/页</option>
<option value="200">200条/页</option>
@@ -47,7 +47,7 @@
</div>
</div>
<!-- Keyboard shortcut help -->
<div id="shortcutHelp" class="hidden fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-xl p-4 text-xs text-slate-300 space-y-1 z-50">
<div id="shortcutHelp" class="hidden fixed bottom-4 right-4 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-xl p-4 text-xs text-slate-300 space-y-1 z-50">
<div class="text-white font-medium mb-2">快捷键</div>
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">↑↓</kbd> / <kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">j k</kbd> 上下移动</div>
<div><kbd class="px-1.5 py-0.5 bg-slate-700 rounded text-[10px]">Esc</kbd> 关闭面板/弹窗</div>
@@ -58,7 +58,7 @@
<div id="batchBar" class="hidden mt-3 bg-brand/10 border border-brand/30 rounded-xl px-5 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-brand-light text-sm font-medium">已选择 <span id="selectedCount">0</span> 台服务器</span>
<button onclick="clearSelection()" class="text-slate-400 hover:text-white text-xs underline">取消选择</button>
<button onclick="clearSelection()" class="text-[var(--text-secondary)] hover:text-white text-xs underline">取消选择</button>
</div>
<div class="flex items-center gap-2">
<button onclick="batchCheck()" class="px-4 py-1.5 bg-emerald-600/80 hover:bg-emerald-600 text-white text-sm rounded-lg transition">健康检查</button>
@@ -72,156 +72,156 @@
</div>
<!-- Server Detail Panel -->
<div id="detailPanel" class="hidden mt-4 bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-slate-800 bg-slate-800/30">
<div id="detailPanel" class="hidden mt-4 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border)] bg-[var(--bg-surface)]/30">
<div class="flex items-center gap-3">
<span id="detailStatus" class="inline-block w-3 h-3 rounded-full"></span>
<h2 id="detailName" class="text-white font-semibold text-lg"></h2>
<span id="detailAddr" class="text-slate-400 text-sm"></span>
<span id="detailAddr" class="text-[var(--text-secondary)] text-sm"></span>
</div>
<div class="flex items-center gap-2">
<a id="detailSSHLink" href="#" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">SSH终端</a>
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
<button onclick="hideDetail()" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
<button onclick="hideDetail()" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-slate-800 px-6">
<div class="flex border-b border-[var(--border)] px-6">
<button onclick="showDetailTab('info')" id="tabInfo" class="px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition">系统信息</button>
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">同步日志</button>
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">SSH会话</button>
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition">Agent 安装</button>
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">同步日志</button>
<button onclick="showDetailTab('ssh')" id="tabSSH" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">SSH会话</button>
<button onclick="showDetailTab('agent')" id="tabAgent" class="px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition">Agent 安装</button>
</div>
<!-- Tab Content: System Info -->
<div id="tabContentInfo" class="p-6">
<div id="sysInfoGrid" class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
<div class="bg-slate-800/50 rounded-lg p-4"><div class="text-slate-500 text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-4"><div class="text-[var(--text-muted)] text-xs mb-1">内核平台</div><div id="siPlatform" class="text-sm font-medium text-slate-300 truncate">--</div></div>
</div>
<div id="sysInfoMeta" class="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
<div id="siDescRow"><span class="text-slate-500">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
<div id="siTargetRow"><span class="text-slate-500">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
<div id="siAuthRow"><span class="text-slate-500">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
<div id="siDescRow"><span class="text-[var(--text-muted)]">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
<div id="siTargetRow"><span class="text-[var(--text-muted)]">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
<div id="siAuthRow"><span class="text-[var(--text-muted)]">认证方式:</span><span id="siAuth" class="text-slate-300"></span></div>
</div>
</div>
<!-- Tab Content: Sync Logs -->
<div id="tabContentSync" class="hidden p-6">
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
<table class="w-full text-sm"><thead class="text-[var(--text-muted)] text-xs uppercase"><tr><th class="text-left py-2">模式</th><th class="text-left py-2">状态</th><th class="text-left py-2">源路径</th><th class="text-left py-2">耗时</th><th class="text-left py-2">时间</th></tr></thead><tbody id="syncLogTbody"><tr><td colspan="5" class="py-4 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
</div>
<!-- Tab Content: SSH Sessions -->
<div id="tabContentSSH" class="hidden p-6">
<table class="w-full text-sm"><thead class="text-slate-500 text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-slate-500">加载中...</td></tr></tbody></table>
<table class="w-full text-sm"><thead class="text-[var(--text-muted)] text-xs uppercase"><tr><th class="text-left py-2">来源IP</th><th class="text-left py-2">状态</th><th class="text-left py-2">开始</th><th class="text-left py-2">结束</th></tr></thead><tbody id="sshSessTbody"><tr><td colspan="4" class="py-4 text-center text-[var(--text-muted)]">加载中...</td></tr></tbody></table>
</div>
<!-- Tab Content: Agent Install -->
<div id="tabContentAgent" class="hidden p-6 space-y-4">
<div id="agentInstallContent"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<div id="agentInstallContent"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
</div>
</div>
<!-- Add/Edit Server Modal -->
<div id="serverModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50 overflow-y-auto py-8">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
<h2 id="modalTitle" class="text-white font-semibold text-lg">添加服务器</h2>
<input type="hidden" id="editServerId">
<!-- Basic -->
<div class="text-slate-500 text-xs uppercase tracking-wider mb-1">基本</div>
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mb-1">基本</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-slate-400 text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="192.168.1.10"></div>
</div>
<div class="grid grid-cols-3 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"></div>
<div><label class="block text-slate-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="password">密码</option><option value="key">密钥</option></select></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="password">密码</option><option value="key">密钥</option></select></div>
</div>
<!-- Password auth section -->
<div id="srvPasswordSection">
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">密码预设</label><select id="srvPreset" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
<div id="srvPasswordRow"><label class="block text-slate-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="SSH密码"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密码预设</label><select id="srvPreset" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
<div id="srvPasswordRow"><label class="block text-[var(--text-secondary)] text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="SSH密码"></div>
</div>
</div>
<!-- Key auth section -->
<div id="srvKeySection" class="hidden space-y-3">
<div class="grid grid-cols-2 gap-3">
<div><label class="block text-slate-400 text-xs mb-1">密钥预设</label><select id="srvKeyPreset" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
<div id="srvKeyPathRow"><label class="block text-slate-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">密钥预设</label><select id="srvKeyPreset" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
<div id="srvKeyPathRow"><label class="block text-[var(--text-secondary)] text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
</div>
<div id="srvKeyPrivateRow"><label class="block text-slate-400 text-xs mb-1">私钥内容</label><textarea id="srvKeyPrivate" rows="4" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(可选,优先于路径)"></textarea><p class="text-slate-600 text-[10px] mt-1">填入私钥内容后无需指定路径,系统自动加密存储</p></div>
<div id="srvKeyPrivateRow"><label class="block text-[var(--text-secondary)] text-xs mb-1">私钥内容</label><textarea id="srvKeyPrivate" rows="4" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(可选,优先于路径)"></textarea><p class="text-[var(--text-dim)] text-[10px] mt-1">填入私钥内容后无需指定路径,系统自动加密存储</p></div>
</div>
<!-- Agent -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
<div class="grid grid-cols-1 gap-3">
<input id="srvAgentPort" type="hidden" value="8601">
<div>
<label class="block text-slate-400 text-xs mb-1">Agent API Key</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">Agent API Key</label>
<!-- Add mode: auto-generate notice -->
<div id="agentKeyAddMode" class="px-3 py-2 bg-slate-800/50 border border-slate-700 rounded-lg text-slate-500 text-xs">创建时自动生成</div>
<div id="agentKeyAddMode" class="px-3 py-2 bg-[var(--bg-surface)]/50 border border-[var(--border-input)] rounded-lg text-[var(--text-muted)] text-xs">创建时自动生成</div>
<!-- Edit mode: existing key info + regenerate button -->
<div id="agentKeyEditMode" class="hidden">
<div class="flex items-center gap-2">
<input id="srvAgentKey" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局 API Key" readonly>
<input id="srvAgentKey" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局 API Key" readonly>
<button type="button" onclick="generateAgentKey()" class="px-2 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg shrink-0 transition" title="重新生成密钥">🔄</button>
</div>
<p class="text-slate-600 text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
<p class="text-[var(--text-dim)] text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
</div>
</div>
</div>
<!-- Organization -->
<div class="text-slate-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
<div class="grid grid-cols-3 gap-3">
<div>
<label class="block text-slate-400 text-xs mb-1">分类</label>
<select id="srvCategorySelect" onchange="_onCategoryChange()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<label class="block text-[var(--text-secondary)] text-xs mb-1">分类</label>
<select id="srvCategorySelect" onchange="_onCategoryChange()" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<option value="">— 无分类 —</option>
</select>
<input id="srvCategory" class="hidden w-full mt-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="输入自定义分类">
<input id="srvCategory" class="hidden w-full mt-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="输入自定义分类">
</div>
<div><label class="block text-slate-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="/www/wwwroot"></div>
<div><label class="block text-slate-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm" placeholder="可选"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="/www/wwwroot"></div>
<div><label class="block text-[var(--text-secondary)] text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm" placeholder="可选"></div>
</div>
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
<div class="flex gap-2 pt-2"><button onclick="saveServer()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button><button onclick="hideServerModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button></div>
</div>
</div>
<!-- CSV Import Modal -->
<div id="importModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">批量导入服务器</h2>
<div class="bg-slate-800/50 rounded-lg p-3 text-xs text-slate-400 space-y-1">
<div class="bg-[var(--bg-surface)]/50 rounded-lg p-3 text-xs text-[var(--text-secondary)] space-y-1">
<p>1. <a href="/app/servers_import_template.csv" download="servers_import_template.csv" class="text-brand-light underline">下载 CSV 模板</a></p>
<p>2. 按模板格式填写(<span class="text-slate-200">名称</span><span class="text-slate-200">地址</span>必填,其余选填)</p>
<p>3. 认证方式填 <span class="text-slate-200">password</span><span class="text-slate-200">key</span>,默认 password</p>
<p>4. 上传 CSV 文件,最多 500 行</p>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">选择 CSV 文件</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">选择 CSV 文件</label>
<input id="importFile" type="file" accept=".csv" class="w-full text-sm text-slate-300 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-brand file:text-white hover:file:bg-brand-dark">
</div>
<div id="importProgress" class="hidden"><div class="bg-slate-800 rounded-full h-2 overflow-hidden"><div id="importProgressBar" class="bg-brand h-full transition-all" style="width:0%"></div></div><p id="importStatus" class="text-slate-400 text-xs mt-1 text-center">上传中...</p></div>
<div id="importProgress" class="hidden"><div class="bg-[var(--bg-surface)] rounded-full h-2 overflow-hidden"><div id="importProgressBar" class="bg-brand h-full transition-all" style="width:0%"></div></div><p id="importStatus" class="text-[var(--text-secondary)] text-xs mt-1 text-center">上传中...</p></div>
<div id="importResult" class="hidden space-y-2">
<div class="grid grid-cols-3 gap-2 text-center">
<div class="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-3"><div id="importCreated" class="text-2xl font-bold text-emerald-400">0</div><div class="text-xs text-emerald-400/70">成功</div></div>
<div class="bg-amber-500/10 border border-amber-500/30 rounded-lg p-3"><div id="importSkipped" class="text-2xl font-bold text-amber-400">0</div><div class="text-xs text-amber-400/70">跳过</div></div>
<div class="bg-red-500/10 border border-red-500/30 rounded-lg p-3"><div id="importFailed" class="text-2xl font-bold text-red-400">0</div><div class="text-xs text-red-400/70">失败</div></div>
</div>
<div id="importErrors" class="hidden max-h-40 overflow-y-auto bg-slate-800 rounded-lg p-3 text-xs text-red-400 space-y-1"></div>
<div id="importErrors" class="hidden max-h-40 overflow-y-auto bg-[var(--bg-surface)] rounded-lg p-3 text-xs text-red-400 space-y-1"></div>
</div>
<div class="flex gap-2 pt-2">
<button id="importBtn" onclick="doImport()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">开始导入</button>
<button onclick="hideImportModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button>
<button onclick="hideImportModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button>
</div>
</div>
</div>
<!-- Batch Agent Result Modal -->
<div id="batchAgentModal" class="hidden fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div class="bg-slate-900 border border-slate-700 rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 id="batchAgentTitle" class="text-white font-semibold text-lg">批量操作结果</h2>
<div id="batchAgentProgress" class="hidden text-center py-4"><div class="animate-spin inline-block w-8 h-8 border-4 border-brand border-t-transparent rounded-full"></div><p class="text-slate-400 text-sm mt-2">执行中,请等待...</p></div>
<div id="batchAgentProgress" class="hidden text-center py-4"><div class="animate-spin inline-block w-8 h-8 border-4 border-brand border-t-transparent rounded-full"></div><p class="text-[var(--text-secondary)] text-sm mt-2">执行中,请等待...</p></div>
<div id="batchAgentResult" class="hidden space-y-2">
<div class="grid grid-cols-2 gap-2 text-center">
<div class="bg-emerald-500/10 border border-emerald-500/30 rounded-lg p-3"><div id="batchAgentOk" class="text-2xl font-bold text-emerald-400">0</div><div class="text-xs text-emerald-400/70">成功</div></div>
@@ -229,7 +229,7 @@
</div>
<div id="batchAgentDetails" class="max-h-60 overflow-y-auto space-y-1"></div>
</div>
<div class="flex gap-2 pt-2"><button onclick="hideBatchAgentModal()" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
<div class="flex gap-2 pt-2"><button onclick="hideBatchAgentModal()" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
</div>
</div>
@@ -332,14 +332,14 @@
const statusHtml=renderStatusBadge(s);
const driftHtml=driftBadge(s);
const actionsHtml=renderActions(s);
return `<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
return `<tr class="hover:bg-[var(--bg-surface)]/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
<td class="px-4 py-3">${statusHtml}</td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftHtml}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.agent_version)||'--'}</td>
<td class="px-4 py-3 text-slate-500 text-xs">${fmtTime(s.last_heartbeat)}</td>
<td class="px-4 py-3 text-[var(--text-secondary)]">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-[var(--text-secondary)]">${esc(s.category||'--')}</td>
<td class="px-4 py-3 text-[var(--text-secondary)]">${esc(s.agent_version)||'--'}</td>
<td class="px-4 py-3 text-[var(--text-muted)] text-xs">${fmtTime(s.last_heartbeat)}</td>
<td class="px-4 py-3 text-right">${actionsHtml}</td>
</tr>`;
}
@@ -354,14 +354,14 @@
if(s.agent_version&&!s.is_online){
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-red-400 animate-pulse"></span><span class="text-red-400">离线</span></span>`;
}
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-slate-500">未安装</span></span>`;
return `<span class="inline-flex items-center gap-1.5 text-xs"><span class="inline-block w-2 h-2 rounded-full bg-slate-600"></span><span class="text-[var(--text-muted)]">未安装</span></span>`;
}
function renderActions(s){
let html=`<a href="/app/terminal.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-brand-light hover:underline text-xs mr-2">终端</a>`;
html+=`<a href="/app/files.html?server_id=${s.id}" onclick="event.stopPropagation()" class="text-amber-400 hover:underline text-xs mr-2">文件</a>`;
if(s.agent_version) html+=`<button onclick="event.stopPropagation();_quickCheck(${s.id})" class="text-emerald-400 hover:underline text-xs mr-2">检查</button>`;
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-slate-400 hover:underline text-xs mr-2">编辑</button>`;
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-[var(--text-secondary)] hover:underline text-xs mr-2">编辑</button>`;
html+=`<button onclick="event.stopPropagation();deleteServer(${s.id})" class="text-red-400 hover:underline text-xs">删除</button>`;
return html;
}
@@ -374,9 +374,9 @@
function renderEmptyState(){
const hasFilters=document.getElementById('searchInput').value||document.getElementById('categoryFilter').value||_statusFilter;
if(hasFilters){
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🔍</div><div class="text-slate-400 mb-1">没有匹配的服务器</div><div class="text-slate-600 text-xs">尝试调整搜索条件或筛选项</div></td></tr>`;
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-[var(--text-muted)] text-4xl mb-3">🔍</div><div class="text-[var(--text-secondary)] mb-1">没有匹配的服务器</div><div class="text-[var(--text-dim)] text-xs">尝试调整搜索条件或筛选项</div></td></tr>`;
}
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-slate-500 text-4xl mb-3">🖥</div><div class="text-slate-400 mb-1">暂无服务器</div><div class="text-slate-600 text-xs">点击右上角「+ 添加」或「↓ 导入」开始</div></td></tr>`;
return `<tr><td colspan="8" class="px-4 py-16 text-center"><div class="text-[var(--text-muted)] text-4xl mb-3">🖥</div><div class="text-[var(--text-secondary)] mb-1">暂无服务器</div><div class="text-[var(--text-dim)] text-xs">点击右上角「+ 添加」或「↓ 导入」开始</div></td></tr>`;
}
// ── Render: Pagination ──
@@ -404,7 +404,7 @@
nums.innerHTML='';
for(let i=start;i<=end;i++){
const active=i===_currentPage;
nums.innerHTML+=`<button onclick="goToPage(${i})" class="px-2.5 py-1 text-xs rounded-lg transition ${active?'bg-brand text-white':'bg-slate-800 hover:bg-slate-700 text-slate-300'}">${i}</button>`;
nums.innerHTML+=`<button onclick="goToPage(${i})" class="px-2.5 py-1 text-xs rounded-lg transition ${active?'bg-brand text-white':'bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300'}">${i}</button>`;
}
}
}
@@ -472,7 +472,7 @@
btn.className='px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded-lg transition';
btn.textContent='自动刷新中';
}else{
btn.className='px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-sm rounded-lg transition';
btn.className='px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition';
btn.textContent='自动刷新';
}
}
@@ -604,10 +604,10 @@
const r=await apiFetch(API+'/servers/'+selectedServerId+'/logs?limit=20');
if(!r)return;const logs=await r.json();
const tbody=document.getElementById('syncLogTbody');
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-slate-500">暂无同步记录</td></tr>';return}
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-[var(--text-muted)]">暂无同步记录</td></tr>';return}
tbody.innerHTML=logs.map(l=>{
const sc=l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-slate-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-slate-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
return `<tr class="border-b border-[var(--border)]/50"><td class="py-2 text-slate-300">${esc(l.sync_mode||'--')}</td><td class="py-2 ${sc}">${esc(l.status)}</td><td class="py-2 text-[var(--text-muted)] text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-[var(--text-muted)]">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-[var(--text-dim)] text-xs">${fmtTime(l.started_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('syncLogTbody').innerHTML='<tr><td colspan="5" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
@@ -617,10 +617,10 @@
const r=await apiFetch(API+'/assets/ssh-sessions?server_id='+selectedServerId+'&limit=20');
if(!r)return;const sessions=await r.json();
const tbody=document.getElementById('sshSessTbody');
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-slate-500">暂无SSH会话</td></tr>';return}
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-[var(--text-muted)]">暂无SSH会话</td></tr>';return}
tbody.innerHTML=sessions.map(s=>{
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-slate-500':'text-yellow-400';
return `<tr class="border-b border-slate-800/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-slate-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-slate-600 text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-[var(--text-muted)]':'text-yellow-400';
return `<tr class="border-b border-[var(--border)]/50"><td class="py-2 text-slate-300">${esc(s.remote_addr||'--')}</td><td class="py-2 ${sc}">${esc(s.status)}</td><td class="py-2 text-[var(--text-muted)] text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-[var(--text-dim)] text-xs">${fmtTime(s.closed_at)}</td></tr>`}).join('');
}catch(e){document.getElementById('sshSessTbody').innerHTML='<tr><td colspan="4" class="py-4 text-center text-red-400">加载失败</td></tr>'}
}
@@ -631,7 +631,7 @@
const btn=document.getElementById('tab'+t);
if(!btn)return;
if(t.toLowerCase()===tab){btn.className='px-4 py-3 text-sm border-b-2 border-brand text-brand-light transition'}
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-slate-400 hover:text-white transition'}
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-[var(--text-secondary)] hover:text-white transition'}
});
if(tab==='sync')loadSyncLogs();
if(tab==='ssh')loadSSHSessions();
@@ -645,7 +645,7 @@
const s=_serversCache[selectedServerId]||{};
const statusBadge=s.is_online
?'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30">● Agent 在线</span>'
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500 border border-slate-700">○ Agent 离线 / 未安装</span>';
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)] border border-[var(--border-input)]">○ Agent 离线 / 未安装</span>';
const base_url_conf=!!(_apiBaseUrl);
const noKey=!s.agent_api_key_set;
let warnings='';
@@ -653,16 +653,16 @@
if(noKey)warnings+=`<div class="text-amber-400 text-xs">⚠ 尚未生成 Agent API Key,请点击「编辑」→「🔄 重新生成」后再安装</div>`;
const canShowCmd=base_url_conf&&!noKey;
const cmdBlock=_cachedInstallCmd
?`<div class="space-y-2"><label class="block text-slate-400 text-xs">在子机上执行(需 root 权限)</label><div class="flex items-start gap-2"><code id="agentCmdText" class="flex-1 block px-3 py-3 bg-slate-950 border border-slate-700 rounded-lg text-green-400 text-xs font-mono break-all leading-relaxed">${esc(_cachedInstallCmd)}</code><button onclick="copyAgentCmd()" class="shrink-0 px-3 py-3 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg transition" title="复制命令">📋</button></div><p class="text-slate-600 text-xs">脚本将自动安装 Agent;心跳走 HTTPS 出站,无需公网放行 ${esc(String(s.agent_port||8601))}</p></div>`
?`<div class="space-y-2"><label class="block text-[var(--text-secondary)] text-xs">在子机上执行(需 root 权限)</label><div class="flex items-start gap-2"><code id="agentCmdText" class="flex-1 block px-3 py-3 bg-[var(--bg-page)] border border-[var(--border-input)] rounded-lg text-green-400 text-xs font-mono break-all leading-relaxed">${esc(_cachedInstallCmd)}</code><button onclick="copyAgentCmd()" class="shrink-0 px-3 py-3 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg transition" title="复制命令">📋</button></div><p class="text-[var(--text-dim)] text-xs">脚本将自动安装 Agent;心跳走 HTTPS 出站,无需公网放行 ${esc(String(s.agent_port||8601))}</p></div>`
:canShowCmd
?`<button onclick="revealInstallCmd()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">🔐 查看安装命令(需验证密码)</button>`
:`<div class="text-slate-500 text-sm">(请先配置 URL 和 API Key</div>`;
:`<div class="text-[var(--text-muted)] text-sm">(请先配置 URL 和 API Key</div>`;
const remoteBtn=canShowCmd&&!s.is_online
?`<button onclick="remoteInstallAgent()" id="remoteInstallBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">⚡ 一键远程安装(通过 SSH</button>`
:(s.is_online
?`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装(已在线)</button>`
:`<button disabled class="px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装</button>`);
el.innerHTML=`<div class="flex items-center justify-between"><h3 class="text-white font-medium">Nexus Agent 安装</h3>${statusBadge}</div>${warnings}<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-4">${cmdBlock}</div><div class="flex items-center gap-3">${remoteBtn}${s.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn" class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">⬆ 升级 Agent</button><button onclick="uninstallAgent()" id="uninstallAgentBtn" class="px-3 py-2 bg-red-900/60 hover:bg-red-800 text-white text-sm rounded-lg transition">🗑 卸载 Agent</button>`:''}<span class="text-slate-500 text-xs">安装约 30-60 秒;首次心跳约 60 秒后自动更新状态</span></div><div id="remoteInstallLog" class="hidden rounded-lg border border-slate-700 bg-slate-950 p-3 max-h-56 overflow-y-auto"><pre id="remoteInstallLogText" class="text-xs text-slate-400 whitespace-pre-wrap"></pre></div>${!s.is_online?`<div class="flex items-center gap-2 text-xs text-slate-500"><span>手动安装后,</span><button onclick="_startAgentOnlinePoller(${selectedServerId});this.textContent='等待中...';this.disabled=true" class="text-brand-light hover:underline">点击等待 Agent 上线</button><span>(最多 2 分钟)</span></div>`:''}`;
?`<button disabled class="px-4 py-2 bg-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed">⚡ 远程安装(已在线)</button>`
:`<button disabled class="px-4 py-2 bg-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed">⚡ 远程安装</button>`);
el.innerHTML=`<div class="flex items-center justify-between"><h3 class="text-white font-medium">Nexus Agent 安装</h3>${statusBadge}</div>${warnings}<div class="rounded-xl border border-[var(--border-input)] bg-[var(--bg-surface)]/30 p-4 space-y-4">${cmdBlock}</div><div class="flex items-center gap-3">${remoteBtn}${s.is_online?`<button onclick="upgradeAgent()" id="upgradeAgentBtn" class="px-3 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">⬆ 升级 Agent</button><button onclick="uninstallAgent()" id="uninstallAgentBtn" class="px-3 py-2 bg-red-900/60 hover:bg-red-800 text-white text-sm rounded-lg transition">🗑 卸载 Agent</button>`:''}<span class="text-[var(--text-muted)] text-xs">安装约 30-60 秒;首次心跳约 60 秒后自动更新状态</span></div><div id="remoteInstallLog" class="hidden rounded-lg border border-[var(--border-input)] bg-[var(--bg-page)] p-3 max-h-56 overflow-y-auto"><pre id="remoteInstallLogText" class="text-xs text-[var(--text-secondary)] whitespace-pre-wrap"></pre></div>${!s.is_online?`<div class="flex items-center gap-2 text-xs text-[var(--text-muted)]"><span>手动安装后,</span><button onclick="_startAgentOnlinePoller(${selectedServerId});this.textContent='等待中...';this.disabled=true" class="text-brand-light hover:underline">点击等待 Agent 上线</button><span>(最多 2 分钟)</span></div>`:''}`;
}
async function revealInstallCmd(){
@@ -751,7 +751,7 @@
badge.className='inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-500/30';
badge.textContent='● Agent 在线';
const btn=document.getElementById('remoteInstallBtn');
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-slate-800 text-slate-500 text-sm rounded-lg cursor-not-allowed'}
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed'}
}
}
@@ -1156,8 +1156,8 @@
document.getElementById('batchAgentFail').textContent=fail;
const details=document.getElementById('batchAgentDetails');
details.innerHTML=results.map(r=>{
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span></span><span>${esc(r.server_name)}</span>${r.stdout?`<span class="text-slate-400">${esc(r.stdout)}</span>`:''}</div>`;
return `<div class="flex items-center gap-2 text-xs text-red-400"><span></span><span>${esc(r.server_name)}</span><span class="text-slate-500 truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
if(r.success)return `<div class="flex items-center gap-2 text-xs text-emerald-400"><span></span><span>${esc(r.server_name)}</span>${r.stdout?`<span class="text-[var(--text-secondary)]">${esc(r.stdout)}</span>`:''}</div>`;
return `<div class="flex items-center gap-2 text-xs text-red-400"><span></span><span>${esc(r.server_name)}</span><span class="text-[var(--text-muted)] truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
}).join('');
}
+100 -100
View File
@@ -1,28 +1,28 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 系统设置</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-[var(--text-secondary)] hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">系统设置</h1></div>
</header>
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="text-slate-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div class="text-[var(--text-secondary)] text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
</div>
<!-- ── Account Security ── -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
<h2 class="text-white font-medium mb-4">账户安全</h2>
<!-- TOTP 2FA — 完整管理区块 -->
<div class="mb-5 pb-5 border-b border-slate-800 space-y-4">
<div class="mb-5 pb-5 border-b border-[var(--border)] space-y-4">
<!-- 状态标题 -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-white text-sm font-medium">Google Authenticator 双因素认证</span>
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">检测中...</span>
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)]">检测中...</span>
</div>
</div>
@@ -36,7 +36,7 @@
</div>
</div>
<div class="flex gap-2 mt-3">
<button onclick="startTotpSetup(true)" class="px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition">🔄 重新绑定(换设备/换 App</button>
<button onclick="startTotpSetup(true)" class="px-3 py-1.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-xs rounded-lg transition">🔄 重新绑定(换设备/换 App</button>
<button onclick="disableTotp()" class="px-3 py-1.5 bg-red-900/40 hover:bg-red-900/70 text-red-400 text-xs rounded-lg transition">禁用 TOTP</button>
</div>
</div>
@@ -57,11 +57,11 @@
<div id="totpBindPanel" class="hidden rounded-xl border border-brand/30 bg-brand/5 p-4 space-y-4">
<div class="flex items-center justify-between">
<span id="totpBindTitle" class="text-white text-sm font-semibold">绑定 Google Authenticator</span>
<button onclick="cancelTotpSetup()" class="text-slate-500 hover:text-white text-xs transition">✕ 取消</button>
<button onclick="cancelTotpSetup()" class="text-[var(--text-muted)] hover:text-white text-xs transition">✕ 取消</button>
</div>
<!-- 步骤说明 -->
<div class="text-slate-400 text-xs space-y-1">
<div class="text-[var(--text-secondary)] text-xs space-y-1">
<p>① 打开手机上的 <strong class="text-slate-300">Google Authenticator</strong>(或 Aegis、Microsoft Authenticator 等兼容 App</p>
<p>② 扫描下方二维码,或手动输入密钥</p>
<p>③ 输入 App 显示的 6 位数字确认绑定</p>
@@ -69,30 +69,30 @@
<!-- QR + 密钥 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
<div class="bg-slate-900 rounded-lg p-4 text-center border border-slate-700">
<p class="text-slate-500 text-xs mb-3">扫描二维码</p>
<div class="bg-[var(--bg-card)] rounded-lg p-4 text-center border border-[var(--border-input)]">
<p class="text-[var(--text-muted)] text-xs mb-3">扫描二维码</p>
<canvas id="totpQrCanvas" class="mx-auto rounded" width="180" height="180"></canvas>
<p id="totpQrFallback" class="hidden text-slate-500 text-xs mt-2">二维码加载失败,请手动输入密钥</p>
<p id="totpQrFallback" class="hidden text-[var(--text-muted)] text-xs mt-2">二维码加载失败,请手动输入密钥</p>
</div>
<div class="space-y-3">
<div>
<label class="block text-slate-400 text-xs mb-1">手动输入密钥</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">手动输入密钥</label>
<div class="flex items-center gap-2">
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all leading-relaxed"></code>
<button onclick="copyTotpSecret()" class="px-2 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono select-all break-all leading-relaxed"></code>
<button onclick="copyTotpSecret()" class="px-2 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
</div>
<p class="text-slate-600 text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
<p class="text-[var(--text-dim)] text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
</div>
<div>
<label class="block text-slate-400 text-xs mb-1">输入 App 中的验证码</label>
<label class="block text-[var(--text-secondary)] text-xs mb-1">输入 App 中的验证码</label>
<div class="flex items-center gap-2">
<input id="totpVerifyCode" maxlength="6" inputmode="numeric" pattern="\d{6}" placeholder="000000"
class="w-28 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
class="w-28 px-3 py-2 bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
onkeydown="if(event.key==='Enter')verifyAndEnableTotp()">
<button onclick="verifyAndEnableTotp()" class="flex-1 px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition font-medium">确认绑定</button>
</div>
</div>
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-slate-500 hover:text-slate-300 transition">🔄 重新生成二维码</button>
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-[var(--text-muted)] hover:text-slate-300 transition">🔄 重新生成二维码</button>
</div>
</div>
</div>
@@ -102,47 +102,47 @@
<!-- Change Password -->
<div>
<span class="text-white text-sm font-medium">修改密码</span>
<p class="text-slate-400 text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
<p class="text-[var(--text-secondary)] text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
<div class="space-y-2 max-w-sm">
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="totpCodeForPassword" type="text" inputmode="numeric" maxlength="6" placeholder="TOTP 验证码 (已启用2FA时必填)" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-brand">
<input id="currentPassword" type="password" placeholder="当前密码" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="newPassword" type="password" placeholder="新密码 (至少6位)" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="confirmPassword" type="password" placeholder="确认新密码" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<input id="totpCodeForPassword" type="text" inputmode="numeric" maxlength="6" placeholder="TOTP 验证码 (已启用2FA时必填)" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono tracking-widest focus:outline-none focus:ring-2 focus:ring-brand">
<button onclick="changePassword()" id="changePwBtn" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">修改密码</button>
</div>
</div>
</div>
<!-- Brand Settings -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
<h2 class="text-white font-medium mb-4">品牌</h2>
<div id="brandSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<div id="brandSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- API Key (read-only with copy) -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
<h2 class="text-white font-medium mb-4">API 密钥</h2>
<div id="apiKeySection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<div id="apiKeySection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Alert Thresholds -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
<div id="alertSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<div id="alertSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Infrastructure -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
<h2 class="text-white font-medium mb-4">基础设施</h2>
<div id="infraSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<div id="infraSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Telegram -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<h2 class="text-white font-medium">Telegram 通知</h2>
<div id="telegramSection" class="space-y-3"><div class="text-slate-500 text-center py-4 text-sm">加载中...</div></div>
<div id="telegramSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<!-- Test + Chat ID detection -->
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-800">
<div class="flex flex-wrap items-center gap-2 pt-1 border-t border-[var(--border)]">
<button onclick="telegramTest()" id="telegramTestBtn"
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
📨 发送测试消息
@@ -151,22 +151,22 @@
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
🔍 检测 Chat ID
</button>
<span class="text-slate-600 text-xs">检测前请先向 Bot 发一条消息</span>
<span class="text-[var(--text-dim)] text-xs">检测前请先向 Bot 发一条消息</span>
</div>
<!-- Chat list -->
<div id="telegramChatList" class="hidden space-y-2"></div>
</div>
<!-- Login IP Allowlist -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-white font-medium">登录 IP 白名单</h2>
<p class="text-slate-500 text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
<p class="text-[var(--text-muted)] text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
</div>
<!-- Master toggle -->
<div class="flex items-center gap-3">
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500">加载中</span>
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)]">加载中</span>
<button id="allowlistToggleBtn" onclick="toggleAllowlist()"
class="w-12 h-6 rounded-full transition-colors bg-slate-700 relative"
data-enabled="false" title="点击启用/关闭白名单">
@@ -176,7 +176,7 @@
</div>
<!-- Disabled notice -->
<div id="allowlistDisabledNotice" class="rounded-lg border border-slate-700 bg-slate-800/40 px-4 py-3 text-slate-400 text-xs">
<div id="allowlistDisabledNotice" class="rounded-lg border border-[var(--border-input)] bg-slate-800/40 px-4 py-3 text-[var(--text-secondary)] text-xs">
⚪ 白名单未启用 — 任何 IP 均可登录。开启后将仅允许下方白名单 IP。
</div>
<div id="allowlistEnabledNotice" class="hidden rounded-lg border border-green-500/30 bg-green-900/10 px-4 py-3 text-green-300 text-xs">
@@ -184,14 +184,14 @@
</div>
<!-- 订阅 URL(持久存储,每 2 小时自动刷新) -->
<div class="rounded-xl border border-slate-700 bg-slate-800/30 p-4 space-y-3">
<div class="rounded-xl border border-[var(--border-input)] bg-slate-800/30 p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-slate-300 text-sm font-medium">代理订阅地址(自动刷新)</label>
<span id="lastRefreshTime" class="text-slate-600 text-xs"></span>
<span id="lastRefreshTime" class="text-[var(--text-dim)] text-xs"></span>
</div>
<div class="flex gap-2">
<input id="subUrl" type="url" placeholder="https://your-subscription-url..."
class="flex-1 px-3 py-2 bg-slate-900 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
class="flex-1 px-3 py-2 bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-brand">
<button onclick="saveSubscriptionUrl()" id="saveSubBtn"
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition shrink-0">
保存
@@ -201,7 +201,7 @@
🔄
</button>
</div>
<p class="text-slate-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
<p class="text-[var(--text-dim)] text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
</div>
<!-- 解析结果预览 -->
@@ -211,20 +211,20 @@
class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-xs rounded-lg transition">
🔍 预览当前订阅节点 IP
</button>
<span class="text-slate-600 text-xs">(不更改白名单,仅查看)</span>
<span class="text-[var(--text-dim)] text-xs">(不更改白名单,仅查看)</span>
</div>
</div>
<!-- 解析结果 (待确认) -->
<div id="parsedIpsSection" class="hidden space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
<label class="text-[var(--text-secondary)] text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
<div class="flex gap-2">
<button onclick="selectAllParsed(true)" class="text-brand-light text-xs hover:underline">全选</button>
<button onclick="selectAllParsed(false)" class="text-slate-500 text-xs hover:underline">全不选</button>
<button onclick="selectAllParsed(false)" class="text-[var(--text-muted)] text-xs hover:underline">全不选</button>
</div>
</div>
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-slate-700 bg-slate-950 p-2 space-y-1"></div>
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-[var(--border-input)] bg-[var(--bg-page)] p-2 space-y-1"></div>
<button onclick="addSelectedToWhitelist()"
class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">
➕ 添加到白名单
@@ -233,9 +233,9 @@
<!-- 手动输入 -->
<div class="space-y-2">
<label class="block text-slate-400 text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
<label class="block text-[var(--text-secondary)] text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
<textarea id="manualIps" rows="3" placeholder="1.2.3.4&#10;10.0.0.0/8&#10;proxy.example.com"
class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono resize-y placeholder-slate-700"></textarea>
class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono resize-y placeholder-slate-700"></textarea>
<button onclick="addManualIps()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">
添加
</button>
@@ -244,32 +244,32 @@
<!-- 订阅节点 IP(只读,自动刷新) -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">订阅节点 IP <span class="text-slate-600">(只读,每 2 小时自动替换)</span></label>
<span id="subIpCount" class="text-slate-600 text-xs"></span>
<label class="text-[var(--text-secondary)] text-xs">订阅节点 IP <span class="text-[var(--text-dim)]">(只读,每 2 小时自动替换)</span></label>
<span id="subIpCount" class="text-[var(--text-dim)] text-xs"></span>
</div>
<div id="subscriptionIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
<div id="subscriptionIpsList" class="min-h-8 text-[var(--text-muted)] text-xs">加载中...</div>
</div>
<!-- 手动 IP -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-slate-400 text-xs">手动添加的 IP <span class="text-slate-600">(永久保留)</span></label>
<label class="text-[var(--text-secondary)] text-xs">手动添加的 IP <span class="text-[var(--text-dim)]">(永久保留)</span></label>
<button onclick="clearManualIps()" class="text-red-400 text-xs hover:underline">清空手动 IP</button>
</div>
<div id="manualIpsList" class="min-h-8 text-slate-500 text-xs">加载中...</div>
<div id="manualIpsList" class="min-h-8 text-[var(--text-muted)] text-xs">加载中...</div>
</div>
</div>
<!-- Notification toggles -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6">
<div class="flex items-center justify-between mb-1">
<h2 class="text-white font-medium">告警通知项目</h2>
<span class="text-slate-500 text-xs">通过 Telegram 推送,可逐项关闭</span>
<span class="text-[var(--text-muted)] text-xs">通过 Telegram 推送,可逐项关闭</span>
</div>
<p class="text-slate-500 text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
<p class="text-[var(--text-muted)] text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
<div class="space-y-1" id="notifyToggles">
<div class="text-slate-500 text-center py-4 text-sm">加载中...</div>
<div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div>
</div>
</div>
@@ -307,14 +307,14 @@
const isMasked=val.includes('***')||val.includes('...');
if(isMasked){
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm select-none">${esc(val)}</span>
<span class="text-slate-600 text-xs">🔒 安全锁定</span>
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<span class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm select-none">${esc(val)}</span>
<span class="text-[var(--text-dim)] text-xs">🔒 安全锁定</span>
</div>`;
}
return `<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" type="${INPUT_TYPES[key]||'text'}" ${INPUT_TYPES[key]==='number'?'min="1"':''} class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<input id="set_${key}" value="${esc(val)}" type="${INPUT_TYPES[key]||'text'}" ${INPUT_TYPES[key]==='number'?'min="1"':''} class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<button type="button" data-setting-key="${escAttr(key)}" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</div>`;
}).join('');
@@ -339,21 +339,21 @@
if(isMasked){
el.innerHTML=`
<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(val)}</span>
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
<span class="text-slate-600 text-xs">🔒 不可修改</span>
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">API_KEY</label>
<span id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm font-mono select-none">${esc(val)}</span>
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<button onclick="copyApiKey()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
<span class="text-[var(--text-dim)] text-xs">🔒 不可修改</span>
</div>
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
<p class="text-[var(--text-muted)] text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
}else{
el.innerHTML=`
<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">API_KEY</label>
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
<button onclick="copyApiKey()" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">API_KEY</label>
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono select-all break-all">${esc(val)}</code>
<button onclick="copyApiKey()" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
</div>
<p class="text-slate-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
<p class="text-[var(--text-muted)] text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
}
}
@@ -364,18 +364,18 @@
const el=document.getElementById('telegramSection');
el.innerHTML=`
<div class="flex items-center gap-3" id="telegramTokenRow">
<label class="text-slate-400 text-sm w-36 shrink-0">Bot Token</label>
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Bot Token</label>
${tokenMasked
?`<span id="tgTokenDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none">${esc(tokenVal)}</span>
<button onclick="revealTelegramToken()" id="tgTokenRevealBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>`
:`<input id="set_telegram_bot_token" value="${esc(tokenVal)}" type="password" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono">
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
?`<span id="tgTokenDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm font-mono select-none">${esc(tokenVal)}</span>
<button onclick="revealTelegramToken()" id="tgTokenRevealBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>`
:`<input id="set_telegram_bot_token" value="${esc(tokenVal)}" type="password" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono">
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<button type="button" data-setting-key="telegram_bot_token" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>`
}
</div>
<div class="flex items-center gap-3">
<label class="text-slate-400 text-sm w-36 shrink-0">Chat ID</label>
<input id="set_telegram_chat_id" value="${esc(chatVal)}" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Chat ID</label>
<input id="set_telegram_chat_id" value="${esc(chatVal)}" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<button type="button" data-setting-key="telegram_chat_id" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>
</div>`;
}
@@ -395,9 +395,9 @@
// Replace masked display with editable input
const row=document.getElementById('telegramTokenRow');
row.innerHTML=`
<label class="text-slate-400 text-sm w-36 shrink-0">Bot Token</label>
<input id="set_telegram_bot_token" value="${esc(realToken)}" type="password" class="flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono">
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-slate-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Bot Token</label>
<input id="set_telegram_bot_token" value="${esc(realToken)}" type="password" class="flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono">
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<button type="button" data-setting-key="telegram_bot_token" class="save-setting-btn px-3 py-2 bg-brand hover:bg-brand-dark text-white text-xs rounded-lg shrink-0 transition">保存</button>`;
_allSettings['telegram_bot_token']=realToken;
toast('success','Bot Token 已显示');
@@ -433,12 +433,12 @@
const data=await r.json();
_apiKeyRaw=data.value||'';
display.textContent=_apiKeyRaw||'(空)';
display.className='flex-1 px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-green-400 text-sm font-mono select-all break-all';
display.className='flex-1 px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-green-400 text-sm font-mono select-all break-all';
btn.textContent='隐藏';
}catch(e){console.error(e);toast('error','密码验证失败')}
}else{
display.textContent=_allSettings['api_key']||'';
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-slate-500 text-sm font-mono select-none';
display.className='flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-[var(--text-muted)] text-sm font-mono select-none';
btn.textContent='显示';
_apiKeyRaw='';
}
@@ -691,11 +691,11 @@
listEl.classList.add('hidden');return;
}
listEl.classList.remove('hidden');
listEl.innerHTML='<p class="text-slate-400 text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-slate-800 rounded-lg">
listEl.innerHTML='<p class="text-[var(--text-secondary)] text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-[var(--bg-surface)] rounded-lg">
<div class="flex-1">
<span class="text-white text-sm font-medium">${esc(c.title||c.username||'私聊')}</span>
<span class="ml-2 text-slate-500 text-xs">${esc(c.type)}</span>
<span class="ml-2 text-[var(--text-muted)] text-xs">${esc(c.type)}</span>
<code class="ml-2 text-brand-light text-xs font-mono">${esc(String(c.id))}</code>
</div>
<button onclick="fillChatId('${escAttr(String(c.id))}')"
@@ -741,13 +741,13 @@
let groupHeader='';
if(item.group!==lastGroup){
lastGroup=item.group;
groupHeader=`<div class="text-slate-500 text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-slate-800 first:border-0">${esc(item.group)}</div>`;
groupHeader=`<div class="text-[var(--text-muted)] text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-[var(--border)] first:border-0">${esc(item.group)}</div>`;
}
return groupHeader+`
<div class="flex items-center justify-between py-2.5 px-3 rounded-lg hover:bg-slate-800/50 transition group">
<div class="flex-1 min-w-0">
<span class="text-sm text-slate-200">${esc(item.label)}</span>
<span class="block text-xs text-slate-500 mt-0.5">${esc(item.desc)}</span>
<span class="block text-xs text-[var(--text-muted)] mt-0.5">${esc(item.desc)}</span>
</div>
<button onclick="toggleNotify('${escAttr(item.key)}',this)"
class="ml-4 shrink-0 w-11 h-6 rounded-full transition-colors ${enabled?'bg-brand':'bg-slate-700'} relative"
@@ -817,17 +817,17 @@
status.className='text-xs px-2 py-0.5 rounded-full bg-green-900/50 text-green-400 border border-green-500/30';
}else{
status.textContent='已关闭';
status.className='text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-500';
status.className='text-xs px-2 py-0.5 rounded-full bg-[var(--bg-surface)] text-[var(--text-muted)]';
}
// Subscription IPs (read-only)
document.getElementById('subIpCount').textContent=`${_subIps.length} 个`;
document.getElementById('subscriptionIpsList').innerHTML=_subIps.length
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-slate-700 bg-slate-800 text-xs font-mono text-slate-400">${esc(ip)}</span>`).join('')+'</div>'
:'<span class="text-slate-600">(空 — 尚未刷新或未配置订阅)</span>';
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-[var(--border-input)] bg-[var(--bg-surface)] text-xs font-mono text-[var(--text-secondary)]">${esc(ip)}</span>`).join('')+'</div>'
:'<span class="text-[var(--text-dim)]">(空 — 尚未刷新或未配置订阅)</span>';
// Manual IPs (deletable)
document.getElementById('manualIpsList').innerHTML=_manualIps.length
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-slate-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-slate-600">(空)</span>';
?'<div class="flex flex-wrap gap-1.5">'+_manualIps.map(ip=>`<span class="inline-flex items-center px-2 py-0.5 rounded border border-brand/40 bg-brand/10 text-brand-light text-xs font-mono">${esc(ip)}<button onclick="removeManualIp('${escAttr(ip)}')" class="ml-1 text-[var(--text-muted)] hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-[var(--text-dim)]">(空)</span>';
}catch(e){console.error(e);toast('error','加载白名单失败')}
}
+46 -46
View File
@@ -1,36 +1,36 @@
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style><style>.xterm{padding:4px}#terminalArea{flex:1;min-height:0;position:relative}.term-instance{position:absolute;inset:0}.term-instance.active{z-index:1}.term-instance:not(.active){z-index:0;pointer-events:none;opacity:0}#ctxMenu{position:fixed;z-index:200;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,.5)}body.fs-mode aside,body.fs-mode header,body.fs-mode #tabBar,body.fs-mode #cmdBar{display:none!important}body.fs-mode #terminalArea{position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-slate-950 text-slate-100 min-h-screen flex">
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — SSH终端</title><script src="/app/vendor/tailwindcss-browser.js"></script><link rel="stylesheet" href="/app/vendor/theme.css"><script defer src="/app/vendor/alpinejs.min.js"></script><link rel="stylesheet" href="/app/vendor/xterm.css"/><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style><style>.xterm{padding:4px}#terminalArea{flex:1;min-height:0;position:relative}.term-instance{position:absolute;inset:0}.term-instance.active{z-index:1}.term-instance:not(.active){z-index:0;pointer-events:none;opacity:0}#ctxMenu{position:fixed;z-index:200;min-width:140px;box-shadow:0 4px 12px rgba(0,0,0,.5)}body.fs-mode aside,body.fs-mode header,body.fs-mode #tabBar,body.fs-mode #cmdBar{display:none!important}body.fs-mode #terminalArea{position:fixed;inset:0;z-index:100}</style></head>
<body class="bg-[var(--bg-page)] text-[var(--text-primary)] min-h-screen flex">
<aside x-data="{open:true}" x-show="open" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
<aside x-data="{open:true}" x-show="open" x-transition class="w-64 bg-[var(--bg-card)] border-r border-[var(--border)] flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab Bar -->
<div id="tabBar" class="bg-slate-900/80 border-b border-slate-800 px-2 py-1 flex items-center gap-0.5 overflow-x-auto shrink-0">
<div id="tabBar" class="bg-[var(--bg-card)]/80 border-b border-[var(--border)] px-2 py-1 flex items-center gap-0.5 overflow-x-auto shrink-0">
<div id="tabs" class="flex items-center gap-0.5"></div>
<button onclick="newSession()" class="shrink-0 px-1.5 py-0.5 text-slate-500 hover:text-slate-300 text-xs rounded transition ml-1" title="新会话">+</button>
<button onclick="newSession()" class="shrink-0 px-1.5 py-0.5 text-[var(--text-muted)] hover:text-slate-300 text-xs rounded transition ml-1" title="新会话">+</button>
</div>
<!-- Toolbar -->
<header class="bg-slate-900 border-b border-slate-800 px-4 py-1.5 flex items-center justify-between h-8 shrink-0">
<header class="bg-[var(--bg-card)] border-b border-[var(--border)] px-4 py-1.5 flex items-center justify-between h-8 shrink-0">
<div class="flex items-center gap-3">
<span id="connServer" class="text-white text-xs font-semibold">...</span>
<span id="connPath" class="text-slate-500 text-xs font-mono truncate max-w-64"></span>
<span id="connStatus" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500">
<span id="connPath" class="text-[var(--text-muted)] text-xs font-mono truncate max-w-64"></span>
<span id="connStatus" class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[var(--bg-surface)] text-[var(--text-muted)]">
<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接
</span>
<span id="connUptime" class="text-slate-600 text-xs hidden"></span>
<span id="connPing" class="text-slate-600 text-xs hidden"></span>
<span id="connUptime" class="text-[var(--text-dim)] text-xs hidden"></span>
<span id="connPing" class="text-[var(--text-dim)] text-xs hidden"></span>
</div>
<div class="flex items-center gap-1.5">
<!-- Font size -->
<button onclick="changeFontSize(-1)" class="px-1.5 py-0.5 text-slate-500 hover:text-white text-xs rounded transition" title="缩小字体">A</button>
<span id="fontSizeLabel" class="text-slate-600 text-xs">14</span>
<button onclick="changeFontSize(1)" class="px-1.5 py-0.5 text-slate-500 hover:text-white text-xs rounded transition" title="放大字体">A+</button>
<button onclick="changeFontSize(-1)" class="px-1.5 py-0.5 text-[var(--text-muted)] hover:text-white text-xs rounded transition" title="缩小字体">A</button>
<span id="fontSizeLabel" class="text-[var(--text-dim)] text-xs">14</span>
<button onclick="changeFontSize(1)" class="px-1.5 py-0.5 text-[var(--text-muted)] hover:text-white text-xs rounded transition" title="放大字体">A+</button>
<!-- Scrollback -->
<select id="scrollbackSel" onchange="changeScrollback(this.value)" class="bg-slate-800 border border-slate-700 rounded text-slate-400 text-xs px-1 py-0.5">
<select id="scrollbackSel" onchange="changeScrollback(this.value)" class="bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-[var(--text-secondary)] text-xs px-1 py-0.5">
<option value="1000">1K 行</option><option value="5000">5K 行</option><option value="10000">10K 行</option><option value="50000">50K 行</option>
</select>
<button onclick="toggleFullscreen()" class="px-2 py-0.5 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition">全屏</button>
<button onclick="toggleFullscreen()" class="px-2 py-0.5 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition">全屏</button>
<button onclick="disconnect()" class="px-2 py-0.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-xs rounded transition" id="disconnectBtn">断开</button>
</div>
</header>
@@ -40,45 +40,45 @@
<div id="termContainer" class="flex-1 min-h-0 relative">
<!-- term instances injected here -->
</div>
<div id="disconnectOverlay" class="hidden absolute inset-0 bg-slate-950/80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
<div id="disconnectOverlay" class="hidden absolute inset-0 bg-[var(--bg-page)]/80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
<div id="disconnectMsg" class="text-slate-300 text-lg mb-1">连接已断开</div>
<div id="reconnectCountdown" class="text-slate-500 text-sm mb-4 hidden"></div>
<div id="reconnectCountdown" class="text-[var(--text-muted)] text-sm mb-4 hidden"></div>
<div class="flex gap-3">
<button onclick="reconnect()" class="px-5 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">重新连接</button>
<a href="/app/servers.html" class="px-5 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
<a href="/app/servers.html" class="px-5 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
</div>
</div>
<!-- Quick Commands -->
<div id="quickCmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-1.5 flex items-center gap-1.5 overflow-x-auto shrink-0">
<span class="text-slate-600 text-xs shrink-0"></span>
<div id="quickCmdBar" class="bg-[var(--bg-card)]/95 border-t border-[var(--border)] px-3 py-1.5 flex items-center gap-1.5 overflow-x-auto shrink-0">
<span class="text-[var(--text-dim)] text-xs shrink-0"></span>
<div id="quickCmdChips" class="flex items-center gap-1 overflow-x-auto"></div>
<button onclick="addQuickCmd()" class="shrink-0 text-slate-600 hover:text-slate-300 text-xs px-1">+</button>
<button onclick="addQuickCmd()" class="shrink-0 text-[var(--text-dim)] hover:text-slate-300 text-xs px-1">+</button>
</div>
<!-- Command Bar -->
<div id="cmdBar" class="bg-slate-900/95 border-t border-slate-800 px-3 py-2 flex items-center gap-2 shrink-0 opacity-50 pointer-events-none">
<div id="cmdBar" class="bg-[var(--bg-card)]/95 border-t border-[var(--border)] px-3 py-2 flex items-center gap-2 shrink-0 opacity-50 pointer-events-none">
<span class="text-brand-light text-sm font-mono select-none shrink-0"></span>
<input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-slate-800 border border-slate-700 rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false">
<input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded px-3 py-1.5 text-white text-sm font-mono placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-brand/50" autocomplete="off" spellcheck="false">
<button onclick="sendCmd()" class="px-3 py-1.5 bg-brand/80 hover:bg-brand text-white text-sm rounded transition">发送</button>
<div class="flex gap-1 border-l border-slate-700 pl-2">
<button onclick="sendCtrl('c')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+C">^C</button>
<button onclick="sendCtrl('d')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Ctrl+D">^D</button>
<button onclick="sendKey('\t')" class="px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-400 text-xs rounded transition" title="Tab补全">Tab</button>
<div class="flex gap-1 border-l border-[var(--border-input)] pl-2">
<button onclick="sendCtrl('c')" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition" title="Ctrl+C">^C</button>
<button onclick="sendCtrl('d')" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition" title="Ctrl+D">^D</button>
<button onclick="sendKey('\t')" class="px-2 py-1 bg-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition" title="Tab补全">Tab</button>
</div>
</div>
</div>
</div>
<!-- Right Panel -->
<aside id="rightPanel" class="w-64 bg-slate-900 border-l border-slate-800 flex flex-col shrink-0">
<div class="p-3 border-b border-slate-800">
<input id="serverSearch" placeholder="搜索服务器…" oninput="filterServerList()" class="w-full px-2 py-1.5 bg-slate-800 border border-slate-700 rounded text-slate-300 text-xs placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-brand/50">
<aside id="rightPanel" class="w-64 bg-[var(--bg-card)] border-l border-[var(--border)] flex flex-col shrink-0">
<div class="p-3 border-b border-[var(--border)]">
<input id="serverSearch" placeholder="搜索服务器…" oninput="filterServerList()" class="w-full px-2 py-1.5 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded text-slate-300 text-xs placeholder-slate-600 focus:outline-none focus:ring-1 focus:ring-brand/50">
</div>
<div id="serverListPanel" class="flex-1 overflow-y-auto px-2 py-1 space-y-0.5" onscroll=""></div>
</div>
</aside>
<!-- Right-Click Context Menu -->
<div id="ctxMenu" class="hidden bg-slate-900 border border-slate-700 rounded-lg py-1" onclick="event.stopPropagation()">
<div id="ctxMenu" class="hidden bg-[var(--bg-card)] border border-[var(--border-input)] rounded-lg py-1" onclick="event.stopPropagation()">
<button onclick="ctxCopy();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">📋 复制</button>
<button onclick="ctxPaste();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">📄 粘贴</button>
<button onclick="ctxClear();hideCtxMenu()" class="w-full text-left px-3 py-1.5 text-xs text-slate-300 hover:bg-slate-800 transition">🧹 清屏</button>
@@ -87,23 +87,23 @@
<!-- QuickCmd Modal -->
<div id="qcModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-sm p-5 space-y-3">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-sm p-5 space-y-3">
<h2 id="qcTitle" class="text-white font-semibold">添加命令</h2>
<input id="qcName" placeholder="命令名称" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500">
<textarea id="qcCmd" placeholder="命令内容(含 \\r 回车)" rows="3" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm font-mono placeholder-slate-500"></textarea>
<input id="qcName" placeholder="命令名称" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500">
<textarea id="qcCmd" placeholder="命令内容(含 \\r 回车)" rows="3" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm font-mono placeholder-slate-500"></textarea>
<input type="hidden" id="qcEditId">
<div class="flex gap-2">
<button onclick="saveQuickCmd()" class="flex-1 py-2 bg-brand hover:bg-brand-dark text-white rounded-lg text-sm">保存</button>
<button onclick="document.getElementById('qcModal').classList.add('hidden')" class="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
<button onclick="document.getElementById('qcModal').classList.add('hidden')" class="px-4 py-2 bg-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">取消</button>
</div>
</div>
</div>
<!-- Server Selector Modal (for new tab) -->
<div id="serverModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60" onclick="if(event.target===this)this.classList.add('hidden')">
<div class="bg-slate-900 border border-slate-700 rounded-xl w-full max-w-sm p-4 space-y-3 max-h-[60vh] flex flex-col">
<div class="bg-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-sm p-4 space-y-3 max-h-[60vh] flex flex-col">
<h2 class="text-white font-semibold text-sm">选择服务器</h2>
<input id="serverModalSearch" placeholder="搜索…" oninput="filterServerModal()" class="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500">
<input id="serverModalSearch" placeholder="搜索…" oninput="filterServerModal()" class="w-full px-3 py-2 bg-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500">
<div id="serverModalList" class="flex-1 overflow-y-auto space-y-1"></div>
</div>
</div>
@@ -392,7 +392,7 @@ async function connectSession(idx) {
if (idx === activeIdx) {
document.getElementById('cmdBar').classList.add('opacity-50','pointer-events-none');
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[var(--bg-surface)] text-[var(--text-muted)]';
document.getElementById('connUptime').classList.add('hidden');
document.getElementById('connPing').classList.add('hidden');
if (uptimeInterval) { clearInterval(uptimeInterval); uptimeInterval = null; }
@@ -491,11 +491,11 @@ function disconnect() {
function updateTabUI() {
document.getElementById('tabs').innerHTML = sessions.map((s, i) => {
const active = i === activeIdx;
return `<div class="flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition ${active ? 'bg-brand/20 text-brand-light' : 'text-slate-400 hover:bg-slate-800'}"
return `<div class="flex items-center gap-1 px-2 py-0.5 rounded text-xs cursor-pointer transition ${active ? 'bg-brand/20 text-brand-light' : 'text-[var(--text-secondary)] hover:bg-slate-800'}"
onclick="switchTab(${i})" title="${esc(s.serverName)}">
<span class="w-1.5 h-1.5 rounded-full ${s.ws && s.ws.readyState===1 ? 'bg-green-400' : 'bg-slate-600'}"></span>
<span class="max-w-28 truncate">${esc(s.serverName)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="text-slate-600 hover:text-red-400 ml-0.5">&times;</button>
<button onclick="event.stopPropagation();closeTab(${i})" class="text-[var(--text-dim)] hover:text-red-400 ml-0.5">&times;</button>
</div>`;
}).join('');
}
@@ -511,7 +511,7 @@ function updateConnInfo() {
} else {
document.getElementById('connServer').textContent = '...';
document.getElementById('connStatus').innerHTML = '<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-500';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[var(--bg-surface)] text-[var(--text-muted)]';
}
updateTabUI();
}
@@ -686,10 +686,10 @@ function renderServerList() {
const el = document.getElementById('serverListPanel');
el.innerHTML = _serversCache.map(s => {
const online = s.is_online;
return `<a href="javascript:void(0)" onclick="newSession(${s.id})" class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition text-slate-400 hover:text-white hover:bg-slate-800">
return `<a href="javascript:void(0)" onclick="newSession(${s.id})" class="flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm transition text-[var(--text-secondary)] hover:text-white hover:bg-slate-800">
<span class="w-2 h-2 rounded-full shrink-0 ${online ? 'bg-green-400' : 'bg-slate-600'}"></span>
<span class="truncate flex-1">${esc(s.name)}</span>
<span class="text-slate-600 text-xs shrink-0">${esc(s.domain)}</span>
<span class="text-[var(--text-dim)] text-xs shrink-0">${esc(s.domain)}</span>
</a>`;
}).join('');
}
@@ -710,10 +710,10 @@ function loadQuickCmds() {
el.innerHTML = all.map((c, i) => {
const isBuiltin = c.id.startsWith('__');
return `<span class="group flex items-center gap-0.5 px-2 py-0.5 rounded text-xs whitespace-nowrap cursor-pointer transition qc-chip
${isBuiltin ? 'bg-slate-800/50 text-slate-400 hover:bg-slate-800 hover:text-slate-200' : 'bg-brand/10 text-brand-light hover:bg-brand/20'}"
${isBuiltin ? 'bg-slate-800/50 text-[var(--text-secondary)] hover:bg-slate-800 hover:text-slate-200' : 'bg-brand/10 text-brand-light hover:bg-brand/20'}"
data-cmd="${esc(c.cmd).replace(/"/g,'&quot;')}">
${esc(c.name)}
${!isBuiltin ? `<button class="hidden group-hover:inline text-slate-500 hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
${!isBuiltin ? `<button class="hidden group-hover:inline text-[var(--text-muted)] hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
</span>`;
}).join('');
// Delegate clicks
@@ -787,7 +787,7 @@ function renderServerModal(filter) {
`<div class="flex items-center gap-2 px-2 py-1.5 rounded text-sm cursor-pointer text-slate-300 hover:bg-slate-800 transition"
onclick="document.getElementById('serverModal').classList.add('hidden');newSession(${s.id})">
<span class="w-2 h-2 rounded-full shrink-0 ${s.is_online?'bg-green-400':'bg-slate-600'}"></span>
<span class="truncate">${esc(s.name)}</span><span class="text-slate-600 text-xs">${esc(s.domain)}</span>
<span class="truncate">${esc(s.name)}</span><span class="text-[var(--text-dim)] text-xs">${esc(s.domain)}</span>
</div>`
).join('');
}
+30
View File
@@ -0,0 +1,30 @@
/* Nexus Theme System — CSS Custom Properties */
/* Dark is default (matches current design). Toggle via data-theme on <html> */
:root, [data-theme="dark"] {
--bg-page: #020617;
--bg-card: #0f172a;
--bg-surface: #1e293b;
--bg-input: #1e293b;
--bg-hover: #1e293b;
--border: #1e293b;
--border-input: #334155;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--text-dim: #475569;
}
[data-theme="light"] {
--bg-page: #f8fafc;
--bg-card: #ffffff;
--bg-surface: #f1f5f9;
--bg-input: #ffffff;
--bg-hover: #e2e8f0;
--border: #e2e8f0;
--border-input: #cbd5e1;
--text-primary: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--text-dim: #cbd5e1;
}