P2: 推送页面增强 + 登录页安全强化
- push.html: 完整重写 — 逐服务器状态显示(成功/失败/等待)、进度条、 同步模式选择卡片、并发/批次配置、全选/全不选、推送历史记录、toast反馈 - login.html: 密码可见性切换(eye图标)、失败次数提示(3次/5次警告)、 登录失败时卡片shake动画、锁定警告提示区、迁移layout.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+80
-3
@@ -49,9 +49,23 @@
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-2">密码</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<div class="relative">
|
||||
<input type="password" id="password" name="password" required autocomplete="current-password"
|
||||
class="w-full px-4 py-3 pr-12 bg-slate-800 border border-slate-700 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand focus:border-transparent transition"
|
||||
placeholder="••••••••">
|
||||
<button type="button" id="togglePwd" tabindex="-1"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition p-1">
|
||||
<!-- 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"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||
</svg>
|
||||
<!-- Eye-off icon (hide) -->
|
||||
<svg id="eyeHide" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="loginBtn"
|
||||
@@ -87,6 +101,9 @@
|
||||
|
||||
<!-- Error Message -->
|
||||
<div id="errorMsg" class="hidden mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl text-red-400 text-sm text-center"></div>
|
||||
|
||||
<!-- Lockout Warning -->
|
||||
<div id="lockoutMsg" class="hidden mt-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl text-amber-400 text-sm text-center"></div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -97,11 +114,30 @@
|
||||
const API = window.location.origin + '/api/auth';
|
||||
let pendingUsername = '';
|
||||
let pendingPassword = '';
|
||||
let failCount = 0;
|
||||
const MAX_FAIL_DISPLAY = 5; // Show warning after this many failures
|
||||
|
||||
// ── Password Visibility Toggle ──
|
||||
document.getElementById('togglePwd').addEventListener('click', () => {
|
||||
const pwdInput = document.getElementById('password');
|
||||
const eyeShow = document.getElementById('eyeShow');
|
||||
const eyeHide = document.getElementById('eyeHide');
|
||||
if (pwdInput.type === 'password') {
|
||||
pwdInput.type = 'text';
|
||||
eyeShow.classList.add('hidden');
|
||||
eyeHide.classList.remove('hidden');
|
||||
} else {
|
||||
pwdInput.type = 'password';
|
||||
eyeShow.classList.remove('hidden');
|
||||
eyeHide.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// ── Step 1: Password Login ──
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
hideLockout();
|
||||
|
||||
const username = document.getElementById('username').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
@@ -126,9 +162,23 @@
|
||||
pendingPassword = password;
|
||||
showTotpStep();
|
||||
} else if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '登录失败');
|
||||
|
||||
// Show lockout warning after repeated failures
|
||||
if (failCount >= MAX_FAIL_DISPLAY) {
|
||||
showLockout(`连续登录失败 ${failCount} 次。多次失败后账户可能被临时锁定,请确认用户名和密码。`);
|
||||
} else if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请检查输入。`);
|
||||
}
|
||||
|
||||
// Shake animation on error
|
||||
const card = document.getElementById('loginCard');
|
||||
card.classList.add('animate-shake');
|
||||
setTimeout(() => card.classList.remove('animate-shake'), 500);
|
||||
} else {
|
||||
// Success — store tokens and redirect
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -165,10 +215,15 @@
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
failCount++;
|
||||
showError(data.detail || '验证码错误');
|
||||
if (failCount >= 3) {
|
||||
showLockout(`已失败 ${failCount} 次,请确认验证码正确。`);
|
||||
}
|
||||
document.getElementById('totpCode').value = '';
|
||||
document.getElementById('totpCode').focus();
|
||||
} else {
|
||||
failCount = 0;
|
||||
onLoginSuccess(data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -218,6 +273,17 @@
|
||||
document.getElementById('errorMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Lockout Warning ──
|
||||
function showLockout(msg) {
|
||||
const el = document.getElementById('lockoutMsg');
|
||||
el.textContent = msg;
|
||||
el.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideLockout() {
|
||||
document.getElementById('lockoutMsg').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Check existing session ──
|
||||
(function checkSession() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
@@ -228,5 +294,16 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+211
-17
@@ -1,28 +1,222 @@
|
||||
<!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="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script><script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.8/dist/cdn.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
||||
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0"><div class="px-5 py-4 border-b border-slate-800"><div class="flex items-center gap-3"><div class="w-8 h-8 rounded-lg bg-brand/20 flex items-center justify-center"><svg class="w-5 h-5 text-brand-light" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2"/></svg></div><span class="text-white font-bold text-lg">Nexus</span></div></div><nav class="flex-1 py-3 px-3 space-y-1"><a href="/app/index.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🏠 仪表盘</a><a href="/app/servers.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🖥 服务器</a><a href="/app/files.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📁 文件管理</a><a href="/app/push.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-brand/10 text-brand-light text-sm font-medium transition">📤 推送</a><a href="/app/scripts.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📜 脚本库</a><a href="/app/credentials.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔑 凭据</a><a href="/app/schedules.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⏰ 调度</a><a href="/app/retries.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">🔄 重试队列</a><a href="/app/audit.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">📋 审计</a><a href="/app/settings.html" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 text-sm transition">⚙️ 设置</a></nav><div class="border-t border-slate-800 p-4"><button onclick="doLogout()" class="text-slate-500 hover:text-red-400 text-xs">退出</button></div></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0"><header class="bg-slate-900 border-b border-slate-800 px-6 py-3"><div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div></header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-2xl mx-auto w-full">
|
||||
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4"><button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button><h1 class="text-white font-semibold">批量推送</h1></div>
|
||||
</header>
|
||||
<main class="flex-1 overflow-y-auto p-6 max-w-3xl mx-auto w-full">
|
||||
<!-- Push Form -->
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">源路径</label><input id="srcPath" placeholder="/home/deploy/source" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label><select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-32"><option>加载中...</option></select></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label><select id="syncMode" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"><option value="incremental">增量同步</option><option value="full">全量同步</option><option value="checksum">校验和</option></select></div>
|
||||
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition">开始推送</button>
|
||||
<div id="pushResult" class="hidden mt-4 p-4 rounded-xl bg-slate-800/50 text-sm text-slate-300"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标路径</label><input id="destPath" placeholder="默认使用服务器配置的目标路径" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">目标服务器</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button onclick="selectAllServers()" class="text-xs text-brand-light hover:underline">全选</button>
|
||||
<button onclick="deselectAllServers()" class="text-xs text-slate-400 hover:underline">全不选</button>
|
||||
</div>
|
||||
<select id="targetServers" multiple class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm h-40"></select>
|
||||
<div id="serverCount" class="text-slate-500 text-xs mt-1">已选择 0 台服务器</div>
|
||||
</div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">同步模式</label>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="incremental" checked class="accent-brand"> <span class="text-sm">增量同步</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="full" class="accent-brand"> <span class="text-sm">全量同步</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl cursor-pointer has-[:checked]:border-brand has-[:checked]:bg-brand/10 transition">
|
||||
<input type="radio" name="syncMode" value="checksum" class="accent-brand"> <span class="text-sm">校验和</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="block text-sm text-slate-300 mb-2">并发数</label><input id="concurrency" type="number" value="10" min="1" max="50" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
<div><label class="block text-sm text-slate-300 mb-2">批次大小</label><input id="batchSize" type="number" value="50" min="1" max="200" class="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-xl text-white text-sm"></div>
|
||||
</div>
|
||||
<button onclick="doPush()" id="pushBtn" class="w-full py-3 bg-brand hover:bg-brand-dark text-white font-semibold rounded-xl transition disabled:opacity-50 disabled:cursor-not-allowed">开始推送</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Section -->
|
||||
<div id="progressSection" class="hidden mt-6 bg-slate-900 rounded-xl border border-slate-800 p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-white font-semibold">推送进度</h2>
|
||||
<span id="progressStats" class="text-sm text-slate-400">0/0</span>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-slate-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-slate-400">◌ 等待: <span id="countPending">0</span></span>
|
||||
</div>
|
||||
<!-- Per-server Results -->
|
||||
<div id="serverResults" class="space-y-2 max-h-96 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<!-- History Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-white font-semibold mb-3">最近推送记录</h2>
|
||||
<div id="pushHistory" class="space-y-2"><div class="text-slate-500 text-center py-6 text-sm">加载中...</div></div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/app/api.js"></script>
|
||||
<script src="/app/layout.js"></script>
|
||||
<script>
|
||||
async function loadServers(){const r=await apiFetch(API+'/servers/');if(!r)return;const data=await r.json();const servers=data.items||data;document.getElementById('targetServers').innerHTML=servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${s.domain})</option>`).join('')}
|
||||
async function doPush(){const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
|
||||
const opts=document.getElementById('targetServers').selectedOptions;const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
const body={server_ids:ids,source_path:document.getElementById('srcPath').value,sync_mode:document.getElementById('syncMode').value};
|
||||
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){btn.disabled=false;btn.textContent='开始推送';return}const d=await r.json();document.getElementById('pushResult').classList.remove('hidden');
|
||||
document.getElementById('pushResult').innerHTML=`完成: ${d.completed||0} 成功, ${d.failed||0} 失败 (共${d.total||0}台)`;
|
||||
btn.disabled=false;btn.textContent='开始推送'}
|
||||
function esc(s){const d=document.createElement('div');d.textContent=s||'';return d.innerHTML}
|
||||
initLayout('push');
|
||||
let _serversCache=[];
|
||||
let _pushInProgress=false;
|
||||
|
||||
async function loadServers(){
|
||||
const r=await apiFetch(API+'/servers/');if(!r)return;
|
||||
const data=await r.json();_serversCache=data.items||data;
|
||||
document.getElementById('targetServers').innerHTML=_serversCache.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
||||
updateServerCount();
|
||||
}
|
||||
|
||||
document.getElementById('targetServers').addEventListener('change',updateServerCount);
|
||||
function updateServerCount(){
|
||||
const sel=document.getElementById('targetServers').selectedOptions.length;
|
||||
document.getElementById('serverCount').textContent=`已选择 ${sel} 台服务器`;
|
||||
}
|
||||
function selectAllServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=true);updateServerCount();
|
||||
}
|
||||
function deselectAllServers(){
|
||||
const s=document.getElementById('targetServers');Array.from(s.options).forEach(o=>o.selected=false);updateServerCount();
|
||||
}
|
||||
|
||||
async function doPush(){
|
||||
if(_pushInProgress)return;
|
||||
const opts=document.getElementById('targetServers').selectedOptions;
|
||||
const ids=Array.from(opts).map(o=>parseInt(o.value));
|
||||
if(!ids.length){toast('warning','请选择至少一台服务器');return}
|
||||
const srcPath=document.getElementById('srcPath').value;
|
||||
if(!srcPath){toast('warning','请输入源路径');return}
|
||||
const syncMode=document.querySelector('input[name="syncMode"]:checked')?.value||'incremental';
|
||||
const body={
|
||||
server_ids:ids,
|
||||
source_path:srcPath,
|
||||
target_path:document.getElementById('destPath').value||null,
|
||||
sync_mode:syncMode,
|
||||
concurrency:parseInt(document.getElementById('concurrency').value)||10,
|
||||
batch_size:parseInt(document.getElementById('batchSize').value)||50,
|
||||
};
|
||||
|
||||
_pushInProgress=true;
|
||||
const btn=document.getElementById('pushBtn');btn.disabled=true;btn.textContent='推送中...';
|
||||
|
||||
// Show progress section with pending states
|
||||
showProgress(ids);
|
||||
|
||||
try{
|
||||
const r=await apiFetch(API+'/sync/files',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
||||
if(!r){toast('error','认证失败或请求被拒绝');resetProgress();return}
|
||||
const d=await r.json();
|
||||
updateProgressFromResult(d);
|
||||
toast(d.failed>0?'warning':'success',`推送完成: ${d.completed||0} 成功, ${d.failed||0} 失败`);
|
||||
}catch(e){
|
||||
toast('error','推送请求失败: '+e.message);
|
||||
resetProgress();
|
||||
}finally{
|
||||
_pushInProgress=false;btn.disabled=false;btn.textContent='开始推送';
|
||||
loadHistory(); // Refresh history
|
||||
}
|
||||
}
|
||||
|
||||
function showProgress(ids){
|
||||
const section=document.getElementById('progressSection');section.classList.remove('hidden');
|
||||
document.getElementById('progressBar').style.width='0%';
|
||||
document.getElementById('progressStats').textContent=`0/${ids.length}`;
|
||||
document.getElementById('countSuccess').textContent='0';
|
||||
document.getElementById('countFailed').textContent='0';
|
||||
document.getElementById('countPending').textContent=ids.length;
|
||||
// Build per-server rows
|
||||
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
|
||||
document.getElementById('serverResults').innerHTML=ids.map(id=>{
|
||||
const s=serversMap[id];
|
||||
return `<div id="srv_${id}" class="flex items-center gap-3 px-3 py-2 bg-slate-800/50 rounded-lg">
|
||||
<span class="shrink-0 w-5 h-5 flex items-center justify-center text-slate-400" id="srv_icon_${id}">◌</span>
|
||||
<span class="text-sm text-slate-300 flex-1">${esc(s?.name||'服务器#'+id)}</span>
|
||||
<span class="text-xs text-slate-500" id="srv_status_${id}">等待中</span>
|
||||
<span class="text-xs text-slate-600 hidden" id="srv_detail_${id}"></span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function updateProgressFromResult(d){
|
||||
const total=d.total||0;const completed=d.completed||0;const failed=d.failed||0;
|
||||
const pct=total>0?Math.round(((completed+failed)/total)*100):0;
|
||||
document.getElementById('progressBar').style.width=pct+'%';
|
||||
document.getElementById('progressStats').textContent=`${completed+failed}/${total}`;
|
||||
document.getElementById('countSuccess').textContent=completed;
|
||||
document.getElementById('countFailed').textContent=failed;
|
||||
document.getElementById('countPending').textContent=total-completed-failed;
|
||||
|
||||
const serversMap={};_serversCache.forEach(s=>serversMap[s.id]=s);
|
||||
if(d.results){
|
||||
for(const[sid,log]of Object.entries(d.results)){
|
||||
const iconEl=document.getElementById('srv_icon_'+sid);
|
||||
const statusEl=document.getElementById('srv_status_'+sid);
|
||||
const detailEl=document.getElementById('srv_detail_'+sid);
|
||||
if(!iconEl)continue;
|
||||
if(log.status==='success'){
|
||||
iconEl.textContent='✓';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-green-400';
|
||||
statusEl.textContent='成功';statusEl.className='text-xs text-green-400';
|
||||
if(log.duration_seconds){detailEl.textContent=log.duration_seconds+'s';detailEl.classList.remove('hidden')}
|
||||
}else{
|
||||
iconEl.textContent='✗';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-red-400';
|
||||
statusEl.textContent='失败';statusEl.className='text-xs text-red-400';
|
||||
if(log.error_message){detailEl.textContent=log.error_message.substring(0,60);detailEl.classList.remove('hidden');detailEl.className='text-xs text-red-400/70'}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mark remaining as pending
|
||||
document.querySelectorAll('[id^="srv_status_"]').forEach(el=>{
|
||||
if(el.textContent==='等待中'){
|
||||
const id=el.id.replace('srv_status_','');
|
||||
const iconEl=document.getElementById('srv_icon_'+id);
|
||||
if(iconEl){iconEl.textContent='◌';iconEl.className='shrink-0 w-5 h-5 flex items-center justify-center text-slate-500'}
|
||||
el.className='text-xs text-slate-500';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetProgress(){
|
||||
document.getElementById('progressSection').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ── Push History (recent sync logs) ──
|
||||
async function loadHistory(){
|
||||
try{
|
||||
const r=await apiFetch(API+'/servers/logs?limit=10');if(!r)return;
|
||||
const logs=await r.json();
|
||||
document.getElementById('pushHistory').innerHTML=logs.length?logs.map(l=>`<div class="bg-slate-900 rounded-lg border border-slate-800 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="${l.status==='success'?'text-green-400':'text-red-400'} text-sm">${l.status==='success'?'✓':'✗'}</span>
|
||||
<div>
|
||||
<span class="text-sm text-slate-300">${esc(l.operator||'system')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.sync_mode||'file')}</span>
|
||||
<span class="text-slate-600 mx-1">·</span>
|
||||
<span class="text-xs text-slate-500">${esc(l.source_path||'')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">${fmtTime(l.started_at)}</div>
|
||||
</div>`).join(''):'<div class="text-slate-500 text-center py-6 text-sm">暂无推送记录</div>';
|
||||
}catch(e){
|
||||
document.getElementById('pushHistory').innerHTML='<div class="text-slate-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}
|
||||
function fmtTime(t){if(!t)return'';return new Date(t+'Z').toLocaleString('zh-CN',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'})}
|
||||
|
||||
loadServers();
|
||||
loadHistory();
|
||||
</script>
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user