feat: Monaco编辑器+右键菜单+排序筛选+chmod+压缩解压+新建文件
编辑器增强:
- E-2 Monaco集成: 70vh全屏编辑器, 暗色主题, bracket着色
- E-3 Ctrl+S保存快捷键 + 未保存指示器
- E-4 语言自动检测(30+扩展名映射)
- Monaco预加载: 登录后CDN后台加载, 打开编辑器秒开
- fallback: Monaco未就绪时退回textarea
文件管理增强:
- F-1 右键菜单: 预览/下载/重命名/权限/复制路径/终端/压缩/解压/删除
- F-2 新建文件按钮(📄+)
- F-3 chmod权限修改(右键→输入权限如755)
- F-4 排序切换(名称/大小/时间)
- F-5 文件类型筛选下拉(.conf/.log/.sh/.py等)
- F-6 压缩(tar.gz)
- F-7 解压(tar.gz/zip)
This commit is contained in:
+205
-10
@@ -15,6 +15,7 @@
|
||||
<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)">
|
||||
@@ -35,6 +36,12 @@
|
||||
<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">
|
||||
@@ -52,13 +59,17 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- P2-9: Custom Modal -->
|
||||
<!-- Modal (supports Monaco + textarea fallback) -->
|
||||
<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">
|
||||
<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 id="monacoContainer" class="hidden mb-4 rounded-lg overflow-hidden border border-slate-600" style="height:70vh"></div>
|
||||
<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 id="editorMeta" class="hidden text-xs text-slate-500 mb-2 flex items-center gap-3">
|
||||
<span id="editorSize"></span><span id="editorLang"></span><span id="editorModified" class="text-amber-400 hidden">● 未保存</span>
|
||||
</div>
|
||||
<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>
|
||||
@@ -66,6 +77,21 @@
|
||||
</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>
|
||||
@@ -133,6 +159,119 @@
|
||||
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}
|
||||
|
||||
@@ -171,6 +310,12 @@
|
||||
_modalResolve=onConfirm;
|
||||
}
|
||||
function closeModal(){
|
||||
if(_monacoEditor){_monacoEditor.dispose();_monacoEditor=null}
|
||||
document.getElementById('monacoContainer').classList.add('hidden');
|
||||
document.getElementById('monacoContainer').innerHTML='';
|
||||
document.getElementById('editorMeta').classList.add('hidden');
|
||||
const dialog=document.getElementById('modalDialog');
|
||||
dialog.style.maxWidth='';dialog.style.width='';
|
||||
document.getElementById('modal').classList.add('hidden');_modalResolve=null;
|
||||
}
|
||||
document.getElementById('modalConfirm').onclick=()=>{
|
||||
@@ -238,7 +383,8 @@
|
||||
</div>`;
|
||||
}
|
||||
// File list
|
||||
const sorted=[...entries].sort((a,b)=>(b.is_dir-a.is_dir)||a.name.localeCompare(b.name));
|
||||
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;
|
||||
@@ -432,18 +578,67 @@
|
||||
_fileCache[path]={content:d.content,size:d.size};
|
||||
openEditor(name,path,d.content);
|
||||
}
|
||||
// ── Monaco Editor language mapping (E-4) ──
|
||||
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 _monacoEditor=null;
|
||||
let _monacoOriginalContent='';
|
||||
function openEditor(name,path,content){
|
||||
const lang=getLang(name);
|
||||
const isMonaco=!!window._monacoReady;
|
||||
// Resize modal for editor
|
||||
const dialog=document.getElementById('modalDialog');
|
||||
dialog.style.maxWidth=isMonaco?'90vw':'32rem';
|
||||
dialog.style.width=isMonaco?'90vw':'';
|
||||
document.getElementById('monacoContainer').classList.toggle('hidden',!isMonaco);
|
||||
document.getElementById('modalTextarea').classList.toggle('hidden',isMonaco);
|
||||
document.getElementById('editorMeta').classList.remove('hidden');
|
||||
document.getElementById('editorSize').textContent=formatSize(content.length);
|
||||
document.getElementById('editorLang').textContent=lang;
|
||||
document.getElementById('editorModified').classList.add('hidden');
|
||||
if(isMonaco){
|
||||
// Create Monaco editor
|
||||
const container=document.getElementById('monacoContainer');
|
||||
container.innerHTML='';
|
||||
_monacoOriginalContent=content;
|
||||
_monacoEditor=monaco.editor.create(container,{
|
||||
value:content,language:lang,theme:'vs-dark',
|
||||
automaticLayout:true,minimap:{enabled:false},
|
||||
wordWrap:'on',fontSize:13,lineNumbers:'on',
|
||||
scrollBeyondLastLine:false,readOnly:false,
|
||||
bracketPairColorization:{enabled:true},
|
||||
});
|
||||
// E-3: Ctrl+S save shortcut
|
||||
_monacoEditor.addCommand(monaco.KeyMod.CtrlCmd|monaco.KeyCode.KeyS,()=>{
|
||||
saveEditorContent(path,name);
|
||||
});
|
||||
// Track modifications
|
||||
_monacoEditor.onDidChangeModelContent(()=>{
|
||||
const modified=_monacoEditor.getValue()!==_monacoOriginalContent;
|
||||
document.getElementById('editorModified').classList.toggle('hidden',!modified);
|
||||
});
|
||||
setTimeout(()=>_monacoEditor.focus(),100);
|
||||
}else{
|
||||
document.getElementById('modalTextarea').value=content;
|
||||
}
|
||||
showModal({
|
||||
title:`📄 ${name}`,textarea:true,textareaValue:content,
|
||||
title:`📄 ${name}`,monaco:isMonaco,
|
||||
confirmText:'保存',cancelText:'关闭',
|
||||
onConfirm:async(newContent)=>{
|
||||
if(newContent===content)return;
|
||||
const wr=await apiFetch(API+'/sync/write-file',{method:'POST',headers:apiHeadersJSON(),body:JSON.stringify({server_id:_currentServerId,path,content:newContent})});
|
||||
if(wr&&wr.ok){toast('success','文件已保存');_fileCache[path]={content:newContent,size:newContent.length}}
|
||||
else{const e=wr?await wr.json().catch(()=>({})):{};toast('error','保存失败: '+(e.detail||''))}
|
||||
}
|
||||
onConfirm:()=>saveEditorContent(path,name),
|
||||
});
|
||||
}
|
||||
async function saveEditorContent(path,name){
|
||||
const content=_monacoEditor?_monacoEditor.getValue():document.getElementById('modalTextarea').value;
|
||||
if(content===_monacoOriginalContent){toast('info','无变更');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','文件已保存');_fileCache[path]={content,size:content.length};_monacoOriginalContent=content;document.getElementById('editorModified').classList.add('hidden')}
|
||||
else{const e=wr?await wr.json().catch(()=>({})):{};toast('error','保存失败: '+(e.detail||''))}
|
||||
}
|
||||
|
||||
// ── Upload (P2-4: progress bar) ──
|
||||
function pickUploadFile(){document.getElementById('uploadFileInput').click()}
|
||||
|
||||
Reference in New Issue
Block a user