feat: 全站主题完全重做 — CSS变量→Tailwind dark: 前缀

设计依据: HyperUI/Flowbite/daisyUI/Meraki UI 深度调研
配色方案: HyperUI 全局色值映射 (gray-* 色系)

改动:
- theme.css: 重写为 @custom-variant dark 配置
- theme-init.js: FOUC防护 — 同步读localStorage设.dark class
- layout.js: 主题系统改用 .dark class + localStorage
- 15个HTML页面: 649处CSS变量class替换为dark:前缀
  bg-[var(--bg-page)] → bg-gray-50 dark:bg-gray-950
  bg-[var(--bg-card)] → bg-white dark:bg-gray-900
  bg-[var(--bg-surface)] → bg-gray-100 dark:bg-gray-800
  border-[var(--border)] → border-gray-200 dark:border-gray-700
  text-[var(--text-primary)] → text-gray-900 dark:text-white
  等9种映射
- design spec: docs/design/specs/2026-05-31-ui-redesign-design.md

修复: FOUC(切换主题时闪暗色)已解决
This commit is contained in:
Your Name
2026-05-31 04:11:17 +08:00
parent c457cb4058
commit 9c5ebdfdbd
14 changed files with 581 additions and 484 deletions
@@ -0,0 +1,97 @@
# 设计文档:Nexus 后台全面美化重构
**日期**: 2026-05-31
**状态**: 实施中
**依据**: HyperUI / Flowbite / daisyUI / Meraki UI / Monaco Editor 深度调研
## 一、设计决策
### 配色方案:基于 HyperUI 全局色值映射
| 用途 | 亮色 (Light) | 暗色 (dark:) |
|------|-------------|-------------|
| 页面背景 | `bg-gray-50` | `dark:bg-gray-950` |
| 卡片/面板 | `bg-white` | `dark:bg-gray-900` |
| 菜单/下拉 | `bg-white` | `dark:bg-gray-800` |
| 主文本 | `text-gray-900` | `dark:text-white` |
| 次文本 | `text-gray-700` | `dark:text-gray-200` |
| 弱文本 | `text-gray-500` | `dark:text-gray-400` |
| 主边框 | `border-gray-200` | `dark:border-gray-700` |
| 次边框/输入框 | `border-gray-300` | `dark:border-gray-600` |
| hover 背景 | `hover:bg-gray-50` | `dark:hover:bg-gray-700` |
| 活跃菜单项 | `bg-gray-100 text-gray-700` | `dark:bg-gray-800 dark:text-gray-200` |
| 主按钮 | `bg-blue-600 text-white` | `dark:bg-blue-300 dark:text-gray-900` |
| 成功 | `bg-green-100 text-green-600` | `dark:bg-green-700 dark:text-green-50` |
| 危险 | `text-red-700` | `dark:text-red-600` |
| 品牌色 | 保持 oklch brand 不变 |
### 暗色模式实现
- **机制**: Tailwind v4 原生 `dark:` 前缀
- **激活**: `.dark` class on `<html>` 元素
- **FOUC 防护**: `<head>` 中同步 `theme-init.js` 从 localStorage 读取
- **持久化**: MySQL `settings``key='theme'`
- **移除**: 旧 CSS 变量方案 (`bg-[var(--bg-page)]` 等)
### Monaco Editor 主题
- **暗色**: 自定义 `nexus-dark`Catppuccin Mocha 色系: `#1E1E2E` 背景)
- **亮色**: 自定义 `nexus-light`VS Code 经典色系: `#FFFFFF` 背景)
- **字体**: `Fira Code`, `JetBrains Mono`, `Cascadia Code` + fontLigatures
## 二、Class 替换映射(核心)
### CSS 变量 → Tailwind dark: 前缀
| 旧 (CSS 变量) | 新 (dark: 前缀) |
|---------------|----------------|
| `bg-[var(--bg-page)]` | `bg-gray-50 dark:bg-gray-950` |
| `bg-[var(--bg-card)]` | `bg-white dark:bg-gray-900` |
| `bg-[var(--bg-surface)]` | `bg-gray-100 dark:bg-gray-800` |
| `border-[var(--border)]` | `border-gray-200 dark:border-gray-700` |
| `border-[var(--border-input)]` | `border-gray-300 dark:border-gray-600` |
| `text-[var(--text-primary)]` | `text-gray-900 dark:text-white` |
| `text-[var(--text-secondary)]` | `text-gray-500 dark:text-gray-400` |
| `text-[var(--text-muted)]` | `text-gray-400 dark:text-gray-500` |
| `text-[var(--text-dim)]` | `text-gray-300 dark:text-gray-600` |
### 保留不变的 class
- `text-white`, `text-red-*`, `text-green-*`, `text-brand-*`
- `bg-brand`, `bg-brand-dark`, `hover:bg-brand-dark`
- `hover:bg-slate-700`, `hover:bg-slate-800`(按钮 hover 不变)
- `bg-slate-700`(按钮背景不改)
- 所有带 opacity 的:`bg-slate-800/50`
## 三、修改范围
| 文件 | 改动 |
|------|------|
| `web/app/vendor/theme.css` | 重写:`@variant dark` 配置 |
| `web/app/vendor/theme-init.js` | FOUC 防护脚本 |
| `web/app/layout.js` | 主题系统改用 `.dark` class |
| `server/api/settings.py` | 白名单已有 theme |
| 15 个 HTML 文件 | class 替换 + theme-init.js 引入 |
| `web/app/files.html` | Monaco 自定义主题 |
## 四、Monaco 自定义主题
### nexus-dark(暗色)
- 背景: `#1E1E2E`Catppuccin Mocha base
- 前景: `#CDD6F4`
- 行号: `#585B70`
- 行高亮: `#313244`
- 选中: `#45475A`
- 关键字: `#C586C0`(紫色)
- 字符串: `#CE9178`(橙色)
- 函数: `#DCDCAA`(黄色)
- 类型: `#4EC9B0`(青色)
- 变量: `#9CDCFE`(蓝色)
### nexus-light(亮色)
- 背景: `#FFFFFF`
- 前景: `#1E1E1E`
- 行号: `#237893`
- 行高亮: `#F5F5F5`
- 选中: `#ADD6FF`
- VS Code 经典语法配色
+16 -16
View File
@@ -2,12 +2,12 @@
<script src="/app/vendor/theme-init.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-gray-50 dark:bg-gray-950 text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 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>
<button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<div class="flex items-center gap-2 flex-wrap">
@@ -28,7 +28,7 @@
<option value="recovery">仅恢复</option>
</select>
<input id="dateFrom" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
<span class="text-[var(--text-dim)] text-xs"></span>
<span class="text-gray-300 dark:text-gray-600 text-xs"></span>
<input id="dateTo" type="date" onchange="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
<button onclick="_offset=0;loadAlerts()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
</div>
@@ -47,7 +47,7 @@
<!-- Alert list -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
<table class="w-full text-sm min-w-[700px]">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase">
<tr>
<th class="text-left px-4 py-3">状态</th>
<th class="text-left px-4 py-3">类型</th>
@@ -57,14 +57,14 @@
</tr>
</thead>
<tbody id="alertsTbody" class="divide-y divide-slate-800">
<tr><td colspan="5" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="pagination" class="hidden flex items-center justify-between text-sm">
<span id="pageInfo" class="text-[var(--text-muted)] text-xs"></span>
<span id="pageInfo" class="text-gray-400 dark:text-gray-500 text-xs"></span>
<div class="flex gap-2">
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
@@ -80,7 +80,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-[var(--text-secondary)]'};
const TYPE_COLOR = {cpu:'text-orange-400',mem:'text-blue-400',disk:'text-yellow-400',time_drift:'text-purple-400',system:'text-gray-500 dark:text-gray-400'};
async function loadServers(){
const r=await apiFetch(API+'/servers/?per_page=200');if(!r)return;
@@ -97,7 +97,7 @@
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-[var(--text-dim)] text-xs text-right">${i+1}</span>
<span class="w-5 text-gray-300 dark:text-gray-600 text-xs text-right">${i+1}</span>
<div class="flex-1 bg-gray-100 dark:bg-gray-800 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>
@@ -110,19 +110,19 @@
const totalRecoveries=(d.daily||[]).reduce((s,r)=>s+r.recoveries,0);
document.getElementById('statsSection').innerHTML=`
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天告警次数</div>
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">近 7 天告警次数</div>
<div class="text-red-400 text-3xl font-bold">${totalAlerts}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">近 7 天恢复次数</div>
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">近 7 天恢复次数</div>
<div class="text-green-400 text-3xl font-bold">${totalRecoveries}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的类型</div>
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">告警最多的类型</div>
<div class="text-white text-xl font-bold">${getTopType(d)}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div class="text-[var(--text-secondary)] text-xs mb-1">告警最多的服务器</div>
<div class="text-gray-500 dark:text-gray-400 text-xs mb-1">告警最多的服务器</div>
<div class="text-white text-base font-bold truncate">${(d.top_servers||[])[0]?.server_name||'--'}</div>
</div>`;
}
@@ -156,20 +156,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-[var(--text-muted)]">暂无告警记录</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-gray-400 dark:text-gray-500">暂无告警记录</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-[var(--text-secondary)]';
const typeColor=TYPE_COLOR[l.alert_type]||'text-gray-500 dark:text-gray-400';
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-[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>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs font-mono">${esc(l.value||'--')}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
+11 -11
View File
@@ -2,11 +2,11 @@
<script src="/app/vendor/theme-init.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-gray-50 dark:bg-gray-950 text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 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-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<div class="flex items-center gap-2 flex-wrap">
<select id="actionFilter" onchange="_offset=0;loadAudit()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
<option value="">全部操作</option>
@@ -60,20 +60,20 @@
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
<span class="text-[var(--text-dim)] text-xs"></span>
<span class="text-gray-300 dark:text-gray-600 text-xs"></span>
<input id="dateTo" type="date" onchange="_offset=0;loadAudit()"
class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
<button onclick="clearFilters()" class="px-2 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition" title="清除日期过滤"></button>
<button onclick="clearFilters()" class="px-2 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-xs rounded-lg transition" title="清除日期过滤"></button>
<button onclick="_offset=0;loadAudit()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 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-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 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>
<table class="w-full text-sm"><thead class="bg-slate-800/50 text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-500">加载中...</td></tr></tbody></table>
</div>
<!-- Pagination -->
<div id="pagination" class="hidden mt-4 flex items-center justify-between">
<span id="pageInfo" class="text-[var(--text-muted)] text-sm"></span>
<span id="pageInfo" class="text-gray-400 dark:text-gray-500 text-sm"></span>
<div class="flex items-center gap-2">
<button onclick="prevPage()" id="prevBtn" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition disabled:opacity-50">下一页</button>
@@ -145,17 +145,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-[var(--text-muted)]">暂无日志</td></tr>'}else{
if(!logs.length){tbody.innerHTML='<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无日志</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-gray-100 dark:bg-gray-800 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-[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>
<td class="px-4 py-3 text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-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-gray-300 dark:text-gray-600 text-xs">${esc(l.ip_address||'--')}</td>
<td class="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`}).join('');
}
updatePagination();
+34 -34
View File
@@ -2,11 +2,11 @@
<script src="/app/vendor/theme-init.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-gray-50 dark:bg-gray-950 text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true}">
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 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-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<div class="flex items-center gap-3">
<!-- View selector -->
<select id="logView" onchange="switchView()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm">
@@ -42,11 +42,11 @@
<div id="commandsView">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
<table class="w-full text-sm min-w-[640px]">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 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-[var(--text-muted)]">加载中...</td></tr>
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -56,11 +56,11 @@
<div id="sessionsView" class="hidden">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
<table class="w-full text-sm min-w-[700px]">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 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-[var(--text-muted)]">加载中...</td></tr>
<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
</tbody>
</table>
</div>
@@ -70,7 +70,7 @@
<div id="pushView" class="hidden space-y-3">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 overflow-x-auto">
<table class="w-full text-sm min-w-[800px]">
<thead class="bg-slate-800/50 text-[var(--text-secondary)] text-xs uppercase">
<thead class="bg-slate-800/50 text-gray-500 dark:text-gray-400 text-xs uppercase">
<tr>
<th class="text-left px-4 py-3">状态</th>
<th class="text-left px-4 py-3">服务器</th>
@@ -85,16 +85,16 @@
</tr>
</thead>
<tbody id="pushTbody" class="divide-y divide-slate-800">
<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">加载中...</td></tr>
<tr><td colspan="10" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">加载中...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div id="pushPagination" class="hidden flex items-center justify-between text-sm">
<span id="pushTotal" class="text-[var(--text-muted)] text-xs"></span>
<span id="pushTotal" class="text-gray-400 dark:text-gray-500 text-xs"></span>
<div class="flex items-center gap-2">
<button id="pushPrevBtn" onclick="pushPage(-1)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 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>
<span id="pushPageInfo" class="text-gray-500 dark:text-gray-400 text-xs"></span>
<button id="pushNextBtn" onclick="pushPage(1)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition disabled:opacity-40">下一页 →</button>
</div>
</div>
@@ -161,16 +161,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-[var(--text-muted)]">暂无命令日志</td></tr>'; return; }
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无命令日志</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-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 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-[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>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(l.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
}
@@ -183,18 +183,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-[var(--text-muted)]">暂无SSH会话</td></tr>'; return; }
if (!sessions.length) { tbody.innerHTML = '<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无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-[var(--text-secondary)]">已断开</span>';
:'<span class="px-2 py-0.5 rounded text-xs bg-slate-700 text-gray-500 dark:text-gray-400">已断开</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-[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 text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap">${esc(_servers[s.server_id]||'#'+s.server_id)}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(s.remote_addr||'--')}</td>
<td class="px-4 py-2.5">${badge}</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-gray-400 dark:text-gray-500 text-xs whitespace-nowrap">${fmt(s.started_at)}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 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('');
@@ -219,13 +219,13 @@
const tbody = document.getElementById('pushTbody');
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-[var(--text-muted)]">暂无推送记录</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">暂无推送记录</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-[var(--text-secondary)]';
const sc = l.status==='success'?'text-green-400':l.status==='failed'?'text-red-400':l.status==='running'?'text-yellow-400':'text-gray-500 dark:text-gray-400';
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||'--';
@@ -238,14 +238,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-gray-100 dark:bg-gray-800 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>
<td class="px-4 py-2.5"><span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">${esc(modeBadge)}</span></td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(triggerBadge)}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-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-gray-500 dark:text-gray-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-gray-500 dark:text-gray-400 text-xs text-right">${l.files_transferred!=null?l.files_transferred:'--'}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-xs text-right whitespace-nowrap">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(l.operator||'--')}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.started_at)}</td>
</tr>`;
}).join('');
@@ -274,15 +274,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-[var(--text-muted)]">该会话无命令记录</td></tr>'; return; }
if (!logs.length) { tbody.innerHTML = '<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">该会话无命令记录</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-[var(--text-secondary)] text-xs whitespace-nowrap">${esc(_servers[l.server_id]||'#'+l.server_id)}</td>
<td class="px-4 py-2.5 text-gray-500 dark:text-gray-400 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-[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>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs">${esc(l.remote_addr||'--')}</td>
<td class="px-4 py-2.5 text-gray-400 dark:text-gray-500 text-xs text-right whitespace-nowrap">${fmt(l.created_at)}</td>
</tr>`;
}).join('');
}
+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>
<script src="/app/vendor/theme-init.js"></script>
<link rel="stylesheet" href="/app/vendor/theme.css">
<script defer src="/app/vendor/alpinejs.min.js"></script>
<style type="text/tailwindcss">
@@ -30,7 +31,7 @@
.copy-btn.copied { background: #22c55e; color: #fff; border-color: #22c55e; }
</style>
</head>
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen flex items-center justify-center p-4">
<div class="installer-card w-full bg-white rounded-2xl shadow-2xl overflow-hidden">
@@ -45,7 +46,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-[var(--text-secondary)]'"
:class="step > i ? 'bg-green-500 text-white' : step === i ? 'bg-blue-500 text-white' : 'border-2 border-slate-200 text-gray-500 dark:text-gray-400'"
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>
@@ -65,7 +66,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-[var(--text-muted)] mb-4">检测到 .env 配置文件,系统已完成安装。</p>
<p class="text-gray-400 dark:text-gray-500 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>
@@ -74,7 +75,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-[var(--text-muted)] mb-6 leading-relaxed">
<p class="text-gray-400 dark:text-gray-500 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> (共享配置) +
@@ -83,7 +84,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-[var(--text-dim)] text-sm ml-5 list-disc space-y-1">
<ul class="text-gray-300 dark:text-gray-600 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>
@@ -101,7 +102,7 @@
<div>
<h2 class="text-lg font-bold text-slate-800 mb-4">🔍 环境检测</h2>
<div x-show="envLoading" class="text-center py-8 text-[var(--text-secondary)]">
<div x-show="envLoading" class="text-center py-8 text-gray-500 dark:text-gray-400">
<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>
@@ -111,7 +112,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-[var(--text-secondary)] text-xs ml-2" x-text="'需要: ' + c.required"></span>
<span class="text-gray-500 dark:text-gray-400 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>
@@ -209,13 +210,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-[var(--text-secondary)] mb-1">自动检测 — 用于 Agent 上报和前端 API 调用,可手动修改</p>
<p class="text-xs text-gray-500 dark:text-gray-400 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-[var(--text-secondary)] mb-1">uvicorn 监听端口</p>
<p class="text-xs text-gray-500 dark:text-gray-400 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>
@@ -269,7 +270,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-[var(--text-secondary)] mb-1">显示在浏览器标题和后台标题栏</p>
<p class="text-xs text-gray-500 dark:text-gray-400 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">
@@ -287,7 +288,7 @@
</div>
<div class="mt-3">
<label class="block font-semibold text-sm text-slate-700 mb-1">管理员密码</label>
<p class="text-xs text-[var(--text-secondary)] mb-1">至少6位字符</p>
<p class="text-xs text-gray-500 dark:text-gray-400 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>
@@ -314,8 +315,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-[var(--text-muted)] mb-1">Nexus 6.0 已成功安装。</p>
<p class="text-[var(--text-secondary)] text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
<p class="text-gray-400 dark:text-gray-500 mb-1">Nexus 6.0 已成功安装。</p>
<p class="text-gray-500 dark:text-gray-400 text-xs">安装向导已锁定 — 防止重复安装(如需重装请删除 .env 文件后重启服务)</p>
<!-- Guardian results -->
<div x-show="initResult?.guardian_results?.length" class="text-left mt-6 mb-4 rounded-lg p-4"
@@ -337,12 +338,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-[var(--text-muted)] space-y-1">
<div x-show="!guardianAllOk" class="text-xs text-gray-400 dark:text-gray-500 space-y-1">
<!-- BT Panel mode -->
<template x-if="btPanel">
<div>
<p>宝塔面板 → 软件商店 → Supervisor管理器 → 添加守护进程</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>
<p>或手动写入配置: <code class="bg-gray-100 dark:bg-gray-800 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>
@@ -358,14 +359,14 @@
stdout_logfile=/www/wwwlogs/nexus_access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB</pre></div>
<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>
<p>重载: <code class="bg-gray-100 dark:bg-gray-800 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-[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>
<p>安装: <code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y supervisor</code></p>
<p>配置文件复制到: <code class="bg-gray-100 dark:bg-gray-800 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>
@@ -381,7 +382,7 @@
stdout_logfile=/var/log/nexus/access.log
stderr_logfile_maxbytes=50MB
stdout_logfile_maxbytes=50MB</pre></div>
<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>
<p>启动: <code class="bg-gray-100 dark:bg-gray-800 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>
@@ -394,11 +395,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-[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 class="text-xs text-gray-400 dark:text-gray-500 space-y-1">
<p><code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">cd <span x-text="installDir"></span></code></p>
<p><code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">python3.12 -m venv venv</code></p>
<p><code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">source venv/bin/activate</code></p>
<p><code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">pip install -r requirements.txt</code></p>
</div>
</div>
@@ -408,11 +409,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-[var(--text-muted)]">
<div class="text-xs text-gray-400 dark:text-gray-500">
<!-- BT Panel mode -->
<template x-if="btPanel">
<div>
<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>
<p class="mb-1">宝塔 → 网站 → 配置文件 → <code class="bg-gray-100 dark:bg-gray-800 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>;
@@ -437,7 +438,7 @@
<!-- Standard mode -->
<template x-if="!btPanel">
<div>
<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>
<p class="mb-1">将以下配置写入 <code class="bg-gray-100 dark:bg-gray-800 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;
@@ -466,7 +467,7 @@ server {
return 404;
}
}</pre></div>
<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>
<p>启用: <code class="bg-gray-100 dark:bg-gray-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>
</div>
</template>
</div>
@@ -478,7 +479,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-[var(--text-muted)]">
<div class="text-xs text-gray-400 dark:text-gray-500">
<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;
@@ -495,13 +496,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-[var(--text-muted)]">
<div class="text-xs text-gray-400 dark:text-gray-500">
<template x-if="btPanel">
<div>宝塔 → 网站 → SSL → Let's Encrypt → 一键申请 → 开启强制HTTPS</div>
</template>
<template x-if="!btPanel">
<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>
<div>安装certbot: <code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">sudo apt install -y certbot python3-certbot-nginx</code><br>
申请证书: <code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">sudo certbot --nginx -d YOUR_DOMAIN</code></div>
</template>
</div>
</div>
@@ -512,12 +513,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-[var(--text-muted)]">
<div x-show="!guardianAllOk" class="text-xs text-gray-400 dark:text-gray-500">
<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-[var(--text-muted)] mt-2">
<div class="text-xs text-gray-400 dark:text-gray-500 mt-2">
<b>3层守护机制:</b><br>
Layer 1: Supervisor — Python崩溃自动重启<br>
Layer 2: Python self_monitor — 每30s检查Redis/MySQL/WebSocket<br>
@@ -531,9 +532,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-[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>
<div class="text-xs text-gray-400 dark:text-gray-500 space-y-1">
<p>✓ 安装向导已锁定 — 删除 <code class="bg-gray-100 dark:bg-gray-800 text-slate-200 px-1.5 py-0.5 rounded">.env</code> 文件可重新安装</p>
<p><code class="bg-gray-100 dark:bg-gray-800 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>
+10 -10
View File
@@ -19,7 +19,7 @@
}
</style>
</head>
<body class="bg-[var(--bg-page)] min-h-screen flex items-center justify-center p-4">
<body class="bg-gray-50 dark:bg-gray-950 min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
@@ -31,11 +31,11 @@
</svg>
</div>
<h1 class="text-2xl font-bold text-white">Nexus</h1>
<p class="text-[var(--text-secondary)] text-sm mt-1">服务器运维管理平台</p>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">服务器运维管理平台</p>
</div>
<!-- Login Card -->
<div id="loginCard" class="bg-[var(--bg-card)] rounded-2xl border border-[var(--border)] p-8 shadow-xl">
<div id="loginCard" class="bg-white dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-700 p-8 shadow-xl">
<!-- Step 1: Password -->
<div id="stepPassword">
@@ -45,7 +45,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-[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"
class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
placeholder="请输入用户名">
</div>
@@ -53,10 +53,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-[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"
class="w-full px-4 py-3 pr-12 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--text-muted)] hover:text-slate-300 transition p-1">
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 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"/>
@@ -80,12 +80,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-[var(--text-secondary)] text-sm mb-6">请输入验证器应用中的 6 位数字</p>
<p class="text-gray-500 dark:text-gray-400 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-[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"
class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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>
@@ -95,7 +95,7 @@
</button>
<button type="button" id="backBtn"
class="w-full py-2 text-[var(--text-secondary)] hover:text-white text-sm transition">
class="w-full py-2 text-gray-500 dark:text-gray-400 hover:text-white text-sm transition">
← 返回
</button>
</form>
@@ -109,7 +109,7 @@
</div>
<!-- Footer -->
<p class="text-center text-[var(--text-dim)] text-xs mt-6">Nexus v6.0 · 安全连接</p>
<p class="text-center text-gray-300 dark:text-gray-600 text-xs mt-6">Nexus v6.0 · 安全连接</p>
</div>
<script>
+106 -106
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 src="/app/vendor/theme-init.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>
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 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 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 class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
<!-- Push Form -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 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-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-sm text-slate-300">
<select id="templateSelect" onchange="applyTemplate()" class="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-sm text-slate-300">
<option value="">📋 推送模板...</option>
</select>
<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>
<button onclick="saveTemplate()" class="px-3 py-2 bg-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-800 hover:bg-red-900/50 text-gray-500 dark:text-gray-400 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-[var(--border-input)] 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-gray-300 dark:border-gray-600 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-[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>
<svg class="w-8 h-8 text-gray-400 dark:text-gray-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>
<div>
<p class="text-sm text-slate-300">拖拽 ZIP 文件到此处,或 <span class="text-brand-light hover:underline">点击选择</span></p>
<p class="text-xs text-[var(--text-muted)] mt-1">仅支持 .zip 格式</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">仅支持 .zip 格式</p>
</div>
</div>
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<div id="uploadProgress" class="hidden flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<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-[var(--text-secondary)]"></span>
<button onclick="clearUpload()" class="text-xs text-[var(--text-muted)] hover:text-slate-300 ml-auto">✕ 清除</button>
<span id="uploadedInfo" class="text-xs text-gray-500 dark:text-gray-400"></span>
<button onclick="clearUpload()" class="text-xs text-gray-400 dark:text-gray-500 hover:text-slate-300 ml-auto">✕ 清除</button>
</div>
<!-- File Manager -->
<div id="fileManager" class="bg-gray-100/50 dark:bg-gray-800/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>
<div id="fileManager" class="bg-gray-100/50 dark:bg-gray-800/50 border border-gray-300/50 dark:border-gray-600/50 rounded-lg overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-800 border-b border-gray-300/50 dark:border-gray-600/50">
<span class="text-xs text-gray-500 dark:text-gray-400">📁</span>
<span id="fmBreadcrumb" class="text-xs text-slate-300 flex-1 truncate"></span>
<span id="fmCount" class="text-xs text-[var(--text-muted)]"></span>
<span id="fmCount" class="text-xs text-gray-400 dark:text-gray-500"></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-[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 class="text-xs text-gray-400 dark:text-gray-500 shrink-0">或输入源路径:</span>
<input id="sourcePathInput" placeholder="/path/to/files" class="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-xs placeholder-slate-600">
<button onclick="validateSourcePath()" id="validatePathBtn" class="px-3 py-2 bg-gray-100 dark:bg-gray-800 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-[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="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-xs text-slate-300">
<select id="filterCategory" onchange="filterServers()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-xs text-slate-300">
<option value="">全部分类</option>
</select>
<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">
<select id="filterPlatform" onchange="filterServers()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--text-secondary)] hover:underline">全不选</button>
<button onclick="deselectAllServers()" class="text-xs text-gray-500 dark:text-gray-400 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-[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>
<input id="serverSearch" placeholder="搜索服务器名称/域名..." oninput="filterServerOptions()" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-xs text-slate-300 placeholder-slate-600 mb-1">
<select id="targetServers" multiple class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white text-sm h-40"></select>
<div id="serverCount" class="text-gray-400 dark:text-gray-500 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-[var(--bg-surface)] border border-[var(--border-input)] 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--text-muted)] mt-2 px-1">仅传输源目录中新增或修改的文件,目标已有且未变的文件跳过。安全快速,适合日常更新。</div>
<div id="syncModeDesc" class="text-xs text-gray-400 dark:text-gray-500 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-[var(--text-muted)] hover:text-slate-300 transition">预览首台变动 →</button>
<button onclick="doPreview()" class="text-xs text-gray-400 dark:text-gray-500 hover:text-slate-300 transition">预览首台变动 →</button>
</div>
<!-- Preview result -->
<div id="previewResult" class="hidden rounded-xl border border-[var(--border-input)] bg-gray-100/50 dark:bg-gray-800/50 p-4 space-y-3">
<div id="previewResult" class="hidden rounded-xl border border-gray-300 dark:border-gray-600 bg-gray-100/50 dark:bg-gray-800/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-[var(--text-muted)] hover:text-slate-300">✕ 关闭</button>
<button onclick="clearPreview()" class="text-xs text-gray-400 dark:text-gray-500 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-[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>
<pre id="previewFiles" class="text-xs text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 rounded-lg p-3 max-h-56 overflow-y-auto whitespace-pre-wrap"></pre>
<p id="previewTruncated" class="hidden text-xs text-gray-400 dark:text-gray-500 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-[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><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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 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-[var(--text-secondary)] -mt-2">
<label class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 -mt-2">
<input type="checkbox" id="verifyAfterPush" class="accent-brand"> 推送后校验(md5sum 对比)
</label>
</div>
<!-- Progress Section -->
<div id="progressSection" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div id="progressSection" class="hidden mt-6 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 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-[var(--text-secondary)]">0/0</span>
<span id="progressStats" class="text-sm text-gray-500 dark:text-gray-400">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-[var(--bg-surface)] rounded-full h-3 overflow-hidden">
<div class="w-full bg-gray-100 dark:bg-gray-800 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-[var(--text-secondary)]">◌ 等待: <span id="countPending">0</span></span>
<span class="text-gray-500 dark:text-gray-400">◌ 等待: <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-[var(--bg-surface)] border border-[var(--border-input)] rounded text-xs text-slate-300">
<select id="historyStatus" onchange="loadHistory(1)" class="px-2 py-1 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] rounded text-xs text-slate-300" onchange="loadHistory(1)">
<input id="historyServer" placeholder="服务器ID" class="w-20 px-2 py-1 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-xs text-slate-300" onchange="loadHistory(1)">
</div>
</div>
<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>
<div id="pushHistory" class="space-y-2"><div class="text-gray-400 dark:text-gray-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-gray-500 dark:text-gray-400">
<button onclick="loadHistory(_histPage-1)" id="histPrev" class="px-2 py-1 bg-gray-100 dark:bg-gray-800 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-[var(--bg-surface)] rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
<button onclick="loadHistory(_histPage+1)" id="histNext" class="px-2 py-1 bg-gray-100 dark:bg-gray-800 rounded hover:bg-slate-700 disabled:opacity-30">下一页</button>
</div>
</div>
<!-- Verify Result Section -->
<div id="verifySection" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-4">
<div id="verifySection" class="hidden mt-6 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 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-[var(--text-muted)] hover:text-slate-300">✕ 关闭</button>
<button onclick="document.getElementById('verifySection').classList.add('hidden')" class="text-xs text-gray-400 dark:text-gray-500 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-[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="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-xl w-[640px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<span class="text-[var(--text-secondary)]">📄</span>
<span class="text-gray-500 dark:text-gray-400">📄</span>
<span id="previewFileName" class="text-sm text-slate-200 font-medium"></span>
<span id="previewFileSize" class="text-xs text-[var(--text-muted)]"></span>
<span id="previewFileSize" class="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">&times;</button>
<button onclick="document.getElementById('filePreviewModal').classList.add('hidden')" class="text-gray-400 dark:text-gray-500 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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-md p-6 space-y-4">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[var(--text-secondary)]">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
<p class="text-sm text-gray-500 dark:text-gray-400">将当前推送配置保存为一次性调度任务,到指定时间自动执行。</p>
<div>
<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">
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">执行时间 *</label>
<input id="schedFireAt" type="datetime-local" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm">
</div>
<div id="schedSummary" class="text-xs text-[var(--text-muted)] bg-gray-100/50 dark:bg-gray-800/50 rounded-lg px-3 py-2"></div>
<div id="schedSummary" class="text-xs text-gray-400 dark:text-gray-500 bg-gray-100/50 dark:bg-gray-800/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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-md p-6 space-y-4">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[var(--text-muted)] hover:text-white text-lg">&times;</button>
<button onclick="document.getElementById('diagModal').classList.add('hidden')" class="text-gray-400 dark:text-gray-500 hover:text-white text-lg">&times;</button>
</div>
<div id="diagServerName" class="text-sm text-[var(--text-secondary)]"></div>
<div id="diagServerName" class="text-sm text-gray-500 dark:text-gray-400"></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-[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="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-xl w-[720px] max-h-[80vh] flex flex-col">
<div class="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-2">
<span class="text-[var(--text-secondary)]">📄</span>
<span class="text-gray-500 dark:text-gray-400">📄</span>
<span id="diffFileName" class="text-sm text-slate-200 font-medium"></span>
<span id="diffFileInfo" class="text-xs text-[var(--text-muted)]"></span>
<span id="diffFileInfo" class="text-xs text-gray-400 dark:text-gray-500"></span>
</div>
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-[var(--text-muted)] hover:text-white text-lg">&times;</button>
<button onclick="document.getElementById('diffModal').classList.add('hidden')" class="text-gray-400 dark:text-gray-500 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-[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)]';
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-gray-500 dark:text-gray-400';
statusEl.textContent='已取消';statusEl.className='text-xs text-gray-500 dark:text-gray-400';
detailEl.textContent='推送已取消';detailEl.classList.remove('hidden');detailEl.className='text-xs text-gray-400 dark:text-gray-500';
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-[var(--text-muted)]">加载中...</div>';
document.getElementById('fmEntries').innerHTML='<div class="px-3 py-2 text-xs text-gray-400 dark:text-gray-500">加载中...</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-[var(--text-secondary)]">📁</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-gray-500 dark:text-gray-400">📁</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-[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>`;
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-gray-400 dark:text-gray-500 hover:text-slate-300 px-1" title="重命名"></button><button onclick="event.stopPropagation();deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-gray-400 dark:text-gray-500 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-[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>`;
html+=`<div class="group flex items-center gap-2 px-3 py-1.5 text-xs"><span class="text-gray-400 dark:text-gray-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-gray-300 dark:text-gray-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-gray-400 dark:text-gray-500 hover:text-slate-300 px-1" title="重命名"></button><button onclick="deleteLocalFile('${esc(fullPath)}','${esc(e.name)}')" class="text-gray-400 dark:text-gray-500 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-[var(--text-muted)]">空目录</div>';
if(!dirs.length&&!files.length)html='<div class="px-3 py-2 text-xs text-gray-400 dark:text-gray-500">空目录</div>';
document.getElementById('fmEntries').innerHTML=html;
}
@@ -589,12 +589,12 @@
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-gray-100/50 dark:bg-gray-800/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="shrink-0 w-5 h-5 flex items-center justify-center text-gray-500 dark:text-gray-400" id="srv_icon_${id}"></span>
<span class="text-sm text-slate-300 flex-1">${esc(s?.name||'服务器#'+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>
<span class="text-xs text-gray-400 dark:text-gray-500" id="srv_status_${id}">等待中</span>
<span class="text-xs text-gray-300 dark:text-gray-600 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-[var(--text-secondary)] hover:text-amber-400 shrink-0" onclick="diagnoseServer(${id})">🔍 诊断</button>
<button id="srv_diag_${id}" class="hidden text-xs text-gray-500 dark:text-gray-400 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-[var(--text-secondary)]';
statusEl.textContent='已取消';statusEl.className='text-xs text-[var(--text-secondary)]';
iconEl.textContent='⊘';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-gray-500 dark:text-gray-400';
statusEl.textContent='已取消';statusEl.className='text-xs text-gray-500 dark:text-gray-400';
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-[var(--text-muted)] text-sm text-center py-4">诊断中...</div>';
document.getElementById('diagResults').innerHTML='<div class="text-gray-400 dark:text-gray-500 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-[var(--text-secondary)]':'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-gray-500 dark:text-gray-400':'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-[var(--text-muted)] text-center py-4">文件内容相同,无差异</div>';return}
if(!lines.length){document.getElementById('diffContent').innerHTML='<div class="text-gray-400 dark:text-gray-500 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-[var(--text-muted)] pl-2">${esc(l.content)}</div>`;
return `<div class="text-gray-400 dark:text-gray-500 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-[var(--text-muted)] text-sm text-center py-4">校验中...</div>';
document.getElementById('verifyResults').innerHTML='<div class="text-gray-400 dark:text-gray-500 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)});
@@ -788,7 +788,7 @@
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-gray-100/50 dark:bg-gray-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-[var(--text-muted)] ml-2">${v.matched} 匹配${v.missing>0?`, ${v.missing} 缺失`:''}${v.mismatched>0?`, ${v.mismatched} 不一致`:''}</span>${detail}</div>`;
return `<div class="bg-gray-100/50 dark:bg-gray-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-gray-400 dark:text-gray-500 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-[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.files_transferred!=null)detail+=`<div class="text-xs text-gray-500 dark:text-gray-400">传输: ${l.files_transferred} 文件</div>`;
if(l.bytes_transferred!=null&&l.bytes_transferred>0)detail+=`<div class="text-xs text-gray-500 dark:text-gray-400">大小: ${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-[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>`;
if(l.diff_summary)detail+=`<details class="mt-1"><summary class="text-xs text-gray-400 dark:text-gray-500 cursor-pointer hover:text-slate-300">查看 diff</summary><pre class="text-xs text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-900 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-[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-gray-100/50 dark:bg-gray-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-[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>
const statusCls=cancelled?'text-gray-500 dark:text-gray-400':ok?'text-green-400':'text-red-400';
return `<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 ${border} px-4 py-3 cursor-pointer hover:bg-gray-100/50 dark:bg-gray-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-gray-300 dark:text-gray-600 mx-1">·</span><span class="text-xs text-gray-400 dark:text-gray-500">${esc(l.operator||'system')}</span><span class="text-gray-300 dark:text-gray-600 mx-1">·</span><span class="text-xs text-gray-400 dark:text-gray-500">${esc(l.sync_mode||'file')}</span></div></div><div class="text-xs text-gray-400 dark:text-gray-500">${fmtTime(l.started_at)}</div></div>
<div class="text-xs text-gray-400 dark:text-gray-500 mt-1 truncate">${summary}</div>
<div class="hist-detail hidden mt-2 space-y-1 border-t border-gray-300/50 dark:border-gray-600/50 pt-2">${detail}</div>
</div>`;
}).join(''):'<div class="text-[var(--text-muted)] text-center py-6 text-sm">暂无推送记录</div>';
}).join(''):'<div class="text-gray-400 dark:text-gray-500 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-[var(--text-muted)] text-center py-6 text-sm">加载失败</div>'}
}catch(e){document.getElementById('pushHistory').innerHTML='<div class="text-gray-400 dark:text-gray-500 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-[var(--text-muted)] text-xs col-span-2">正在运行 rsync --dry-run...</span>';
document.getElementById('previewStats').innerHTML='<span class="text-gray-400 dark:text-gray-500 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-[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>`:'')}`;
<div class="bg-white dark:bg-gray-900 rounded-lg px-3 py-2"><div class="text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-500 text-xs">${fmtBytes(s.transfer_size_bytes)}</div>`:''}</div>
<div class="bg-white dark:bg-gray-900 rounded-lg px-3 py-2"><div class="text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-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-white dark:bg-gray-900 rounded-lg px-3 py-2"><div class="text-gray-500 dark:text-gray-400 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');
+14 -14
View File
@@ -2,24 +2,24 @@
<script src="/app/vendor/theme-init.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>
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 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 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 class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<div class="flex items-center gap-3">
<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">
<select id="statusFilter" onchange="loadRetries()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="loadRetries()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 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-[var(--text-muted)] text-center py-8">加载中...</div></div>
<div id="retryList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
</main>
</div>
<script src="/app/api.js"></script>
@@ -43,7 +43,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-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
return `<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-sm">${statusIcon}</span>
@@ -51,17 +51,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-[var(--text-secondary)]">重试 ${j.retry_count}/${j.max_retries}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">重试 ${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-[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>`:''}
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">${esc(j.source_path)} → ${esc(j.target_path||'')}</div>
${j.operator?`<div class="mt-1 text-xs text-gray-300 dark:text-gray-600">操作人: ${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-[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>';
<div class="mt-2 w-full bg-gray-100 dark:bg-gray-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-gray-300 dark:text-gray-600">下次重试: ${fmtTime(j.next_retry_at)}</div>`:''}
</div>`}).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无重试任务</div>';
}catch(e){document.getElementById('retryList').innerHTML='<div class="text-red-400 text-center py-8">加载失败</div>'}
}
+44 -44
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 src="/app/vendor/theme-init.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>
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0"><header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-3"><div class="flex items-center justify-between"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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-gray-100 dark:bg-gray-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>
<main class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- ── 直接执行(无需保存到脚本库)── -->
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border)] overflow-hidden">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<button onclick="toggleQuickExec()" id="quickExecToggleBtn"
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-[var(--bg-surface)]/50 transition group">
class="w-full flex items-center justify-between px-5 py-3 text-left hover:bg-gray-100/50 dark:hover:bg-gray-800/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-[var(--text-muted)] text-xs">(临时命令,不保存到脚本库)</span>
<span class="text-gray-400 dark:text-gray-500 text-xs">(临时命令,不保存到脚本库)</span>
</div>
<svg id="quickExecChevron" class="w-4 h-4 text-[var(--text-muted)] transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg id="quickExecChevron" class="w-4 h-4 text-gray-400 dark:text-gray-500 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-[var(--border)] p-5 space-y-4">
<div id="quickExecPanel" class="hidden border-t border-gray-200 dark:border-gray-700 p-5 space-y-4">
<!-- Command input -->
<div>
<label class="block text-[var(--text-secondary)] text-xs mb-1.5">命令内容 <span class="text-[var(--text-dim)]">(支持多行 Shell 脚本)</span></label>
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1.5">命令内容 <span class="text-gray-300 dark:text-gray-600">(支持多行 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-[var(--bg-page)] border border-[var(--border-input)] rounded-xl text-green-400 text-sm font-mono
class="w-full px-4 py-3 bg-gray-50 dark:bg-gray-950 border border-gray-300 dark:border-gray-600 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-[var(--text-dim)] text-xs mt-1">Ctrl + Enter 快捷执行</p>
<p class="text-gray-300 dark:text-gray-600 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-[var(--text-secondary)] text-xs">目标服务器</label>
<label class="text-gray-500 dark:text-gray-400 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-[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>
<select id="quickServers" multiple class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm h-28"></select>
<p id="quickBatchHint" class="text-gray-300 dark:text-gray-600 text-xs mt-1"></p>
</div>
<div class="space-y-3">
<div>
<label class="block text-[var(--text-secondary)] text-xs mb-1">超时 (秒)</label>
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">超时 (秒)</label>
<input id="quickTimeout" 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">
class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm">
</div>
<div>
<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">
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">DB 凭据(可选)</label>
<select id="quickCredential" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded-lg transition">
<button onclick="document.getElementById('quickCmd').value=''" class="px-3 py-2.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-xs rounded-lg transition">
清空
</button>
<span class="text-[var(--text-dim)] text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
<span class="text-gray-300 dark:text-gray-600 text-xs">命令不会被保存;执行结果在下方「批量执行状态」查看</span>
</div>
</div>
</div>
<!-- ── 执行状态 ── -->
<div id="execStatusPanel" class="hidden bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-4">
<div id="execStatusPanel" class="hidden bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 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-[var(--text-muted)] text-center py-8">加载中...</div></div>
<div id="scriptList" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-8">加载中...</div></div>
<!-- Add/Edit Script Form -->
<div id="scriptForm" class="hidden mt-6 bg-[var(--bg-card)] rounded-xl border border-[var(--border)] p-6 space-y-3">
<div id="scriptForm" class="hidden mt-6 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 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-[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>
<input id="scriptName" placeholder="脚本名称" class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white text-sm">
<input id="scriptCategory" placeholder="分类 (ops/deploy/check)" class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-xl text-white text-sm">
<textarea id="scriptContent" rows="10" placeholder="Shell 命令内容" class="w-full px-4 py-3 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-gray-100 dark:bg-gray-800 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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[var(--text-secondary)] text-sm"></p>
<p id="execScriptName" class="text-gray-500 dark:text-gray-400 text-sm"></p>
<div>
<div class="flex items-center justify-between mb-1">
<label class="block text-[var(--text-secondary)] text-xs">选择服务器</label>
<label class="block text-gray-500 dark:text-gray-400 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-[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>
<select id="execServers" multiple class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm h-32"></select>
<p id="execBatchHint" class="text-gray-400 dark:text-gray-500 text-xs mt-1"></p>
</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>
<div><label class="block text-gray-500 dark:text-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm"><p class="text-gray-400 dark:text-gray-500 text-xs mt-1">仅限制「点火」命令;长任务勾选后 60~120 秒即可</p></div>
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">DB 凭据(可选,替换命令中的 $DB_*)</label><select id="execCredential" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--text-secondary)]">nohup</code>,日志在子机 <code class="text-[var(--text-secondary)]">/var/log/nexus-job/</code></span>
<span class="text-sm text-slate-300">长任务(后台 <code class="text-gray-500 dark:text-gray-400">nohup</code>,日志在子机 <code class="text-gray-500 dark:text-gray-400">/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-[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>
<p class="text-gray-400 dark:text-gray-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-gray-100 dark:bg-gray-800 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-[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 logBlock=t.logExpanded?`<pre class="mt-2 p-3 bg-white dark:bg-gray-900 rounded-lg text-xs text-slate-300 font-mono max-h-72 overflow-auto whitespace-pre-wrap border border-gray-200 dark:border-gray-700">${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-[var(--bg-page)] border border-[var(--border)] p-3">
return `<div class="rounded-lg bg-gray-50 dark:bg-gray-950 border border-gray-200 dark:border-gray-700 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-[var(--text-muted)] text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-[var(--text-dim)]">${esc(ids)}</span>`:''}</div>
<div class="text-gray-400 dark:text-gray-500 text-xs mt-0.5">${esc(execProgressDetail(t))}${ids?` <span class="text-gray-300 dark:text-gray-600">${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-[var(--text-secondary)] hover:bg-[var(--bg-surface)]">标记卡住</button>`:''}
${showStuck?`<button type="button" onclick="markStuckJob('${t.id}')" class="text-xs px-2 py-1 rounded border border-slate-600 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800">标记卡住</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-[var(--border-input)] text-slate-300 hover:bg-[var(--bg-surface)] disabled:opacity-40">刷新状态</button>
<button type="button" onclick="refreshJobStatusLogs('${t.id}',false)" ${btnDis} class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-slate-300 hover:bg-gray-100 dark:hover:bg-gray-800 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-[var(--border-input)] text-[var(--text-secondary)] hover:bg-[var(--bg-surface)]">${t.logExpanded?'收起':'展开'}</button>`:''}
${t.logText?`<button type="button" onclick="toggleJobLog('${t.id}')" class="text-xs px-2 py-1 rounded border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800">${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-[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){}}
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-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 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-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-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-gray-500 dark:text-gray-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-gray-500 dark:text-gray-400 font-mono bg-gray-50 dark:bg-gray-950 rounded-lg p-3 overflow-x-auto max-h-32">${esc(s.content?.substring(0,300)||'')}</pre></div>`).join(''):'<div class="text-gray-400 dark:text-gray-500 text-center py-8">暂无脚本</div>'}catch(e){}}
function showNewScript(){
document.getElementById('editScriptId').value='';
+100 -100
View File
@@ -4,42 +4,42 @@
.detail-panel.open{max-height:800px}
</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>
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 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 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 class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<div class="flex items-center gap-2 flex-wrap">
<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">
<select id="categoryFilter" onchange="loadServers(true)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm">
<select id="statusFilter" onchange="_statusFilter=this.value;loadServers(true)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[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>
<input id="searchInput" type="text" placeholder="搜索名称/地址..." autocomplete="off" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-sm rounded-lg transition" title="自动刷新">自动刷新</button>
<button onclick="loadServers()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">刷新</button>
<button onclick="exportServersCSV()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">↑ 导出</button>
<button onclick="showImportModal()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 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-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition" title="快捷键">?</button>
<button onclick="document.getElementById('shortcutHelp').classList.toggle('hidden')" class="px-2 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-sm rounded-lg transition" title="快捷键">?</button>
</div>
</header>
<main class="flex-1 overflow-y-auto p-6">
<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-gray-100/50 dark:bg-gray-800/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 id="serversTable" class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table class="w-full text-sm"><thead class="bg-gray-100/50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-500">加载中...</td></tr></tbody></table>
</div>
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-[var(--text-muted)]">
<div id="pagination" class="mt-4 flex items-center justify-between text-xs text-gray-400 dark:text-gray-500">
<span id="serverCount">共 -- 台</span>
<div class="flex items-center gap-2">
<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>
<button onclick="goToPage(1)" id="firstBtn" class="px-2 py-1 bg-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-800 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-[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">
<button onclick="goToPage(_currentPage+1)" id="nextBtn" class="px-3 py-1 bg-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--text-secondary)] hover:text-white text-xs underline">取消选择</button>
<button onclick="clearSelection()" class="text-gray-500 dark:text-gray-400 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-[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-gray-100/30 dark:bg-gray-800/30">
<div id="detailPanel" class="hidden mt-4 bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-100/30 dark:bg-gray-800/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-[var(--text-secondary)] text-sm"></span>
<span id="detailAddr" class="text-gray-500 dark:text-gray-400 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-[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>
<button onclick="showEditServer(selectedServerId)" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-sm rounded-lg transition">编辑</button>
<button onclick="hideDetail()" class="px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">关闭</button>
</div>
</div>
<!-- Tabs -->
<div class="flex border-b border-[var(--border)] px-6">
<div class="flex border-b border-gray-200 dark:border-gray-700 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-[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>
<button onclick="showDetailTab('sync')" id="tabSync" class="px-4 py-3 text-sm border-b-2 border-transparent text-gray-500 dark:text-gray-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-gray-500 dark:text-gray-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-gray-500 dark:text-gray-400 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-gray-100/50 dark:bg-gray-800/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-gray-100/50 dark:bg-gray-800/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-gray-100/50 dark:bg-gray-800/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-gray-100/50 dark:bg-gray-800/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-gray-100/50 dark:bg-gray-800/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 class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-4"><div class="text-gray-400 dark:text-gray-500 text-xs mb-1">CPU使用率</div><div id="siCPU" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-4"><div class="text-gray-400 dark:text-gray-500 text-xs mb-1">内存使用率</div><div id="siMem" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-4"><div class="text-gray-400 dark:text-gray-500 text-xs mb-1">磁盘使用率</div><div id="siDisk" class="text-2xl font-bold text-white">--</div></div>
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-4"><div class="text-gray-400 dark:text-gray-500 text-xs mb-1">发行版</div><div id="siOSRelease" class="text-lg font-medium text-white truncate">--</div></div>
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-4"><div class="text-gray-400 dark:text-gray-500 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-[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 id="siDescRow"><span class="text-gray-400 dark:text-gray-500">描述:</span><span id="siDesc" class="text-slate-300"></span></div>
<div id="siTargetRow"><span class="text-gray-400 dark:text-gray-500">目标路径:</span><code id="siTarget" class="text-slate-300 text-xs"></code></div>
<div id="siAuthRow"><span class="text-gray-400 dark:text-gray-500">认证方式:</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-[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>
<table class="w-full text-sm"><thead class="text-gray-400 dark:text-gray-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-gray-400 dark:text-gray-500">加载中...</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-[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>
<table class="w-full text-sm"><thead class="text-gray-400 dark:text-gray-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-gray-400 dark:text-gray-500">加载中...</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-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<div id="agentInstallContent"><div class="text-gray-400 dark:text-gray-500 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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3 my-auto">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[var(--text-muted)] text-xs uppercase tracking-wider mb-1">基本</div>
<div class="text-gray-400 dark:text-gray-500 text-xs uppercase tracking-wider mb-1">基本</div>
<div class="grid grid-cols-2 gap-3">
<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><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">名称 *</label><input id="srvName" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm" placeholder="web-server-01"></div>
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">地址 *</label><input id="srvDomain" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[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><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">SSH端口</label><input id="srvPort" type="number" value="22" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm"></div>
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">用户名</label><input id="srvUser" value="root" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm"></div>
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">认证方式</label><select id="srvAuth" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[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><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">密码预设</label><select id="srvPreset" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
<div id="srvPasswordRow"><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">密码</label><input id="srvPassword" type="password" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[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><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">密钥预设</label><select id="srvKeyPreset" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm"><option value="">— 手动输入 —</option></select></div>
<div id="srvKeyPathRow"><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">密钥路径</label><input id="srvKeyPath" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm" placeholder="/root/.ssh/id_rsa"></div>
</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 id="srvKeyPrivateRow"><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">私钥内容</label><textarea id="srvKeyPrivate" rows="4" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm font-mono" placeholder="粘贴 PEM 格式私钥内容(可选,优先于路径)"></textarea><p class="text-gray-300 dark:text-gray-600 text-[10px] mt-1">填入私钥内容后无需指定路径,系统自动加密存储</p></div>
</div>
<!-- Agent -->
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mt-3 mb-1">Agent</div>
<div class="text-gray-400 dark:text-gray-500 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-[var(--text-secondary)] text-xs mb-1">Agent API Key</label>
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">Agent API Key</label>
<!-- Add mode: auto-generate notice -->
<div id="agentKeyAddMode" class="px-3 py-2 bg-gray-100/50 dark:bg-gray-800/50 border border-[var(--border-input)] rounded-lg text-[var(--text-muted)] text-xs">创建时自动生成</div>
<div id="agentKeyAddMode" class="px-3 py-2 bg-gray-100/50 dark:bg-gray-800/50 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-400 dark:text-gray-500 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-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm text-xs" placeholder="留空则使用全局 API Key" readonly>
<input id="srvAgentKey" class="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--text-dim)] text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
<p class="text-gray-300 dark:text-gray-600 text-[10px] mt-1">点击🔄重新生成(旧密钥将失效)</p>
</div>
</div>
</div>
<!-- Organization -->
<div class="text-[var(--text-muted)] text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
<div class="text-gray-400 dark:text-gray-500 text-xs uppercase tracking-wider mt-3 mb-1">组织</div>
<div class="grid grid-cols-3 gap-3">
<div>
<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">
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">分类</label>
<select id="srvCategorySelect" onchange="_onCategoryChange()" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm">
<option value="">— 无分类 —</option>
</select>
<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="输入自定义分类">
<input id="srvCategory" class="hidden w-full mt-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">目标路径</label><input id="srvTarget" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm" placeholder="/www/wwwroot"></div>
<div><label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">备注</label><input id="srvDesc" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-xl p-6 w-full max-w-lg space-y-3">
<h2 class="text-white font-semibold text-lg">批量导入服务器</h2>
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-[var(--text-secondary)] space-y-1">
<div class="bg-gray-100/50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-gray-500 dark:text-gray-400 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-[var(--text-secondary)] text-xs mb-1">选择 CSV 文件</label>
<label class="block text-gray-500 dark:text-gray-400 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-[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="importProgress" class="hidden"><div class="bg-gray-100 dark:bg-gray-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-gray-500 dark:text-gray-400 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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 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-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button>
<button onclick="hideImportModal()" class="px-4 py-2 bg-gray-100 dark:bg-gray-800 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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl p-6 w-full max-w-lg space-y-3">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[var(--text-secondary)] 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-gray-500 dark:text-gray-400 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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm">关闭</button></div>
</div>
</div>
@@ -336,10 +336,10 @@
<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-[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-gray-500 dark:text-gray-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">${esc(s.category||'--')}</td>
<td class="px-4 py-3 text-gray-500 dark:text-gray-400">${esc(s.agent_version)||'--'}</td>
<td class="px-4 py-3 text-gray-400 dark:text-gray-500 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-[var(--text-muted)]">未安装</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-gray-400 dark:text-gray-500">未安装</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-[var(--text-secondary)] hover:underline text-xs mr-2">编辑</button>`;
html+=`<button onclick="event.stopPropagation();showEditServer(${s.id})" class="text-gray-500 dark:text-gray-400 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-[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-gray-400 dark:text-gray-500 text-4xl mb-3">🔍</div><div class="text-gray-500 dark:text-gray-400 mb-1">没有匹配的服务器</div><div class="text-gray-300 dark:text-gray-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-gray-400 dark:text-gray-500 text-4xl mb-3">🖥</div><div class="text-gray-500 dark:text-gray-400 mb-1">暂无服务器</div><div class="text-gray-300 dark:text-gray-600 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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 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-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-sm rounded-lg transition';
btn.className='px-3 py-1.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 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-[var(--text-muted)]">暂无同步记录</td></tr>';return}
if(!logs.length){tbody.innerHTML='<tr><td colspan="5" class="py-4 text-center text-gray-400 dark:text-gray-500">暂无同步记录</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-gray-200/50 dark:border-gray-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-[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('');
return `<tr class="border-b border-gray-200/50 dark:border-gray-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-gray-400 dark:text-gray-500 text-xs max-w-xs truncate">${esc(l.source_path||'--')}</td><td class="py-2 text-gray-400 dark:text-gray-500">${l.duration_seconds!=null?l.duration_seconds+'s':'--'}</td><td class="py-2 text-gray-300 dark:text-gray-600 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-[var(--text-muted)]">暂无SSH会话</td></tr>';return}
if(!sessions.length){tbody.innerHTML='<tr><td colspan="4" class="py-4 text-center text-gray-400 dark:text-gray-500">暂无SSH会话</td></tr>';return}
tbody.innerHTML=sessions.map(s=>{
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-[var(--text-muted)]':'text-yellow-400';
return `<tr class="border-b border-gray-200/50 dark:border-gray-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-[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('');
const sc=s.status==='active'?'text-green-400':s.status==='closed'?'text-gray-400 dark:text-gray-500':'text-yellow-400';
return `<tr class="border-b border-gray-200/50 dark:border-gray-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-gray-400 dark:text-gray-500 text-xs">${fmtTime(s.started_at)}</td><td class="py-2 text-gray-300 dark:text-gray-600 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-[var(--text-secondary)] hover:text-white transition'}
else{btn.className='px-4 py-3 text-sm border-b-2 border-transparent text-gray-500 dark:text-gray-400 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-[var(--bg-surface)] text-[var(--text-muted)] border border-[var(--border-input)]">○ Agent 离线 / 未安装</span>';
:'<span class="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 border border-gray-300 dark:border-gray-600">○ 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-[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>`
?`<div class="space-y-2"><label class="block text-gray-500 dark:text-gray-400 text-xs">在子机上执行(需 root 权限)</label><div class="flex items-start gap-2"><code id="agentCmdText" class="flex-1 block px-3 py-3 bg-gray-50 dark:bg-gray-950 border border-gray-300 dark:border-gray-600 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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg transition" title="复制命令">📋</button></div><p class="text-gray-300 dark:text-gray-600 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-[var(--text-muted)] text-sm">(请先配置 URL 和 API Key</div>`;
:`<div class="text-gray-400 dark:text-gray-500 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-[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-gray-100/30 dark:bg-gray-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-[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>`:''}`;
?`<button disabled class="px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 text-sm rounded-lg cursor-not-allowed">⚡ 远程安装(已在线)</button>`
:`<button disabled class="px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-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-gray-300 dark:border-gray-600 bg-gray-100/30 dark:bg-gray-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-gray-400 dark:text-gray-500 text-xs">安装约 30-60 秒;首次心跳约 60 秒后自动更新状态</span></div><div id="remoteInstallLog" class="hidden rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-950 p-3 max-h-56 overflow-y-auto"><pre id="remoteInstallLogText" class="text-xs text-gray-500 dark:text-gray-400 whitespace-pre-wrap"></pre></div>${!s.is_online?`<div class="flex items-center gap-2 text-xs text-gray-400 dark:text-gray-500"><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-[var(--bg-surface)] text-[var(--text-muted)] text-sm rounded-lg cursor-not-allowed'}
if(btn){btn.disabled=true;btn.textContent='⚡ 远程安装(已在线)';btn.className='px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500 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-[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>`;
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-gray-500 dark:text-gray-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-gray-400 dark:text-gray-500 truncate" title="${esc(r.error)}">${esc(r.error)}</span></div>`;
}).join('');
}
+60 -60
View File
@@ -1,14 +1,14 @@
<!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 src="/app/vendor/theme-init.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-gray-50 dark:bg-gray-950 text-[var(--text-primary)] min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
<body class="bg-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex" x-data="{sidebarOpen:true,totpStep:'idle',totpSecret:'',totpUri:''}">
<aside x-show="sidebarOpen" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 flex flex-col shrink-0" data-sidebar></aside>
<div class="flex-1 flex flex-col min-w-0">
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 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-4"><button @click="sidebarOpen=!sidebarOpen" class="text-gray-500 dark:text-gray-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>
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full space-y-4">
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-4">
<div class="text-[var(--text-secondary)] text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
<div class="text-gray-500 dark:text-gray-400 text-xs">以下为可修改的运行时配置。SECRET_KEY / API_KEY / DATABASE_URL 为安全锁定项,不可通过此页面修改。</div>
</div>
<!-- ── Account Security ── -->
@@ -22,7 +22,7 @@
<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-gray-100 dark:bg-gray-800 text-[var(--text-muted)]">检测中...</span>
<span id="totpStatusBadge" class="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500">检测中...</span>
</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-[var(--text-muted)] hover:text-white text-xs transition">✕ 取消</button>
<button onclick="cancelTotpSetup()" class="text-gray-400 dark:text-gray-500 hover:text-white text-xs transition">✕ 取消</button>
</div>
<!-- 步骤说明 -->
<div class="text-[var(--text-secondary)] text-xs space-y-1">
<div class="text-gray-500 dark:text-gray-400 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>
@@ -70,21 +70,21 @@
<!-- QR + 密钥 -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-start">
<div class="bg-white dark:bg-gray-900 rounded-lg p-4 text-center border border-gray-300 dark:border-gray-700">
<p class="text-[var(--text-muted)] text-xs mb-3">扫描二维码</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mb-3">扫描二维码</p>
<canvas id="totpQrCanvas" class="mx-auto rounded" width="180" height="180"></canvas>
<p id="totpQrFallback" class="hidden text-[var(--text-muted)] text-xs mt-2">二维码加载失败,请手动输入密钥</p>
<p id="totpQrFallback" class="hidden text-gray-400 dark:text-gray-500 text-xs mt-2">二维码加载失败,请手动输入密钥</p>
</div>
<div class="space-y-3">
<div>
<label class="block text-[var(--text-secondary)] text-xs mb-1">手动输入密钥</label>
<label class="block text-gray-500 dark:text-gray-400 text-xs mb-1">手动输入密钥</label>
<div class="flex items-center gap-2">
<code id="totpSecretDisplay" class="flex-1 px-3 py-2 bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition" title="复制密钥">📋</button>
</div>
<p class="text-[var(--text-dim)] text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
<p class="text-gray-300 dark:text-gray-600 text-xs mt-1">密钥即使截图也无法替代 App 扫码,请妥善保管</p>
</div>
<div>
<label class="block text-[var(--text-secondary)] text-xs mb-1">输入 App 中的验证码</label>
<label class="block text-gray-500 dark:text-gray-400 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-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-lg font-mono text-center tracking-[0.3em] focus:outline-none focus:ring-2 focus:ring-brand"
@@ -92,7 +92,7 @@
<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-[var(--text-muted)] hover:text-slate-300 transition">🔄 重新生成二维码</button>
<button onclick="startTotpSetup(_isRebind)" class="text-xs text-gray-400 dark:text-gray-500 hover:text-slate-300 transition">🔄 重新生成二维码</button>
</div>
</div>
</div>
@@ -102,7 +102,7 @@
<!-- Change Password -->
<div>
<span class="text-white text-sm font-medium">修改密码</span>
<p class="text-[var(--text-secondary)] text-xs mb-3">修改当前账户的登录密码。已启用 TOTP 时需要验证码。</p>
<p class="text-gray-500 dark:text-gray-400 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
@@ -116,31 +116,31 @@
<!-- Brand Settings -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-white font-medium mb-4">品牌</h2>
<div id="brandSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<div id="brandSection" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- API Key (read-only with copy) -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-white font-medium mb-4">API 密钥</h2>
<div id="apiKeySection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<div id="apiKeySection" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Alert Thresholds -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-white font-medium mb-4">告警阈值 (%)</h2>
<div id="alertSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<div id="alertSection" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Infrastructure -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-white font-medium mb-4">基础设施</h2>
<div id="infraSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<div id="infraSection" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 text-center py-4 text-sm">加载中...</div></div>
</div>
<!-- Telegram -->
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6 space-y-4">
<h2 class="text-white font-medium">Telegram 通知</h2>
<div id="telegramSection" class="space-y-3"><div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div></div>
<div id="telegramSection" class="space-y-3"><div class="text-gray-400 dark:text-gray-500 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-gray-200 dark:border-gray-800">
<button onclick="telegramTest()" id="telegramTestBtn"
@@ -151,7 +151,7 @@
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-[var(--text-dim)] text-xs">检测前请先向 Bot 发一条消息</span>
<span class="text-gray-300 dark:text-gray-600 text-xs">检测前请先向 Bot 发一条消息</span>
</div>
<!-- Chat list -->
<div id="telegramChatList" class="hidden space-y-2"></div>
@@ -162,11 +162,11 @@
<div class="flex items-center justify-between">
<div>
<h2 class="text-white font-medium">登录 IP 白名单</h2>
<p class="text-[var(--text-muted)] text-xs mt-0.5">开启后仅白名单 IP 可登录后台;关闭则不限制来源 IP</p>
<p class="text-gray-400 dark:text-gray-500 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-gray-100 dark:bg-gray-800 text-[var(--text-muted)]">加载中</span>
<span id="ipAllowlistStatus" class="text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500">加载中</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-gray-300 dark:border-gray-700 bg-slate-800/40 px-4 py-3 text-[var(--text-secondary)] text-xs">
<div id="allowlistDisabledNotice" class="rounded-lg border border-gray-300 dark:border-gray-700 bg-slate-800/40 px-4 py-3 text-gray-500 dark:text-gray-400 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">
@@ -187,7 +187,7 @@
<div class="rounded-xl border border-gray-300 dark:border-gray-700 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-[var(--text-dim)] text-xs"></span>
<span id="lastRefreshTime" class="text-gray-300 dark:text-gray-600 text-xs"></span>
</div>
<div class="flex gap-2">
<input id="subUrl" type="url" placeholder="https://your-subscription-url..."
@@ -201,7 +201,7 @@
🔄
</button>
</div>
<p class="text-[var(--text-dim)] text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
<p class="text-gray-300 dark:text-gray-600 text-xs">支持 SS / VMess / VLESS / Trojan / Hysteria2 订阅 · 系统启动后 + 每 2 小时自动拉取更新节点 IP</p>
</div>
<!-- 解析结果预览 -->
@@ -211,17 +211,17 @@
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-[var(--text-dim)] text-xs">(不更改白名单,仅查看)</span>
<span class="text-gray-300 dark:text-gray-600 text-xs">(不更改白名单,仅查看)</span>
</div>
</div>
<!-- 解析结果 (待确认) -->
<div id="parsedIpsSection" class="hidden space-y-2">
<div class="flex items-center justify-between">
<label class="text-[var(--text-secondary)] text-xs">解析到的节点 IP(勾选要加入白名单的)</label>
<label class="text-gray-500 dark:text-gray-400 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-[var(--text-muted)] text-xs hover:underline">全不选</button>
<button onclick="selectAllParsed(false)" class="text-gray-400 dark:text-gray-500 text-xs hover:underline">全不选</button>
</div>
</div>
<div id="parsedIpsList" class="max-h-40 overflow-y-auto rounded-lg border border-gray-300 dark:border-gray-700 bg-gray-50 dark:bg-gray-950 p-2 space-y-1"></div>
@@ -233,7 +233,7 @@
<!-- 手动输入 -->
<div class="space-y-2">
<label class="block text-[var(--text-secondary)] text-xs">手动添加 IP / CIDR / 域名(每行一条)</label>
<label class="block text-gray-500 dark:text-gray-400 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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 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">
@@ -244,19 +244,19 @@
<!-- 订阅节点 IP(只读,自动刷新) -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<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>
<label class="text-gray-500 dark:text-gray-400 text-xs">订阅节点 IP <span class="text-gray-300 dark:text-gray-600">(只读,每 2 小时自动替换)</span></label>
<span id="subIpCount" class="text-gray-300 dark:text-gray-600 text-xs"></span>
</div>
<div id="subscriptionIpsList" class="min-h-8 text-[var(--text-muted)] text-xs">加载中...</div>
<div id="subscriptionIpsList" class="min-h-8 text-gray-400 dark:text-gray-500 text-xs">加载中...</div>
</div>
<!-- 手动 IP -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<label class="text-[var(--text-secondary)] text-xs">手动添加的 IP <span class="text-[var(--text-dim)]">(永久保留)</span></label>
<label class="text-gray-500 dark:text-gray-400 text-xs">手动添加的 IP <span class="text-gray-300 dark:text-gray-600">(永久保留)</span></label>
<button onclick="clearManualIps()" class="text-red-400 text-xs hover:underline">清空手动 IP</button>
</div>
<div id="manualIpsList" class="min-h-8 text-[var(--text-muted)] text-xs">加载中...</div>
<div id="manualIpsList" class="min-h-8 text-gray-400 dark:text-gray-500 text-xs">加载中...</div>
</div>
</div>
@@ -264,12 +264,12 @@
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-6">
<div class="flex items-center justify-between mb-1">
<h2 class="text-white font-medium">告警通知项目</h2>
<span class="text-[var(--text-muted)] text-xs">通过 Telegram 推送,可逐项关闭</span>
<span class="text-gray-400 dark:text-gray-500 text-xs">通过 Telegram 推送,可逐项关闭</span>
</div>
<p class="text-[var(--text-muted)] text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
<p class="text-gray-400 dark:text-gray-500 text-xs mb-4">关闭后该类告警仍记录日志,但不发送 Telegram 消息。Telegram Bot Token 未配置时全部静默。</p>
<div class="space-y-1" id="notifyToggles">
<div class="text-[var(--text-muted)] text-center py-4 text-sm">加载中...</div>
<div class="text-gray-400 dark:text-gray-500 text-center py-4 text-sm">加载中...</div>
</div>
</div>
@@ -307,13 +307,13 @@
const isMasked=val.includes('***')||val.includes('...');
if(isMasked){
return `<div class="flex items-center gap-3">
<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>
<label class="text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-500 text-sm select-none">${esc(val)}</span>
<span class="text-gray-300 dark:text-gray-600 text-xs">🔒 安全锁定</span>
</div>`;
}
return `<div class="flex items-center gap-3">
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">${cfg.labels[key]||key}</label>
<label class="text-gray-500 dark:text-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 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>`;
@@ -339,21 +339,21 @@
if(isMasked){
el.innerHTML=`
<div class="flex items-center gap-3">
<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>
<label class="text-gray-500 dark:text-gray-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-gray-400 dark:text-gray-500 text-sm font-mono select-none">${esc(val)}</span>
<button onclick="toggleApiKeyVisibility()" id="apiKeyToggleBtn" class="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
<button onclick="copyApiKey()" class="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
<span class="text-[var(--text-dim)] text-xs">🔒 不可修改</span>
<span class="text-gray-300 dark:text-gray-600 text-xs">🔒 不可修改</span>
</div>
<p class="text-[var(--text-muted)] text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
<p class="text-gray-400 dark:text-gray-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
}else{
el.innerHTML=`
<div class="flex items-center gap-3">
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">API_KEY</label>
<label class="text-gray-500 dark:text-gray-400 text-sm w-36 shrink-0">API_KEY</label>
<code id="apiKeyDisplay" class="flex-1 px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-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-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">复制</button>
</div>
<p class="text-[var(--text-muted)] text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
<p class="text-gray-400 dark:text-gray-500 text-xs mt-2">Agent 子服务器使用此密钥与主服务器通信。请勿泄露。</p>`;
}
}
@@ -364,9 +364,9 @@
const el=document.getElementById('telegramSection');
el.innerHTML=`
<div class="flex items-center gap-3" id="telegramTokenRow">
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Bot Token</label>
<label class="text-gray-500 dark:text-gray-400 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-[var(--text-muted)] text-sm font-mono select-none">${esc(tokenVal)}</span>
?`<span id="tgTokenDisplay" class="flex-1 px-3 py-2 bg-slate-800/50 border border-slate-700/50 rounded-lg text-gray-400 dark:text-gray-500 text-sm font-mono select-none">${esc(tokenVal)}</span>
<button onclick="revealTelegramToken()" id="tgTokenRevealBtn" class="px-3 py-2 bg-gray-100 dark:bg-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono">
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-white text-xs rounded-lg shrink-0 transition">显示</button>
@@ -374,7 +374,7 @@
}
</div>
<div class="flex items-center gap-3">
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Chat ID</label>
<label class="text-gray-500 dark:text-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 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,7 +395,7 @@
// Replace masked display with editable input
const row=document.getElementById('telegramTokenRow');
row.innerHTML=`
<label class="text-[var(--text-secondary)] text-sm w-36 shrink-0">Bot Token</label>
<label class="text-gray-500 dark:text-gray-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-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-lg text-white text-sm font-mono">
<button type="button" onclick="toggleTgTokenVis()" id="tgTokenToggleBtn" class="px-3 py-2 bg-gray-100 dark:bg-gray-800 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>`;
@@ -438,7 +438,7 @@
}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-[var(--text-muted)] 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-gray-400 dark:text-gray-500 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-[var(--text-secondary)] text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
listEl.innerHTML='<p class="text-gray-500 dark:text-gray-400 text-xs">检测到以下对话,点击填入 Chat ID:</p>'+
d.chats.map(c=>`<div class="flex items-center gap-3 px-3 py-2 bg-gray-100 dark:bg-gray-800 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-[var(--text-muted)] text-xs">${esc(c.type)}</span>
<span class="ml-2 text-gray-400 dark:text-gray-500 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-[var(--text-muted)] text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-gray-200 dark:border-gray-800 first:border-0">${esc(item.group)}</div>`;
groupHeader=`<div class="text-gray-400 dark:text-gray-500 text-xs font-semibold uppercase tracking-wide pt-3 pb-1 border-t border-gray-200 dark:border-gray-800 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-[var(--text-muted)] mt-0.5">${esc(item.desc)}</span>
<span class="block text-xs text-gray-400 dark:text-gray-500 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-gray-100 dark:bg-gray-800 text-[var(--text-muted)]';
status.className='text-xs px-2 py-0.5 rounded-full bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500';
}
// 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-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 text-xs font-mono text-[var(--text-secondary)]">${esc(ip)}</span>`).join('')+'</div>'
:'<span class="text-[var(--text-dim)]">(空 — 尚未刷新或未配置订阅)</span>';
?'<div class="flex flex-wrap gap-1.5">'+_subIps.map(ip=>`<span class="px-2 py-0.5 rounded border border-gray-300 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 text-xs font-mono text-gray-500 dark:text-gray-400">${esc(ip)}</span>`).join('')+'</div>'
:'<span class="text-gray-300 dark:text-gray-600">(空 — 尚未刷新或未配置订阅)</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-[var(--text-muted)] hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-[var(--text-dim)]">(空)</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-gray-400 dark:text-gray-500 hover:text-red-400">×</button></span>`).join('')+'</div>'
:'<span class="text-gray-300 dark:text-gray-600">(空)</span>';
}catch(e){console.error(e);toast('error','加载白名单失败')}
}
+47 -46
View File
@@ -1,36 +1,37 @@
<!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">
<!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 src="/app/vendor/theme-init.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-gray-50 dark:bg-gray-950 text-gray-900 dark:text-white min-h-screen flex">
<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>
<aside x-data="{open:true}" x-show="open" x-transition class="w-64 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 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-[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="tabBar" class="bg-white/80 dark:bg-gray-900/80 border-b border-gray-200 dark:border-gray-700 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-[var(--text-muted)] 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-gray-400 dark:text-gray-500 hover:text-slate-300 text-xs rounded transition ml-1" title="新会话">+</button>
</div>
<!-- Toolbar -->
<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">
<header class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 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-[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 id="connPath" class="text-gray-400 dark:text-gray-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-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500">
<span class="w-1.5 h-1.5 rounded-full bg-slate-600"></span>未连接
</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>
<span id="connUptime" class="text-gray-300 dark:text-gray-600 text-xs hidden"></span>
<span id="connPing" class="text-gray-300 dark:text-gray-600 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-[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>
<button onclick="changeFontSize(-1)" class="px-1.5 py-0.5 text-gray-400 dark:text-gray-500 hover:text-white text-xs rounded transition" title="缩小字体">A</button>
<span id="fontSizeLabel" class="text-gray-300 dark:text-gray-600 text-xs">14</span>
<button onclick="changeFontSize(1)" class="px-1.5 py-0.5 text-gray-400 dark:text-gray-500 hover:text-white text-xs rounded transition" title="放大字体">A+</button>
<!-- Scrollback -->
<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">
<select id="scrollbackSel" onchange="changeScrollback(this.value)" class="bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded text-gray-500 dark:text-gray-400 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-[var(--bg-surface)] hover:bg-slate-700 text-[var(--text-secondary)] text-xs rounded transition">全屏</button>
<button onclick="toggleFullscreen()" class="px-2 py-0.5 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 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 +41,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-[var(--bg-page)]/80 backdrop-blur-sm flex flex-col items-center justify-center z-50">
<div id="disconnectOverlay" class="hidden absolute inset-0 bg-gray-50 dark:bg-gray-950/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-[var(--text-muted)] text-sm mb-4 hidden"></div>
<div id="reconnectCountdown" class="text-gray-400 dark:text-gray-500 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-[var(--bg-surface)] hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
<a href="/app/servers.html" class="px-5 py-2 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-slate-300 text-sm rounded-lg transition">返回列表</a>
</div>
</div>
<!-- Quick Commands -->
<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="quickCmdBar" class="bg-white/95 dark:bg-gray-900/95 border-t border-gray-200 dark:border-gray-700 px-3 py-1.5 flex items-center gap-1.5 overflow-x-auto shrink-0">
<span class="text-gray-300 dark:text-gray-600 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-[var(--text-dim)] hover:text-slate-300 text-xs px-1">+</button>
<button onclick="addQuickCmd()" class="shrink-0 text-gray-300 dark:text-gray-600 hover:text-slate-300 text-xs px-1">+</button>
</div>
<!-- Command Bar -->
<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">
<div id="cmdBar" class="bg-white/95 dark:bg-gray-900/95 border-t border-gray-200 dark:border-gray-700 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-[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">
<input id="cmdInput" type="text" placeholder="输入命令,Enter 发送…" class="flex-1 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[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 class="flex gap-1 border-l border-gray-300 dark:border-gray-600 pl-2">
<button onclick="sendCtrl('c')" class="px-2 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-xs rounded transition" title="Ctrl+C">^C</button>
<button onclick="sendCtrl('d')" class="px-2 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-xs rounded transition" title="Ctrl+D">^D</button>
<button onclick="sendKey('\t')" class="px-2 py-1 bg-gray-100 dark:bg-gray-800 hover:bg-slate-700 text-gray-500 dark:text-gray-400 text-xs rounded transition" title="Tab补全">Tab</button>
</div>
</div>
</div>
</div>
<!-- Right Panel -->
<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">
<aside id="rightPanel" class="w-64 bg-white dark:bg-gray-900 border-l border-gray-200 dark:border-gray-700 flex flex-col shrink-0">
<div class="p-3 border-b border-gray-200 dark:border-gray-700">
<input id="serverSearch" placeholder="搜索服务器…" oninput="filterServerList()" class="w-full px-2 py-1.5 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-card)] border border-[var(--border-input)] rounded-lg py-1" onclick="event.stopPropagation()">
<div id="ctxMenu" class="hidden bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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 +88,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-[var(--bg-card)] border border-[var(--border-input)] rounded-xl w-full max-w-sm p-5 space-y-3">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[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 id="qcName" placeholder="命令名称" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-white text-sm placeholder-slate-500">
<textarea id="qcCmd" placeholder="命令内容(含 \\r 回车)" rows="3" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] 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-gray-100 dark:bg-gray-800 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-[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">
<div class="bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 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-[var(--bg-surface)] border border-[var(--border-input)] rounded-lg text-white text-sm placeholder-slate-500">
<input id="serverModalSearch" placeholder="搜索…" oninput="filterServerModal()" class="w-full px-3 py-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 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 +393,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-[var(--bg-surface)] text-[var(--text-muted)]';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500';
document.getElementById('connUptime').classList.add('hidden');
document.getElementById('connPing').classList.add('hidden');
if (uptimeInterval) { clearInterval(uptimeInterval); uptimeInterval = null; }
@@ -491,11 +492,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-[var(--text-secondary)] 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-gray-500 dark:text-gray-400 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-[var(--text-dim)] hover:text-red-400 ml-0.5">&times;</button>
<button onclick="event.stopPropagation();closeTab(${i})" class="text-gray-300 dark:text-gray-600 hover:text-red-400 ml-0.5">&times;</button>
</div>`;
}).join('');
}
@@ -511,7 +512,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-[var(--bg-surface)] text-[var(--text-muted)]';
document.getElementById('connStatus').className = 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-gray-100 dark:bg-gray-800 text-gray-400 dark:text-gray-500';
}
updateTabUI();
}
@@ -686,10 +687,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-[var(--text-secondary)] 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-gray-500 dark:text-gray-400 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-[var(--text-dim)] text-xs shrink-0">${esc(s.domain)}</span>
<span class="text-gray-300 dark:text-gray-600 text-xs shrink-0">${esc(s.domain)}</span>
</a>`;
}).join('');
}
@@ -710,10 +711,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-[var(--text-secondary)] hover:bg-slate-800 hover:text-slate-200' : 'bg-brand/10 text-brand-light hover:bg-brand/20'}"
${isBuiltin ? 'bg-slate-800/50 text-gray-500 dark:text-gray-400 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-[var(--text-muted)] hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
${!isBuiltin ? `<button class="hidden group-hover:inline text-gray-400 dark:text-gray-500 hover:text-red-400 qc-del-chip" data-id="${c.id}">×</button>` : ''}
</span>`;
}).join('');
// Delegate clicks
@@ -787,7 +788,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-[var(--text-dim)] text-xs">${esc(s.domain)}</span>
<span class="truncate">${esc(s.name)}</span><span class="text-gray-300 dark:text-gray-600 text-xs">${esc(s.domain)}</span>
</div>`
).join('');
}
+2 -2
View File
@@ -1,4 +1,4 @@
// Nexus Theme — Synchronous dark mode init (MUST load in <head> before CSS)
// Nexus Theme — Synchronous dark mode init (load in <head> before CSS)
// Reads theme from localStorage to prevent FOUC (flash of unstyled content)
(function(){
try{
@@ -8,6 +8,6 @@
document.documentElement.classList.add('dark');
}
}catch(e){
document.documentElement.classList.add('dark'); // default dark
document.documentElement.classList.add('dark');
}
})();
+2 -4
View File
@@ -1,7 +1,5 @@
/* Nexus Theme System — Tailwind v4 native dark mode */
/* Dark mode toggled via .dark class on <html> element */
/* Dark mode activated via .dark class on <html> element */
/* Configure Tailwind v4 to use class-based dark mode */
@variant dark (&:where(.dark, .dark *));
/* FOUC prevention — synchronous theme init reads localStorage before CSS renders */
@custom-variant dark (&:where(.dark, .dark *));