Files
Nexus/web/app/files.html
T
Your Name be633f3e58
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run
feat: IDE编辑器重构 — 多标签+文件树+最小化+状态栏
- 全屏IDE面板替代模态框编辑器
- 多文件标签页: 独立Monaco实例, 切换保持状态
- 左侧文件树: 基于当前目录, 点击文件打开, 点击目录导航
- 最小化/恢复: 底部最小化栏显示所有打开的文件标签
- 状态栏: 光标位置+语言+编码+未保存指示器
- Ctrl+S保存 + 未保存关闭确认
- 标签栏: 点击切换, ×关闭, amber色未保存标记
- 文件树同步: 浏览新目录时自动更新文件树
- 模态框保留用于非编辑操作(重命名/删除/新建等)
2026-05-31 01:50:34 +08:00

871 lines
49 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 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-2 flex-wrap">
<select id="serverSelect" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm focus:outline-none focus:ring-2 focus:ring-brand" onchange="onServerChange()">
<option value="">-- 选择服务器 --</option>
</select>
<button id="refreshServersBtn" class="px-2 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="刷新服务器列表" onclick="loadServers()">🔄</button>
<input id="dirPath" type="text" value="/www/wwwroot" placeholder="/www/wwwroot" class="px-3 py-1.5 bg-slate-800 border border-slate-700 rounded-lg text-white text-sm placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-brand w-48">
<button id="browseBtn" onclick="browseDir()" class="px-3 py-1.5 bg-brand hover:bg-brand-dark text-white text-sm rounded-lg transition">浏览</button>
<button onclick="doMkdir()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建目录">📁+</button>
<button onclick="doNewFile()" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white text-sm rounded-lg transition" title="新建文件">📄+</button>
<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>
<button onclick="openTerminal()" class="px-3 py-1.5 bg-green-700 hover:bg-green-600 text-white text-sm rounded-lg transition" title="打开SSH终端">🖥 终端</button>
<input id="uploadFileInput" type="file" multiple class="hidden" onchange="doUploadFiles(this.files)">
</div>
</header>
<!-- 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">
<select onchange="setSort(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
<option value="name">名称↑</option><option value="size">大小</option><option value="time">时间</option>
</select>
<select onchange="filterByExt(this.value)" class="px-2 py-1 bg-slate-800 border border-slate-700 rounded text-white text-xs focus:outline-none">
<option value="">全部</option><option value="conf">.conf</option><option value="log">.log</option><option value="sh">.sh</option><option value="py">.py</option><option value="json">.json</option><option value="yml">.yml</option><option value="txt">.txt</option>
</select>
</div>
<!-- Batch Action Bar -->
<div id="batchBar" class="hidden bg-slate-800 border-b border-slate-700 px-6 py-2 flex items-center gap-3">
<span id="batchCount" class="text-slate-300 text-sm"></span>
<button onclick="batchDelete()" class="px-3 py-1 bg-red-600 hover:bg-red-700 text-white text-xs rounded-lg transition">🗑 批量删除</button>
<button onclick="clearSelection()" class="px-3 py-1 bg-slate-600 hover:bg-slate-500 text-white text-xs rounded-lg transition">取消选择</button>
</div>
<main class="flex-1 overflow-y-auto p-6">
<div id="fileList" class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<div class="px-4 py-12 text-center text-slate-500">
<div class="text-4xl mb-3">📂</div>
<p>选择服务器并输入目录路径开始浏览</p>
</div>
</div>
</main>
</div>
<!-- IDE Editor Panel -->
<div id="editorPanel" class="hidden fixed inset-0 z-40 bg-slate-950 flex">
<!-- File tree sidebar -->
<div id="editorTree" class="w-56 bg-slate-900 border-r border-slate-800 flex flex-col shrink-0">
<div class="px-3 py-2 border-b border-slate-800 flex items-center justify-between">
<span class="text-xs text-slate-500 font-medium">📁 文件列表</span>
<button onclick="toggleEditorTree()" class="text-slate-600 hover:text-slate-400 text-xs"></button>
</div>
<div id="editorTreeList" class="flex-1 overflow-y-auto text-sm"></div>
</div>
<!-- Editor area -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Tab bar -->
<div id="editorTabBar" class="flex bg-slate-900 border-b border-slate-800 overflow-x-auto shrink-0"></div>
<!-- Monaco container (each tab gets its own child div) -->
<div id="editorMonacoWrap" class="flex-1 relative"></div>
<!-- Status bar -->
<div class="flex items-center gap-4 px-4 py-1.5 bg-slate-900 border-t border-slate-800 text-xs text-slate-500 shrink-0">
<span id="editorPos">Ln 1, Col 1</span>
<span id="editorLang">plaintext</span>
<span>UTF-8</span>
<span id="editorMod" class="text-amber-400 hidden">● 未保存</span>
<div class="flex-1"></div>
<span id="editorFileName" class="text-slate-400"></span>
<button onclick="minimizeEditor()" class="px-2 py-0.5 hover:text-white hover:bg-slate-700 rounded transition" title="最小化"></button>
<button onclick="closeAllTabs()" class="px-2 py-0.5 hover:text-red-400 hover:bg-red-900/30 rounded transition" title="关闭全部"></button>
</div>
</div>
</div>
<!-- Minimized editor bar -->
<div id="editorMinBar" class="hidden fixed bottom-0 left-0 right-0 z-30 bg-slate-900 border-t border-slate-700 px-4 py-1.5 flex items-center gap-2">
<div id="editorMinTabs" class="flex-1 flex items-center gap-1 overflow-x-auto"></div>
<button onclick="restoreEditor()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-white hover:bg-slate-700 rounded">▲ 展开</button>
<button onclick="closeAllTabs()" class="px-2 py-0.5 text-xs text-slate-400 hover:text-red-400 hover:bg-red-900/30 rounded"></button>
</div>
<!-- Modal (for non-editor operations: rename/delete/mkdir/chmod/compress) -->
<div id="modal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div id="modalDialog" class="bg-slate-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl border border-slate-700">
<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">
<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>
<!-- F-1: Right-click context menu -->
<div id="contextMenu" class="hidden fixed z-50 bg-slate-800 border border-slate-700 rounded-lg shadow-xl py-1 min-w-[180px]">
<button data-ctx="preview" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">👁 预览/编辑</button>
<button data-ctx="download" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">⬇ 下载</button>
<button data-ctx="rename" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">✏ 重命名</button>
<button data-ctx="chmod" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🔐 修改权限</button>
<button data-ctx="copyPath" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📋 复制路径</button>
<button data-ctx="terminal" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">🖥 在此打开终端</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-ctx="compress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📦 压缩为 tar.gz</button>
<button data-ctx="decompress" class="ctx-item w-full text-left px-4 py-2 text-sm text-slate-200 hover:bg-slate-700 transition">📂 解压到当前目录</button>
<div class="border-t border-slate-700 my-1"></div>
<button data-ctx="delete" class="ctx-item w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-900/30 transition">🗑 删除</button>
</div>
<script src="/app/api.js"></script>
<script src="/app/layout.js"></script>
<script>
// Monaco Editor preload (inline to avoid api.js caching issues)
if(!window._monacoLoading&&!window._monacoReady){
window._monacoLoading=true;
const _ms=document.createElement('script');
_ms.src='https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs/loader.js';
_ms.onload=function(){
require.config({paths:{vs:'https://cdn.jsdelivr.net/npm/monaco-editor@0.45.0/min/vs'}});
require(['vs/editor/editor.main'],function(){window.monaco=monaco;window._monacoReady=true});
};
document.head.appendChild(_ms);
}
initLayout('files');
let _currentPath='/';
let _currentServerId=null;
let _allEntries=[];
let _selectedPaths=new Set();
let _fileCache={}; // {path: {content, size, loaded_at}}
// ── 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 ──
let _clickTimer=null;
const fileListEl=document.getElementById('fileList');
fileListEl.addEventListener('click',e=>{
const btn=e.target.closest('[data-action]');
if(!btn)return;
// Delay single-click to allow dblclick to cancel it
clearTimeout(_clickTimer);
_clickTimer=setTimeout(()=>{
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);
},250);
});
// ── Double-click: open editor for files, browse for directories ──
fileListEl.addEventListener('dblclick',e=>{
clearTimeout(_clickTimer); // Cancel single-click action
const row=e.target.closest('.file-row');
if(!row)return;
const pathBtn=row.querySelector('[data-action="rename"]');
if(!pathBtn)return;
const path=pathBtn.dataset.path;
const isDir=!!row.querySelector('[data-action="browse"]');
if(isDir){browseDir(path)}else{doPreview(path)}
});
// ── F-1: Right-click context menu ──
let _ctxPath='',_ctxIsDir=false,_ctxName='';
const ctxMenu=document.getElementById('contextMenu');
fileListEl.addEventListener('contextmenu',e=>{
e.preventDefault();
const row=e.target.closest('.file-row');
if(!row){ctxMenu.classList.add('hidden');return}
const renameBtn=row.querySelector('[data-action="rename"]');
if(!renameBtn)return;
_ctxPath=renameBtn.dataset.path;_ctxName=renameBtn.dataset.name||'';
_ctxIsDir=!!row.querySelector('[data-action="browse"]');
// Show/hide items based on file type
ctxMenu.querySelectorAll('[data-ctx="preview"],[data-ctx="download"]').forEach(b=>{b.classList.toggle('hidden',_ctxIsDir)});
ctxMenu.querySelectorAll('[data-ctx="decompress"]').forEach(b=>{b.classList.toggle('hidden',!_ctxName.match(/\.(tar\.gz|tgz|zip)$/))});
// Position
const x=Math.min(e.clientX,window.innerWidth-200);
const y=Math.min(e.clientY,window.innerHeight-350);
ctxMenu.style.left=x+'px';ctxMenu.style.top=y+'px';
ctxMenu.classList.remove('hidden');
});
document.addEventListener('click',()=>ctxMenu.classList.add('hidden'));
ctxMenu.addEventListener('click',async e=>{
const btn=e.target.closest('[data-ctx]');
if(!btn)return;
const action=btn.dataset.ctx;
ctxMenu.classList.add('hidden');
if(action==='preview')doPreview(_ctxPath);
else if(action==='download')doDownload(_ctxPath,_ctxName);
else if(action==='rename')doRename(_ctxPath,_ctxName);
else if(action==='delete')doDelete(_ctxPath,_ctxIsDir);
else if(action==='copyPath'){navigator.clipboard.writeText(_ctxPath);toast('success','已复制路径')}
else if(action==='chmod')doChmod(_ctxPath,_ctxName);
else if(action==='terminal'){window.open(`/app/terminal.html?server_id=${_currentServerId}&path=${encodeURIComponent(_ctxPath.replace(/\/[^/]+$/,'')||'/')}`,'_blank')}
else if(action==='compress')doCompress([_ctxPath],_ctxName);
else if(action==='decompress')doDecompress(_ctxPath);
});
// ── F-3: chmod ──
async function doChmod(path,name){
// Get current perms from _allEntries
const entry=_allEntries.find(e=>{const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;return fp===path});
const currentPerms=entry?.perms||'';
showModal({title:`🔐 修改权限 — ${name}`,text:`当前权限: ${currentPerms}`,input:true,inputDefault:'',confirmText:'修改',
onConfirm:async(mode)=>{
if(!mode.match(/^[0-7]{3,4}$/)){toast('error','权限格式错误,如 755');return}
const r=await apiFetch(API+'/sync/chmod',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,mode})});
if(r&&r.ok){toast('success',`权限已修改: ${mode}`);browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','修改失败: '+(e.detail||''))}
}
});
}
// ── F-6: Compress ──
async function doCompress(paths,baseName){
const dest=_currentPath.replace(/\/$/,'')+'/'+(baseName||'archive')+'.tar.gz';
showModal({title:'📦 压缩文件',text:`将 ${paths.length} 个文件压缩为:\n${dest}`,input:true,inputDefault:dest,confirmText:'压缩',
onConfirm:async(destPath)=>{
if(!destPath)return;
const r=await apiFetch(API+'/sync/compress',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,paths,dest:destPath,format:'tar.gz'})});
if(r&&r.ok){toast('success','压缩完成');browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','压缩失败: '+(e.detail||''))}
}
});
}
// ── F-7: Decompress ──
async function doDecompress(path){
const dest=_currentPath.replace(/\/$/,'')+'/';
showModal({title:'📂 解压文件',text:`解压到: ${dest}`,confirmText:'解压',
onConfirm:async()=>{
const r=await apiFetch(API+'/sync/decompress',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,dest})});
if(r&&r.ok){toast('success','解压完成');browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','解压失败: '+(e.detail||''))}
}
});
}
// ── F-2: New file ──
async function doNewFile(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
showModal({title:'📄 新建文件',input:true,inputDefault:'',confirmText:'创建',
onConfirm:async(name)=>{
if(!name)return;
if(name.includes('/')){toast('error','文件名不能包含 /');return}
const path=_currentPath.replace(/\/$/,'')+'/'+name;
const r=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content:''})});
if(r&&r.ok){toast('success',`已创建: ${name}`);browseDir(_currentPath)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','创建失败: '+(e.detail||''))}
}
});
}
// ── F-4: Sort toggle ──
let _sortBy='name';let _sortAsc=true;
function setSort(by){
if(_sortBy===by){_sortAsc=!_sortAsc}else{_sortBy=by;_sortAsc=true}
renderFileList(_allEntries);
}
function sortEntries(entries){
return[...entries].sort((a,b)=>{
if(a.is_dir!==b.is_dir)return b.is_dir-a.is_dir;
let cmp=0;
if(_sortBy==='name')cmp=a.name.localeCompare(b.name);
else if(_sortBy==='size')cmp=parseInt(a.size||0)-parseInt(b.size||0);
else if(_sortBy==='time')cmp=(a.modified||'').localeCompare(b.modified||'');
return _sortAsc?cmp:-cmp;
});
}
// ── F-5: File type filter ──
let _filterExt='';
function filterByExt(ext){_filterExt=ext;renderFileList(_allEntries)}
/** H-29: HTML-escape a string for safe innerHTML insertion */
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Open SSH terminal to current server/path ──
function openTerminal(){
if(!_currentServerId){toast('warning','请先选择服务器');return}
const path=_currentPath||'/www/wwwroot';
window.open(`/app/terminal.html?server_id=${_currentServerId}&path=${encodeURIComponent(path)}`,'_blank');
}
// ── Server change: reset path and clear file list ──
function onServerChange(){
document.getElementById('dirPath').value='/www/wwwroot';
document.getElementById('breadcrumb').classList.add('hidden');
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-4xl mb-3">📂</div><p>点击「浏览」查看目录</p></div>';
_fileCache={};
}
// ── 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;
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);_selectedPaths.clear();updateBatchBar();
fileListEl.innerHTML='<div class="px-4 py-12 text-center text-slate-500"><div class="text-2xl mb-2 animate-spin">⏳</div>加载中...</div>';
try{
const r=await apiFetch(API+'/sync/browse',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:_currentPath})});
if(!r){fileListEl.innerHTML='<div class="px-4 py-12 text-center text-red-400">认证失败</div>';return}
const d=await r.json();
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}
updateBreadcrumb(_currentPath);
_allEntries=d.entries||[];
renderFileList(_allEntries);
preloadFiles(_allEntries); // Background preload small text files
if(_editorVisible)renderFileTree(); // Update IDE file tree
}catch(e){
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 filtered=_filterExt?entries.filter(e=>{if(e.is_dir)return true;const ext=e.name.includes('.')?e.name.split('.').pop().toLowerCase():'';return ext===_filterExt}):entries;
const sorted=sortEntries(filtered);
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 ──
document.getElementById('breadcrumbPath').addEventListener('click',e=>{
const el=e.target.closest('[data-action]');
if(!el)return;
const action=el.dataset.action;
if(action==='browse')browseDir(el.dataset.path);
});
function updateBreadcrumb(path){
const bc=document.getElementById('breadcrumb');
const bcPath=document.getElementById('breadcrumbPath');
bc.classList.remove('hidden');
const segments=path.split('/').filter(Boolean);
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;
const sep=i===0?'':'<span class="text-slate-600">/</span>';
if(i<segments.length-1){
html+=`${sep}<span class="cursor-pointer text-brand-light hover:underline" data-action="browse" data-path="${esc(p)}">${esc(seg)}</span>`;
}else{
html+=`${sep}<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;
}
// P1-1: Delete with double confirmation for directories
async function doDelete(fullPath,isDir){
const name=fullPath.split('/').pop();
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('/'))||'/';
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}
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)}
}
});
}
// 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 content preload + cache ──
const PRELOAD_MAX_SIZE=1000000;
const PRELOAD_MAX_FILES=10;
const PRELOAD_TEXT_EXTS=new Set(['conf','cfg','ini','toml','sh','bash','py','js','ts','json','yml','yaml','html','htm','css','xml','sql','md','env','txt','log','service','nginx','vhost','properties']);
function isTextFile(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return PRELOAD_TEXT_EXTS.has(ext)||ext.length<=4}
async function preloadFiles(entries){
const toPreload=entries.filter(e=>!e.is_dir&&!e.is_link&&parseInt(e.size)<=PRELOAD_MAX_SIZE&&parseInt(e.size)>0&&isTextFile(e.name)).slice(0,PRELOAD_MAX_FILES);
await Promise.allSettled(toPreload.map(async e=>{
const path=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(_fileCache[path])return;
try{const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r&&r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size}}}catch(e){}
}));
}
// P2-8: Preview/Edit (with cache) → IDE editor
async function doPreview(path){
const name=path.split('/').pop();
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
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();
_fileCache[path]={content:d.content,size:d.size};
openFileInEditor(path,name,d.content);
}
// ── IDE Editor ──
const LANG_MAP={conf:'ini',cfg:'ini',ini:'ini',toml:'ini',sh:'shell',bash:'shell',zsh:'shell',
py:'python',js:'javascript',ts:'typescript',json:'json',yml:'yaml',yaml:'yaml',
html:'html',htm:'html',css:'css',xml:'xml',sql:'sql',md:'markdown',
env:'ini',txt:'plaintext',log:'plaintext',service:'ini',nginx:'ini',properties:'ini',
go:'go',rs:'rust',rb:'ruby',java:'java',php:'php',c:'cpp',h:'cpp',cpp:'cpp'};
function getLang(name){const ext=name.includes('.')?name.split('.').pop().toLowerCase():'';return LANG_MAP[ext]||'plaintext'}
let _openTabs=[]; // [{path, name, content, originalContent, language, editor, container, modified}]
let _activeTab=-1;
let _editorVisible=false;
let _editorMinimized=false;
function openFileInEditor(path,name,content){
// Check if already open
const existing=_openTabs.findIndex(t=>t.path===path);
if(existing>=0){switchTab(existing);return}
// Add new tab
const lang=getLang(name);
const container=document.createElement('div');
container.className='absolute inset-0';
container.style.display='none';
document.getElementById('editorMonacoWrap').appendChild(container);
const tab={path,name,content,originalContent:content,language:lang,editor:null,container,modified:false};
_openTabs.push(tab);
const idx=_openTabs.length-1;
// Create Monaco instance
if(window._monacoReady){
tab.editor=monaco.editor.create(container,{
value:content,language:lang,theme:'vs-dark',
automaticLayout:true,minimap:{enabled:true,side:'right'},
wordWrap:'on',fontSize:13,lineNumbers:'on',
scrollBeyondLastLine:false,bracketPairColorization:{enabled:true},
scrollbar:{verticalScrollbarSize:10},
});
tab.editor.addCommand(monaco.KeyMod.CtrlCmd|monaco.KeyCode.KeyS,()=>saveCurrentTab());
tab.editor.onDidChangeModelContent(()=>{
tab.modified=tab.editor.getValue()!==tab.originalContent;
updateStatusBar();renderTabBar();
});
tab.editor.onDidChangeCursorPosition(e=>{
document.getElementById('editorPos').textContent=`Ln ${e.position.lineNumber}, Col ${e.position.column}`;
});
}
switchTab(idx);
showEditorPanel();
renderFileTree();
}
function switchTab(idx){
if(idx<0||idx>=_openTabs.length)return;
// Hide current
if(_activeTab>=0&&_openTabs[_activeTab]){
const prev=_openTabs[_activeTab];
if(prev.container)prev.container.style.display='none';
}
_activeTab=idx;
const tab=_openTabs[idx];
if(tab.container)tab.container.style.display='';
if(tab.editor){tab.editor.focus();tab.editor.layout()}
updateStatusBar();renderTabBar();renderMinBar();
}
function closeTab(idx){
if(idx<0||idx>=_openTabs.length)return;
const tab=_openTabs[idx];
if(tab.modified&&!confirm(`文件 "${tab.name}" 有未保存的修改,确定关闭?`))return;
if(tab.editor)tab.editor.dispose();
if(tab.container)tab.container.remove();
_openTabs.splice(idx,1);
if(_openTabs.length===0){hideEditorPanel();return}
if(_activeTab>=_openTabs.length)_activeTab=_openTabs.length-1;
switchTab(_activeTab);
}
function closeAllTabs(){
const unsaved=_openTabs.filter(t=>t.modified).length;
if(unsaved&&!confirm(`有 ${unsaved} 个文件未保存,确定全部关闭?`))return;
_openTabs.forEach(t=>{if(t.editor)t.editor.dispose();if(t.container)t.container.remove()});
_openTabs=[];_activeTab=-1;
hideEditorPanel();
}
function saveCurrentTab(){
if(_activeTab<0)return;
const tab=_openTabs[_activeTab];
const content=tab.editor?tab.editor.getValue():tab.content;
if(content===tab.originalContent){toast('info','无变更');return}
apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path:tab.path,content})}).then(r=>{
if(r&&r.ok){toast('success','已保存');tab.originalContent=content;tab.modified=false;_fileCache[tab.path]={content,size:content.length};updateStatusBar();renderTabBar()}
else{r?r.json().then(e=>toast('error','保存失败: '+(e.detail||''))).catch(()=>toast('error','保存失败')):toast('error','保存失败')}
});
}
function showEditorPanel(){
_editorVisible=true;_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
// Trigger layout on active Monaco
setTimeout(()=>{if(_activeTab>=0&&_openTabs[_activeTab].editor)_openTabs[_activeTab].editor.layout()},50);
}
function hideEditorPanel(){
_editorVisible=false;
document.getElementById('editorPanel').classList.add('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
renderMinBar();
}
function minimizeEditor(){
_editorMinimized=true;
document.getElementById('editorPanel').classList.add('hidden');
document.getElementById('editorMinBar').classList.remove('hidden');
renderMinBar();
}
function restoreEditor(){
_editorMinimized=false;
document.getElementById('editorPanel').classList.remove('hidden');
document.getElementById('editorMinBar').classList.add('hidden');
if(_activeTab>=0&&_openTabs[_activeTab].editor){setTimeout(()=>_openTabs[_activeTab].editor.layout(),50)}
}
function renderTabBar(){
const bar=document.getElementById('editorTabBar');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
const mod=t.modified?'● ':'';
return `<div class="flex items-center gap-1 px-3 py-2 text-xs cursor-pointer border-r border-slate-800 shrink-0 ${active?'bg-slate-800 text-white':'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'}" onclick="switchTab(${i})">
<span class="${t.modified?'text-amber-400':''}">${mod}${esc(t.name)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
</div>`;
}).join('');
}
function renderMinBar(){
const bar=document.getElementById('editorMinTabs');
bar.innerHTML=_openTabs.map((t,i)=>{
const active=i===_activeTab;
return `<div class="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer rounded ${active?'bg-slate-700 text-white':'text-slate-400 hover:text-slate-200'}" onclick="switchTab(${i});restoreEditor()">
<span class="${t.modified?'text-amber-400':''}">${t.modified?'● ':''}${esc(t.name)}</span>
<button onclick="event.stopPropagation();closeTab(${i})" class="ml-1 text-slate-600 hover:text-red-400">×</button>
</div>`;
}).join('');
}
function updateStatusBar(){
if(_activeTab<0)return;
const tab=_openTabs[_activeTab];
document.getElementById('editorLang').textContent=tab.language;
document.getElementById('editorMod').classList.toggle('hidden',!tab.modified);
document.getElementById('editorFileName').textContent=tab.path;
}
function renderFileTree(){
const list=document.getElementById('editorTreeList');
let html=`<div class="px-3 py-1.5 text-xs text-slate-600 cursor-pointer hover:bg-slate-800" onclick="browseDir('${esc(_currentPath.replace(/\/[^/]+\/?$/,'')||'/')}')">📂 ..</div>`;
const sorted=sortEntries(_allEntries);
sorted.forEach(e=>{
const fp=_currentPath.replace(/\/$/,'')+'/'+e.name;
if(e.is_dir){
html+=`<div class="px-3 py-1 text-xs text-slate-400 cursor-pointer hover:bg-slate-800 truncate" onclick="browseDir('${esc(fp)}')">📁 ${esc(e.name)}</div>`;
}else{
const fi=fileIcon(e.name,false,e.is_link);
const inTab=_openTabs.findIndex(t=>t.path===fp);
const active=inTab===_activeTab;
html+=`<div class="px-3 py-1 text-xs cursor-pointer hover:bg-slate-800 truncate ${active?'bg-slate-800 text-white':'text-slate-400'}" onclick="openFileFromTree('${esc(fp)}','${esc(e.name)}')">
${fi.icon} ${esc(e.name)}${inTab>=0?'<span class="text-brand-light ml-1">●</span>':''}
</div>`;
}
});
list.innerHTML=html;
}
async function openFileFromTree(path,name){
const cached=_fileCache[path];
if(cached){openFileInEditor(path,name,cached.content);return}
const r=await apiFetch(API+'/sync/read-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path})});
if(r&&r.ok){const d=await r.json();_fileCache[path]={content:d.content,size:d.size};openFileInEditor(path,name,d.content)}
else{const e=r?await r.json().catch(()=>({})):{};toast('error','读取失败: '+(e.detail||''))}
}
function toggleEditorTree(){
const tree=document.getElementById('editorTree');
tree.classList.toggle('hidden');
if(_activeTab>=0&&_openTabs[_activeTab].editor)setTimeout(()=>_openTabs[_activeTab].editor.layout(),50);
}
}
// ── 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(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{
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}`)}
}
progressEl.classList.add('hidden');
if(ok>0)toast('success',`上传完成: ${ok} 个文件${fail?`, ${fail} 失败`:''}`);
if(ok>0)browseDir(_currentPath);
document.getElementById('uploadFileInput').value='';
}
// ── 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=>{
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
document.getElementById('dirPath').addEventListener('keydown',e=>{if(e.key==='Enter')browseDir()});
// Auto-select server from URL param (e.g., from servers.html "文件" button)
loadServers().then(()=>{
const urlServerId=new URLSearchParams(window.location.search).get('server_id');
if(urlServerId){
const sel=document.getElementById('serverSelect');
sel.value=urlServerId;
_currentServerId=parseInt(urlServerId);
browseDir('/www/wwwroot');
}
});
</script>
</body></html>