feat: files.html 全面迭代 — 15项改进

安全+后端:
- P2-1 符号链接解析(l权限检测+name/target分离)
- P2-7 新增 /api/sync/download 端点(SFTP流式下载)
- P2-8 新增 /api/sync/read-file + /write-file 端点(base64编码避免shell注入)

前端全面重写:
- P1-1 目录删除二次确认(自定义模态框+输入目录名)
- P2-2/2-3 事件委托替代内联onclick(data-action+data-path)
- P2-4 上传进度条(XHR.upload.onprogress)
- P2-5 文件大小可读化(B/KB/MB/GB)
- P2-6 文件类型图标(30+扩展名映射)
- P2-7 下载按钮+逻辑
- P2-8 文件预览/编辑弹窗(读取+修改+保存)
- P2-9 自定义模态框替代prompt/confirm
- P2-10 批量选择+批量删除(checkbox+全选+底部操作栏)
- P3-1 文件名搜索框(前端实时过滤)
- P3-2 符号链接🔗图标+target tooltip
- P3-3 空目录状态(图标+上传引导按钮)
- P3-4 服务器刷新按钮
- P3-5 操作loading状态(进度条) 2>&1
This commit is contained in:
Your Name
2026-05-30 23:34:24 +08:00
parent 64503c0260
commit 14131f98f0
3 changed files with 499 additions and 104 deletions
+20
View File
@@ -192,6 +192,26 @@ class FileOperation(BaseModel):
new_path: Optional[str] = None # required for rename
class FileDownload(BaseModel):
"""Download a file from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1)
class FileRead(BaseModel):
"""Read text file content from a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1)
max_size: int = Field(1_000_000, ge=1, le=5_000_000) # 1MB default, 5MB max
class FileWrite(BaseModel):
"""Write text content to a file on a remote server."""
server_id: int = Field(..., ge=1)
path: str = Field(..., min_length=1)
content: str = Field(..., max_length=5_000_000)
# ── Script ──
class ScriptCreate(BaseModel):
+151 -4
View File
@@ -9,7 +9,7 @@ import logging
from fastapi import APIRouter, Depends, HTTPException, Request
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff
from server.api.schemas import SyncFiles, SyncBrowse, SyncBrowseLocal, SyncPreview, FileOperation, LocalFileOperation, LocalFilePreview, SyncVerify, SyncCancel, ValidateSourcePath, SyncDiagnose, FileSyncDiff, FileDownload, FileRead, FileWrite
from server.application.services.sync_engine_v2 import SyncEngineV2
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.database.sync_log_repo import SyncLogRepositoryImpl
@@ -177,12 +177,21 @@ async def browse_directory(
continue
parts = line.split(None, 8)
if len(parts) >= 9:
is_dir = parts[0].startswith("d")
perms = parts[0]
is_dir = perms.startswith("d")
is_link = perms.startswith("l")
name = parts[8]
link_target = None
# P2-1: Parse symlink: "name -> target"
if is_link and " -> " in name:
name, link_target = name.split(" -> ", 1)
entries.append({
"name": parts[8], # Last field after split(None, 8) — handles spaces in names
"name": name,
"is_dir": is_dir,
"is_link": is_link,
"link_target": link_target,
"size": parts[4],
"perms": parts[0],
"perms": perms,
"owner": parts[2],
"modified": " ".join(parts[5:8]),
})
@@ -1043,3 +1052,141 @@ async def verify_sync(
)
return {"results": results, "total_local_files": len(local_files)}
# ── File Download / Read / Write ──
MAX_READ_FILE_SIZE = 1_000_000 # 1MB
@router.post("/download")
async def download_file(
payload: FileDownload,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""P2-7: Download a file from a remote server via SFTP."""
import os
from fastapi.responses import StreamingResponse
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import ssh_pool
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
conn = await ssh_pool.acquire(server)
try:
async with conn.create_sftp_client() as sftp:
# Check file exists and size
try:
stat = await sftp.stat(payload.path)
except FileNotFoundError:
raise HTTPException(status_code=404, detail="文件不存在") from None
except Exception as e:
raise HTTPException(status_code=502, detail=f"无法访问文件: {e}") from e
if stat.type not in ("regular file", "unknown"):
raise HTTPException(status_code=400, detail="只能下载文件,不能下载目录")
filename = os.path.basename(payload.path)
async def file_generator():
async with sftp.open(payload.path, "rb") as f:
while chunk := await f.read(65536):
yield chunk
await _audit_sync(
"file_download", "server", payload.server_id,
f"下载文件: {payload.path}",
admin.username, request,
)
return StreamingResponse(
file_generator(),
media_type="application/octet-stream",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
finally:
await ssh_pool.release(server.id)
@router.post("/read-file")
async def read_file(
payload: FileRead,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""P2-8: Read text file content from a remote server."""
import shlex
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
# Check file size first
size_result = await exec_ssh_command(server, f"stat -c%s {shlex.quote(payload.path)}", timeout=5)
if size_result["exit_code"] != 0:
raise HTTPException(status_code=404, detail="文件不存在或无法访问")
try:
file_size = int(size_result["stdout"].strip())
except ValueError:
raise HTTPException(status_code=502, detail="无法获取文件大小") from None
if file_size > payload.max_size:
raise HTTPException(
status_code=413,
detail=f"文件大小 {file_size} 字节超过限制 {payload.max_size} 字节",
)
# Read file content
result = await exec_ssh_command(server, f"cat {shlex.quote(payload.path)}", timeout=15)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"读取失败: {result['stderr'][:200]}")
await _audit_sync(
"file_read", "server", payload.server_id,
f"读取文件: {payload.path} ({file_size} bytes)",
admin.username, request,
)
return {"content": result["stdout"], "size": file_size, "path": payload.path}
@router.post("/write-file")
async def write_file(
payload: FileWrite,
request: Request,
admin: Admin = Depends(get_current_admin),
):
"""P2-8: Write text content to a file on a remote server."""
import shlex
import base64
from server.infrastructure.database.server_repo import ServerRepositoryImpl
from server.infrastructure.ssh.asyncssh_pool import exec_ssh_command
session = request.state.db
server = await ServerRepositoryImpl(session).get_by_id(payload.server_id)
if not server:
raise HTTPException(status_code=404, detail="服务器不存在")
# Use base64 to avoid shell escaping issues with file content
encoded = base64.b64encode(payload.content.encode("utf-8")).decode("ascii")
cmd = f"echo '{encoded}' | base64 -d | tee {shlex.quote(payload.path)} > /dev/null"
result = await exec_ssh_command(server, cmd, timeout=30)
if result["exit_code"] != 0:
raise HTTPException(status_code=502, detail=f"写入失败: {result['stderr'][:200]}")
await _audit_sync(
"file_write", "server", payload.server_id,
f"写入文件: {payload.path} ({len(payload.content)} chars)",
admin.username, request,
)
return {"success": True, "path": payload.path, "size": len(payload.content)}
+328 -100
View File
@@ -2,114 +2,288 @@
<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">
<header class="bg-slate-900 border-b border-slate-800 px-6 py-3 flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-4">
<button @click="sidebarOpen=!sidebarOpen" class="text-slate-400 hover:text-white transition"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg></button>
<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">
<div class="flex items-center gap-2 flex-wrap">
<select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand">
<option value="">-- 选择服务器 --</option>
</select>
<button id="refreshServersBtn" class="px-2 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="刷新服务器列表" onclick="loadServers()">🔄</button>
<input id="dirPath" type="text" value="/" 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 id="browseBtn" onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
<button onclick="pickUploadFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="上传文件">↑ 上传</button>
<button id="uploadBtn" 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>
<!-- Upload Progress Bar -->
<div id="uploadProgress" class="hidden bg-slate-900/80 border-b border-slate-800 px-6 py-2">
<div class="flex items-center gap-3 text-sm">
<span id="uploadFileName" class="text-slate-300 truncate flex-1"></span>
<span id="uploadPercent" class="text-brand-light tabular-nums w-12 text-right"></span>
</div>
<div class="mt-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div id="uploadBar" class="h-full bg-brand rounded-full transition-all" style="width:0%"></div>
</div>
</div>
<!-- Breadcrumb + Search -->
<div id="breadcrumb" class="bg-slate-900/50 border-b border-slate-800 px-6 py-2 flex items-center gap-2 hidden">
<span class="text-slate-500 text-sm shrink-0">路径:</span>
<div id="breadcrumbPath" class="flex items-center gap-1 flex-1 overflow-x-auto"></div>
<input id="fileSearch" placeholder="🔍 搜索..." oninput="filterFiles(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs w-40 focus:outline-none focus:ring-1 focus:ring-brand">
</div>
<!-- Batch Action Bar -->
<div id="batchBar" class="hidden bg-slate-800 border-b border-slate-700 px-6 py-2 flex items-center gap-3">
<span id="batchCount" class="text-slate-300 text-sm"></span>
<button onclick="batchDelete()" class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition">🗑 批量删除</button>
<button onclick="clearSelection()" class="px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white text-xs rounded-lg transition">取消选择</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-4 py-8 text-center text-slate-500">选择服务器并输入目录路径开始浏览</div>
<div class="px-4 py-12 text-center text-slate-500">
<div class="text-4xl mb-3">📂</div>
<p>选择服务器并输入目录路径开始浏览</p>
</div>
</div>
</main>
</div>
<!-- P2-9: Custom Modal -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700">
<h3 id="modalTitle" class="text-white font-medium mb-4 text-lg"></h3>
<p id="modalText" class="text-slate-300 text-sm mb-4 hidden whitespace-pre-line"></p>
<input id="modalInput" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 focus:outline-none focus:ring-2 focus:ring-brand text-sm" autocomplete="off">
<textarea id="modalTextarea" class="hidden w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white mb-4 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-brand resize-y" rows="20"></textarea>
<div class="flex gap-2 justify-end">
<button onclick="closeModal()" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition">取消</button>
<button id="modalConfirm" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">确认</button>
</div>
</div>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
initLayout('files');
let _currentPath='/';
let _currentServerId=null;
let _allEntries=[];
let _selectedPaths=new Set();
// ── P2-5: File size formatting ──
function formatSize(bytes){
if(!bytes||bytes==='--')return'--';
const b=parseInt(bytes);if(isNaN(b))return bytes;
if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';
if(b<1073741824)return(b/1048576).toFixed(1)+' MB';return(b/1073741824).toFixed(1)+' GB';
}
// ── P2-6: File type icons ──
function fileIcon(name,isDir,isLink){
if(isDir)return{icon:'📁',color:'text-yellow-400'};
if(isLink)return{icon:'🔗',color:'text-cyan-400'};
const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';
const m={py:'🐍',js:'📜',ts:'📜',html:'🌐',htm:'🌐',css:'🎨',json:'📋',yml:'📋',yaml:'📋',xml:'📋',conf:'📋',cfg:'📋',ini:'📋',toml:'📋',
sh:'⚙️',bash:'⚙️',zsh:'⚙️',
jpg:'🖼️',jpeg:'🖼️',png:'🖼️',gif:'🖼️',svg:'🖼️',ico:'🖼️',webp:'🖼️',
tar:'📦',gz:'📦',zip:'📦',rar:'📦',tgz:'📦',bz2:'📦',xz:'📦',
log:'📊',txt:'📝',md:'📝',csv:'📊',tsv:'📊',
sql:'🗄️',db:'🗄️',sqlite:'🗄️',
pdf:'📕',doc:'📘',docx:'📘',xls:'📗',xlsx:'📗',
mp3:'🎵',mp4:'🎬',avi:'🎬',mkv:'🎬',
env:'🔒',key:'🔒',pem:'🔒',crt:'🔒',cert:'🔒'};
return{icon:m[ext]||'📄',color:'text-slate-400'};
}
// ── P2-2/P2-3: Event delegation ──
const fileListEl=document.getElementById('fileList');
fileListEl.addEventListener('click',e=>{
const btn=e.target.closest('[data-action]');
if(!btn)return;
const action=btn.dataset.action,path=btn.dataset.path||'',name=btn.dataset.name||'',isDir=btn.dataset.isDir==='true';
if(action==='browse')browseDir(path);
else if(action==='rename')doRename(path,name);
else if(action==='delete')doDelete(path,isDir);
else if(action==='download')doDownload(path,name);
else if(action==='preview')doPreview(path);
else if(action==='select')toggleSelect(btn,path);
else if(action==='select-all')toggleSelectAll(btn);
});
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Modal system (P2-9) ──
let _modalResolve=null;
function showModal({title,text,input,textarea,inputDefault,textareaValue,confirmText,danger,onConfirm}){
document.getElementById('modalTitle').textContent=title;
const textEl=document.getElementById('modalText');
const inputEl=document.getElementById('modalInput');
const textareaEl=document.getElementById('modalTextarea');
const confirmBtn=document.getElementById('modalConfirm');
textEl.classList.toggle('hidden',!text);
textEl.textContent=text||'';
inputEl.classList.toggle('hidden',!input);
if(input){inputEl.value=inputDefault||'';setTimeout(()=>inputEl.focus(),50)}
textareaEl.classList.toggle('hidden',!textarea);
if(textarea){textareaEl.value=textareaValue||'';setTimeout(()=>textareaEl.focus(),50)}
confirmBtn.textContent=confirmText||'确认';
confirmBtn.className=`px-4 py-2 text-white text-sm rounded-lg transition ${danger?'bg-red-600 hover:bg-red-700':'bg-brand hover:bg-brand-dark'}`;
document.getElementById('modal').classList.remove('hidden');
_modalResolve=onConfirm;
}
function closeModal(){
document.getElementById('modal').classList.add('hidden');_modalResolve=null;
}
document.getElementById('modalConfirm').onclick=()=>{
if(_modalResolve){
const inputEl=document.getElementById('modalInput');
const textareaEl=document.getElementById('modalTextarea');
const val=textareaEl.classList.contains('hidden')?inputEl.value:textareaEl.value;
_modalResolve(val);
}
closeModal();
};
document.getElementById('modalInput').addEventListener('keydown',e=>{if(e.key==='Enter')document.getElementById('modalConfirm').click()});
document.getElementById('modal').addEventListener('click',e=>{if(e.target===document.getElementById('modal'))closeModal()});
// ── Servers (P3-4: refresh button) ──
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('');
const sel=document.getElementById('serverSelect');
const prev=sel.value;
sel.innerHTML='<option value="">-- 选择服务器 --</option>'+servers.map(s=>`<option value="${s.id}">${esc(s.name)} (${esc(s.domain)})</option>`).join('');
if(prev)sel.value=prev;
toast('info','服务器列表已刷新');
}
// ── Browse directory ──
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);
_currentServerId=parseInt(sid);_selectedPaths.clear();updateBatchBar();
document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-slate-500">加载中...</div>';
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-2xl mb-2 animate-spin">⏳</div>加载中...</div>';
try{
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
if(!r){document.getElementById('fileList').innerHTML='<div class="px-4 py-8 text-center text-red-400">认证失败</div>';return}
if(!r){fileListEl.innerHTML='<div class="px-4 py-12 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}
if(d.error){fileListEl.innerHTML=`<div class="px-4 py-12 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;
_allEntries=d.entries||[];
renderFileList(_allEntries);
}catch(e){
document.getElementById('fileList').innerHTML=`<div class="px-4 py-8 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
fileListEl.innerHTML=`<div class="px-4 py-12 text-center text-red-400">连接失败: ${esc(e.message)}</div>`;
toast('error','连接失败');
}
}
function renderFileList(entries){
let html='';
// Parent directory
if(_currentPath!=='/'){
const parentPath=_currentPath.replace(/\/[^/]+\/?$/,'')||'/';
html+=`<div class="flex items-center gap-3 px-4 py-2 border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer" data-action="browse" data-path="${esc(parentPath)}">
<span class="text-slate-500">📂</span><span class="text-slate-400 text-sm">..</span><span class="text-slate-600 text-xs">返回上级目录</span>
</div>`;
}
// P2-10: Select all checkbox
if(entries.length){
html+=`<div class="flex items-center gap-3 px-4 py-1.5 border-b border-slate-800 bg-slate-800/30 text-xs text-slate-500">
<input type="checkbox" data-action="select-all" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand cursor-pointer">
<span class="flex-1">全选</span><span class="w-16 text-right">大小</span><span class="w-20 hidden sm:block">权限</span><span class="w-16 hidden md:block">所有者</span><span class="w-20"></span>
</div>`;
}
// File list
const sorted=[...entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
html+=sorted.map(e=>{
const fi=fileIcon(e.name,e.is_dir,e.is_link);
const fullPath=_currentPath.replace(/\/$/,'')+'/'+e.name;
const linkHint=e.is_link?`title="→ ${esc(e.link_target||'')}"`:'';
return `<div class="file-row group flex items-center gap-3 px-4 py-2.5 border-b border-slate-800 hover:bg-slate-800/50" data-name="${esc(e.name.toLowerCase())}">
<input type="checkbox" data-action="select" data-path="${esc(fullPath)}" class="file-cb rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand shrink-0">
<span class="${fi.color} text-sm shrink-0" ${linkHint}>${fi.icon}</span>
<span class="text-white text-sm flex-1 truncate ${e.is_dir?'cursor-pointer hover:text-brand-light':''}" ${e.is_dir?`data-action="browse" data-path="${esc(fullPath)}"`:''} ${linkHint}>${esc(e.name)}</span>
<span class="text-slate-500 text-xs tabular-nums w-16 text-right shrink-0">${e.is_dir?'--':formatSize(e.size)}</span>
<span class="text-slate-600 text-xs shrink-0 w-20 hidden sm:block">${esc(e.perms)}</span>
<span class="text-slate-600 text-xs shrink-0 w-16 hidden md:block truncate">${esc(e.owner||'')}</span>
<div class="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity shrink-0 w-20 justify-end">
${!e.is_dir?`<button data-action="preview" data-path="${esc(fullPath)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="预览">👁</button>`:''}
${!e.is_dir?`<button data-action="download" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="下载">⬇</button>`:''}
<button data-action="rename" data-path="${esc(fullPath)}" data-name="${esc(e.name)}" class="px-1.5 py-1 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded" title="重命名">✏</button>
<button data-action="delete" data-path="${esc(fullPath)}" data-is-dir="${e.is_dir}" class="px-1.5 py-1 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/30 rounded" title="${e.is_dir?'删除目录':'删除文件'}">🗑</button>
</div>
</div>`;
}).join('');
// P3-3: Empty state
if(!entries.length){
html+=`<div class="px-4 py-12 text-center"><div class="text-4xl mb-3">📂</div><p class="text-slate-400 mb-3">空目录</p><button onclick="pickUploadFile()" class="px-4 py-2 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">上传文件</button></div>`;
}
fileListEl.innerHTML=html;
document.getElementById('fileSearch').value='';
}
// ── P3-1: File search filter ──
function filterFiles(q){
q=q.toLowerCase();
document.querySelectorAll('.file-row').forEach(row=>{
row.style.display=!q||row.dataset.name.includes(q)?'':'none';
});
}
// ── P2-10: Selection ──
function toggleSelect(cb,path){
if(cb.checked)_selectedPaths.add(path);else _selectedPaths.delete(path);
updateBatchBar();
}
function toggleSelectAll(cb){
const cbs=document.querySelectorAll('.file-cb');
cbs.forEach(c=>{c.checked=cb.checked;const p=c.dataset.path;if(p){cb.checked?_selectedPaths.add(p):_selectedPaths.delete(p)}});
updateBatchBar();
}
function clearSelection(){_selectedPaths.clear();document.querySelectorAll('.file-cb').forEach(c=>c.checked=false);updateBatchBar()}
function updateBatchBar(){
const bar=document.getElementById('batchBar');
const n=_selectedPaths.size;
bar.classList.toggle('hidden',n===0);
document.getElementById('batchCount').textContent=`已选择 ${n}`;
}
async function batchDelete(){
const paths=[..._selectedPaths];if(!paths.length)return;
showModal({
title:'⚠️ 批量删除',text:`确定要删除选中的 ${paths.length} 项?此操作不可恢复!`,
confirmText:`删除 ${paths.length}`,danger:true,
onConfirm:async()=>{
let ok=0,fail=0;
for(const p of paths){
const isDir=_allEntries.some(e=>{const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;return fp===p&&e.is_dir});
if(await fileOp('delete',p)){ok++}else{fail++}
}
toast('success',`批量删除完成: ${ok} 成功${fail?`, ${fail} 失败`:''}`);
clearSelection();browseDir(_currentPath);
}
});
}
// ── Breadcrumb ──
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 html=`<span class="cursor-pointer text-brand-light hover:underline shrink-0" data-action="browse" data-path="/">/</span>`;
let accumulated='';
segments.forEach((seg,i)=>{
accumulated+='/'+seg;
const p=accumulated;
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>`;
html+=`<span class="text-slate-600">/</span><span class="cursor-pointer text-brand-light hover:underline" data-action="browse" data-path="${esc(p)}">${esc(seg)}</span>`;
}else{
html+=`<span class="text-slate-600">/</span><span class="text-white font-medium">${esc(seg)}</span>`;
}
@@ -118,10 +292,9 @@
}
// ── File Operations ──
async function fileOp(operation, path, newPath){
async function fileOp(operation,path,newPath){
if(!_currentServerId){toast('warning','请先选择服务器');return false}
const body={server_id:_currentServerId, operation, path};
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}
@@ -129,72 +302,127 @@
return true;
}
async function doDelete(fullPath, isDir){
// P1-1: Delete with double confirmation for directories
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);
if(isDir){
showModal({
title:'⚠️ 危险操作',
text:`即将递归删除目录「${name}」及其所有内容,此操作不可恢复!\n请输入目录名以确认:`,
input:true,inputDefault:'',
confirmText:'确认删除',danger:true,
onConfirm:async(val)=>{
if(val!==name){toast('error','目录名不匹配,取消删除');return}
if(await fileOp('delete',fullPath)){toast('success',`已删除目录: ${name}`);browseDir(_currentPath)}
}
});
}else{
showModal({
title:'删除文件',text:`确定要删除文件「${name}」?`,
confirmText:'删除',danger:true,
onConfirm:async()=>{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 doRename(fullPath,oldName){
const dir=fullPath.slice(0,fullPath.lastIndexOf('/'))||'/';
showModal({
title:'重命名',input:true,inputDefault:oldName,confirmText:'确认',
onConfirm:async(newName)=>{
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);
}
showModal({
title:'新建目录',input:true,inputDefault:'',confirmText:'创建',
onConfirm:async(name)=>{
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,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
// P2-7: Download
async function doDownload(path,name){
toast('info',`正在下载 ${name}...`);
const r=await apiFetch(API+'/sync/download',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','下载失败: '+(e.detail||''));return}
const blob=await r.blob();
const url=URL.createObjectURL(blob);
const a=document.createElement('a');a.href=url;a.download=name;a.click();
URL.revokeObjectURL(url);toast('success',`已下载: ${name}`);
}
// ── File Upload ──
// P2-8: Preview/Edit
async function doPreview(path){
const name=path.split('/').pop();
toast('info','正在读取文件...');
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(!r||!r.ok){const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''));return}
const d=await r.json();
showModal({
title:`📄 ${name}`,textarea:true,textareaValue:d.content,
confirmText:'保存',cancelText:'关闭',
onConfirm:async(content)=>{
if(content===d.content){return}
const wr=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content})});
if(wr&&wr.ok){toast('success','文件已保存')}else{toast('error','保存失败')}
}
});
}
// ── Upload (P2-4: progress bar) ──
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
async function doUploadFiles(files){
if(!_currentServerId){toast('warning','请先选择服务器');return}
if(!files||!files.length)return;
const remotePath=_currentPath||'/';
const progressEl=document.getElementById('uploadProgress');
const barEl=document.getElementById('uploadBar');
const fileEl=document.getElementById('uploadFileName');
const pctEl=document.getElementById('uploadPercent');
progressEl.classList.remove('hidden');
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);
for(let i=0;i<files.length;i++){
const file=files[i];
fileEl.textContent=`[${i+1}/${files.length}] ${file.name}`;
barEl.style.width='0%';pctEl.textContent='0%';
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)}`);
}
await new Promise((resolve,reject)=>{
const fd=new FormData();
fd.append('server_id',_currentServerId);
fd.append('remote_path',remotePath);
fd.append('file',file);
const xhr=new XMLHttpRequest();
xhr.upload.onprogress=e=>{if(e.lengthComputable){const p=Math.round(e.loaded/e.total*100);barEl.style.width=p+'%';pctEl.textContent=p+'%'}};
xhr.onload=()=>{
if(xhr.status===200){ok++;resolve()}
else{fail++;try{const d=JSON.parse(xhr.responseText);toast('error',`${file.name}: ${(d.detail||'上传失败').substring(0,60)}`)}catch(e){toast('error',`${file.name}: 上传失败 (${xhr.status})`)}resolve()}
};
xhr.onerror=()=>{fail++;toast('error',`${file.name}: 网络错误`);resolve()};
xhr.open('POST',API+'/sync/upload');
xhr.setRequestHeader('Authorization','Bearer '+access_token);
xhr.send(fd);
});
}catch(e){fail++;toast('error',`${file.name}: ${e.message}`)}
}
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?', '+fail+' 失败':''}`);
progressEl.classList.add('hidden');
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');
// ── Drag & drop ──
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=>{
@@ -203,7 +431,7 @@
if(e.dataTransfer.files.length)doUploadFiles(e.dataTransfer.files);
});
// Enter key in path input triggers browse
// Enter key in path input
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
loadServers();