8cd091add8
Add three missing frontend management features: 1. Server CSV import (POST /api/servers/import) with injection prevention 2. SFTP file upload (POST /api/sync/upload) with path traversal protection 3. Batch Agent install/upgrade with Semaphore concurrency control Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
212 lines
12 KiB
HTML
212 lines
12 KiB
HTML
<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Nexus — 文件管理</title><script src="/app/vendor/tailwindcss-browser.js"></script><script defer src="/app/vendor/alpinejs.min.js"></script><style type="text/tailwindcss">@theme{--color-brand:oklch(55% 0.2 250);--color-brand-light:oklch(75% 0.15 250);--color-brand-dark:oklch(35% 0.18 250)}</style></head>
|
|
<body class="bg-slate-950 text-slate-100 min-h-screen flex" x-data="{sidebarOpen:true}">
|
|
<aside x-show="sidebarOpen" x-transition class="w-64 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0" data-sidebar></aside>
|
|
<div class="flex-1 flex flex-col min-w-0">
|
|
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 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-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="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm">
|
|
<option value="">-- 选择服务器 --</option>
|
|
</select>
|
|
<input id="dirPath" type="text" value="/" placeholder="/ 路径" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
|
|
<button onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
|
|
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
|
|
<button onclick="pickUploadFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="上传文件">↑ 上传</button>
|
|
<input id="uploadFileInput" type="file" multiple class="hidden" onchange="doUploadFiles(this.files)">
|
|
</div>
|
|
</header>
|
|
<!-- Breadcrumb -->
|
|
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-1 text-sm hidden">
|
|
<span class="text-slate-500">路径:</span>
|
|
<div id="breadcrumbPath" class="flex items-center gap-1"></div>
|
|
</div>
|
|
<main class="flex-1 overflow-y-auto p-6">
|
|
<div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
|
<div class="px-4 py-8 text-center text-slate-500">选择服务器并输入目录路径开始浏览</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
<script src="/app/api.js"></script>
|
|
<script src="/app/layout.js"></script>
|
|
<script>
|
|
initLayout('files');
|
|
let _currentPath='/';
|
|
let _currentServerId=null;
|
|
|
|
async function loadServers(){
|
|
const r=await apiFetch(API+'/servers/');if(!r)return;
|
|
const data=await r.json();const servers=data.items||data;
|
|
document.getElementById('serverSelect').innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
|
|
}
|
|
|
|
async function browseDir(path){
|
|
const sid=document.getElementById('serverSelect').value;
|
|
if(path){document.getElementById('dirPath').value=path;_currentPath=path}
|
|
else{_currentPath=document.getElementById('dirPath')?.value||'/'}
|
|
if(!sid){toast('warning','请先选择服务器');return}
|
|
_currentServerId=parseInt(sid);
|
|
|
|
document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">加载中...</div>';
|
|
try{
|
|
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
|
|
if(!r){document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-red-400">认证失败</div>';return}
|
|
const d=await r.json();
|
|
if(d.error){document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">${esc(d.error)}</div>`;toast('error','浏览失败: '+d.error.substring(0,50));return}
|
|
|
|
// Update breadcrumb
|
|
updateBreadcrumb(_currentPath);
|
|
|
|
// Build file list
|
|
let html='';
|
|
// Parent directory link (if not root)
|
|
if(_currentPath!=='/'){
|
|
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
|
|
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" onclick="browseDir('${escAttr(parentPath)}')">
|
|
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
|
|
</div>`;
|
|
}
|
|
if(d.entries.length){
|
|
// Sort: directories first, then files
|
|
const sorted=[...d.entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
|
|
html+=sorted.map(e=>{
|
|
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
|
|
return `<div class="group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50">
|
|
<span class="${e.is_dir?'text-yellow-400':'text-slate-400'} text-sm shrink-0">${e.is_dir?'📁':'📄'}</span>
|
|
<span class="text-white text-sm flex-1 ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`onclick="browseDir('${escAttr(fullPath)}')"`:''} id="name_${escAttr(fullPath)}">${esc(e.name)}</span>
|
|
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':e.size}</span>
|
|
<span class="text-slate-600 text-xs shrink-0 hidden sm:block">${esc(e.perms)}</span>
|
|
<span class="text-slate-600 text-xs shrink-0 hidden md:block">${esc(e.owner||'')}</span>
|
|
<!-- Action buttons (appear on row hover) -->
|
|
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
|
|
<button onclick="doRename('${escAttr(fullPath)}','${escAttr(e.name)}')" class="px-2 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded transition" title="重命名">✏</button>
|
|
<button onclick="doDelete('${escAttr(fullPath)}',${e.is_dir})" class="px-2 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded transition" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}else if(_currentPath==='/'){
|
|
html+='<div class="px-4 py-8 text-center text-slate-500">空目录</div>';
|
|
}
|
|
document.getElementById('fileList').innerHTML=html;
|
|
}catch(e){
|
|
document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
|
|
toast('error','连接失败');
|
|
}
|
|
}
|
|
|
|
function updateBreadcrumb(path){
|
|
const bc=document.getElementById('breadcrumb');
|
|
const bcPath=document.getElementById('breadcrumbPath');
|
|
bc.classList.remove('hidden');
|
|
|
|
// Split path into segments
|
|
const segments=path.split('/').filter(Boolean);
|
|
let html=`<span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('/')">/</span>`;
|
|
let accumulated='';
|
|
segments.forEach((seg,i)=>{
|
|
accumulated+='/'+seg;
|
|
const p=accumulated;
|
|
if(i<segments.length-1){
|
|
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" onclick="browseDir('${escAttr(p)}')">${esc(seg)}</span>`;
|
|
}else{
|
|
html+=`<span class="text-slate-600">/</span><span class="text-white font-medium">${esc(seg)}</span>`;
|
|
}
|
|
});
|
|
bcPath.innerHTML=html;
|
|
}
|
|
|
|
// ── File Operations ──
|
|
|
|
async function fileOp(operation, path, newPath){
|
|
if(!_currentServerId){toast('warning','请先选择服务器');return false}
|
|
const body={server_id:_currentServerId, operation, path};
|
|
if(newPath)body.new_path=newPath;
|
|
const r=await apiFetch(API+'/sync/file-ops',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify(body)});
|
|
if(!r){toast('error','请求失败');return false}
|
|
if(!r.ok){const e=await r.json().catch(()=>({detail:'未知错误'}));toast('error',(e.detail||'操作失败').substring(0,80));return false}
|
|
return true;
|
|
}
|
|
|
|
async function doDelete(fullPath, isDir){
|
|
const name=fullPath.split('/').pop();
|
|
const warn=isDir?`确定要删除目录 "${name}" 及其所有内容吗?此操作不可恢复!`:`确定要删除文件 "${name}" 吗?`;
|
|
if(!confirm(warn))return;
|
|
if(await fileOp('delete', fullPath)){
|
|
toast('success', `已删除: ${name}`);
|
|
browseDir(_currentPath);
|
|
}
|
|
}
|
|
|
|
async function doRename(fullPath, oldName){
|
|
const dir=fullPath.slice(0, fullPath.lastIndexOf('/')) || '/';
|
|
const newName=prompt(`重命名「${oldName}」为:`, oldName);
|
|
if(!newName||newName===oldName)return;
|
|
if(newName.includes('/')){toast('error','文件名不能包含 /');return}
|
|
const newPath=dir.replace(/\/$/,'')+'/'+newName;
|
|
if(await fileOp('rename', fullPath, newPath)){
|
|
toast('success', `已重命名: ${oldName} → ${newName}`);
|
|
browseDir(_currentPath);
|
|
}
|
|
}
|
|
|
|
async function doMkdir(){
|
|
if(!_currentServerId){toast('warning','请先选择服务器');return}
|
|
const name=prompt('新建目录名称:', '');
|
|
if(!name)return;
|
|
if(name.includes('/')){toast('error','目录名不能包含 /');return}
|
|
const fullPath=_currentPath.replace(/\/$/,'')+'/'+name;
|
|
if(await fileOp('mkdir', fullPath)){
|
|
toast('success', `已创建目录: ${name}`);
|
|
browseDir(_currentPath);
|
|
}
|
|
}
|
|
|
|
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
|
|
function escAttr(s){if(!s)return'';return s.replace(/&/g,'&').replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>')}
|
|
|
|
// ── File Upload ──
|
|
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
|
|
|
|
async function doUploadFiles(files){
|
|
if(!_currentServerId){toast('warning','请先选择服务器');return}
|
|
if(!files||!files.length)return;
|
|
const remotePath=_currentPath||'/';
|
|
let ok=0,fail=0;
|
|
for(const file of files){
|
|
const formData=new FormData();
|
|
formData.append('server_id',_currentServerId);
|
|
formData.append('remote_path',remotePath);
|
|
formData.append('file',file);
|
|
try{
|
|
const r=await apiFetch(API+'/sync/upload',{method:'POST',body:formData});
|
|
if(r&&r.ok){ok++}else{
|
|
fail++;
|
|
const e=await r.json().catch(()=>({detail:'上传失败'}));
|
|
toast('error',`${file.name}: ${(e.detail||'未知错误').substring(0,60)}`);
|
|
}
|
|
}catch(e){fail++;toast('error',`${file.name}: ${e.message}`)}
|
|
}
|
|
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?', '+fail+' 失败':''}`);
|
|
if(ok>0)browseDir(_currentPath);
|
|
document.getElementById('uploadFileInput').value='';
|
|
}
|
|
|
|
// Drag & drop support on file list area
|
|
const fileListEl=document.getElementById('fileList');
|
|
fileListEl.addEventListener('dragover',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.add('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
|
|
fileListEl.addEventListener('dragleave',e=>{e.preventDefault();e.stopPropagation();fileListEl.classList.remove('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950')});
|
|
fileListEl.addEventListener('drop',e=>{
|
|
e.preventDefault();e.stopPropagation();
|
|
fileListEl.classList.remove('ring-2','ring-brand','ring-offset-2','ring-offset-slate-950');
|
|
if(e.dataTransfer.files.length)doUploadFiles(e.dataTransfer.files);
|
|
});
|
|
|
|
// Enter key in path input triggers browse
|
|
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
|
|
|
|
loadServers();
|
|
</script>
|
|
</body></html>
|