feat: complete file manager core operations
This commit is contained in:
@@ -6,8 +6,10 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"chfs-ng/internal/auth"
|
||||
@@ -149,3 +151,44 @@ func TestHTTPPermissionMatrix(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPBatchDeleteAndTransferConflict(t *testing.T) {
|
||||
s, root := permissionTestServer(t, auth.Read|auth.Write|auth.Delete)
|
||||
h := s.Handler()
|
||||
if err := os.WriteFile(filepath.Join(root, "second.txt"), []byte("second"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
mw := multipart.NewWriter(&b)
|
||||
_ = mw.WriteField("filepath", "file.txt")
|
||||
_ = mw.WriteField("filepath", "second.txt")
|
||||
_ = mw.Close()
|
||||
rr := doRequest(h, http.MethodDelete, "/chfs/rmfiles", &b, mw.FormDataContentType())
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("batch delete: got %d", rr.Code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "file.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("file.txt still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(root, "second.txt")); !os.IsNotExist(err) {
|
||||
t.Fatalf("second.txt still exists: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("source"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
form := url.Values{"source": {"source.txt"}, "destination": {"target.txt"}}
|
||||
copyReq := doRequest(h, http.MethodPost, "/chfs/copy", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
|
||||
if copyReq.Code != http.StatusBadRequest {
|
||||
t.Fatalf("copy conflict: got %d", copyReq.Code)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(root, "target.txt"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != "target" {
|
||||
t.Fatalf("target overwritten: %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,30 +72,38 @@ function fileDragStart(e,p){const paths=selected.has(p)?[...selected]:[p];e.data
|
||||
function fileDragEnd(e){e.currentTarget.classList.remove("dragging");document.querySelectorAll('.drop-target').forEach(el=>el.classList.remove('drop-target'))}
|
||||
function folderDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();if(target===current||target===""||[...selected].some(p=>p===target||target.startsWith(p+'/')))return;e.dataTransfer.dropEffect="move";e.currentTarget.classList.add('drop-target')}
|
||||
function folderDragLeave(e){e.currentTarget.classList.remove('drop-target')}
|
||||
async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');toast('正在移动 '+paths.length+' 项…');let done=0,failed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{await api('/chfs/move',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({source,destination})});done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length)fail('移动完成 '+done+' 项,失败 '+failed.length+' 项');else toast('已移动 '+done+' 项')}
|
||||
async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');await runOperation('移动 '+paths.length+' 项',async()=>{let done=0,failed=[],renamed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop());done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('<h2>移动完成</h2><div class="notice">'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast('已移动 '+done+' 项')})}
|
||||
function activateItem(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)}
|
||||
function toggleTile(p){selectItem(p,!selected.has(p))}
|
||||
function openSelected(){if(selected.size!==1)return;const p=[...selected][0],f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)}
|
||||
function toggleAll(v){lastItems.forEach(f=>{const p=current?current+"/"+f.name:f.name;if(v)selected.add(p);else selected.delete(p)});render(lastItems)}
|
||||
function clearSelection(){selected.clear();updateSelection();renderGrid(lastItems);renderList(lastItems)}
|
||||
function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";document.querySelectorAll("#files .file-row").forEach(row=>{const p=row.dataset.searchPath||row.dataset.filePath;row.classList.toggle("selected",!!p&&selected.has(p));const cb=row.querySelector(".selectbox");if(cb)cb.checked=!!p&&selected.has(p)});$("grid").querySelectorAll(".tile").forEach((el,i)=>{const f=lastItems[i];if(f){const p=current?current+"/"+f.name:f.name,yes=selected.has(p);el.classList.toggle("selected",yes);const cb=el.querySelector(".selectbox");if(cb)cb.checked=yes}})}
|
||||
function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";const header=$("files").closest("table")?.querySelector("thead .selectbox");if(header){const total=lastItems.length;header.checked=total>0&&n===total;header.indeterminate=n>0&&n<total}document.querySelectorAll("#files .file-row").forEach(row=>{const p=row.dataset.searchPath||row.dataset.filePath;row.classList.toggle("selected",!!p&&selected.has(p));const cb=row.querySelector(".selectbox");if(cb)cb.checked=!!p&&selected.has(p)});$("grid").querySelectorAll(".tile").forEach((el,i)=>{const f=lastItems[i];if(f){const p=current?current+"/"+f.name:f.name,yes=selected.has(p);el.classList.toggle("selected",yes);const cb=el.querySelector(".selectbox");if(cb)cb.checked=yes}})}
|
||||
function setView(v){localStorage.setItem('chfs-view',v);$("files").parentElement.hidden=v==='grid';$("grid").hidden=v!=='grid';document.querySelectorAll('.viewbtn').forEach(b=>b.classList.remove('active'));$(v+'Btn').classList.add('active')}
|
||||
function itemMenu(e,p){e.preventDefault();if(!selected.has(p)){selected.clear();selected.add(p);updateSelection();renderList(lastItems);renderGrid(lastItems)}const f=lastItems.find(x=>(current?current+'/':'')+x.name===p);const canRead=(f?.mask||'').includes('R'),canWrite=(f?.mask||'').includes('W'),canDelete=(f?.mask||'').includes('D');let html='';if(canRead)html+='<button onclick="downloadFile('+JSON.stringify(p)+');closeContext()">下载</button>';if(f?.dir&&canRead)html+='<button onclick="downloadDir('+JSON.stringify(p)+');closeContext()">下载 ZIP</button>';if(canWrite)html+='<button onclick="renameItem('+JSON.stringify(p)+');closeContext()">重命名</button><button onclick="transferItem('+JSON.stringify(p)+',false);closeContext()">移动到…</button><button onclick="transferItem('+JSON.stringify(p)+',true);closeContext()">复制到…</button>';if(canDelete)html+='<button onclick="removeItem('+JSON.stringify(p)+');closeContext()">删除</button>';if(f?.guestMask?.includes('R'))html+='<button onclick="shareItem('+JSON.stringify(p)+');closeContext()">分享</button>';if(!html)html='<div class="meta" style="padding:8px">没有可用操作</div>';$("context").innerHTML=html;$("context").style.left=Math.min(e.clientX,innerWidth-190)+'px';$("context").style.top=Math.min(e.clientY,innerHeight-220)+'px';$("context").style.display='block'}
|
||||
function closeContext(){$("context").style.display='none'}
|
||||
addEventListener('click',e=>{if(!e.target.closest('#context')&&!e.target.closest('.tilemenu'))closeContext()});
|
||||
function bulkDownload(){if(!selected.size)return;const q=[...selected].map(p=>'path='+encodeURIComponent(p)).join('&');location.href='/chfs/downloadzip?'+q}
|
||||
async function chooseDestination(title){return await ask(title,"目标目录(留空表示当前目录)",current)}
|
||||
async function transferItem(source,copyOnly){const dstDir=await chooseDestination(copyOnly?"复制到":"移动到");if(dstDir===null)return;const name=source.split("/").pop();const base=String(dstDir).replace(/^\/+|\/+$/g,"");const destination=base?(base+"/"+name):name,action=copyOnly?"复制":"移动";toast(action+"中…");try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});await refresh();toast(action+"完成")}catch(e){show('<h2>'+action+'失败</h2><div class="notice">'+esc(e.message)+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}}
|
||||
let destinationResolve=null,destinationPath="";
|
||||
async function chooseDestination(title){destinationPath=current;return await new Promise(async resolve=>{destinationResolve=resolve;try{const d=await api("/chfs/roots"),roots=d.roots||[];const writableCurrent=currentMask.includes("W");if(!writableCurrent){const first=roots.find(r=>String(r.mask||"").includes("W"));if(first)destinationPath=first.path||""}await renderDestinationPicker(title)}catch(e){destinationResolve=null;fail("无法打开目标目录:"+e.message);resolve(null)}})}
|
||||
async function renderDestinationPicker(title){const data=await api("/chfs/files?filepath="+encodeURIComponent(destinationPath));const dirs=(data.files||[]).filter(f=>f.dir&&(f.mask||"").includes("W"));const parent=destinationPath?destinationPath.split("/").slice(0,-1).join("/"):null;let html='<h2>'+esc(title)+'</h2><div class="notice">当前目标:<b>'+esc(destinationPath||"全部文件")+'</b></div><div class="field" style="max-height:42vh;overflow:auto">';if(parent!==null)html+='<button class="ghost" style="display:block;width:100%;text-align:left;margin:5px 0" onclick="destinationPath='+JSON.stringify(parent)+';renderDestinationPicker('+JSON.stringify(title)+')">上一级</button>';for(const d of dirs){const p=destinationPath?(destinationPath+"/"+d.name):d.name;html+='<button class="ghost" style="display:block;width:100%;text-align:left;margin:5px 0" onclick="destinationPath='+JSON.stringify(p)+';renderDestinationPicker('+JSON.stringify(title)+')">'+svgIcon("folder")+' '+esc(d.name)+'</button>'}if(!dirs.length)html+='<div class="meta" style="padding:10px">这里没有可写入的子目录。</div>';html+='</div><div class="row"><button class="ghost" onclick="resolveDestination(null)">取消</button><button onclick="resolveDestination('+JSON.stringify(destinationPath)+')">选择此目录</button></div>';show(html)}
|
||||
function resolveDestination(path){const resolve=destinationResolve;destinationResolve=null;closeModal();if(resolve)resolve(path)}
|
||||
let operationBusy=false;
|
||||
async function runOperation(label,fn){if(operationBusy)return null;operationBusy=true;toast(label+"…");try{return await fn()}finally{operationBusy=false}}
|
||||
function destinationName(name,index){const dot=name.lastIndexOf("."),base=dot>0?name.slice(0,dot):name,ext=dot>0?name.slice(dot):"";return base+" ("+index+")"+ext}
|
||||
async function transferWithConflict(source,destination,copyOnly){if(source===destination){if(!copyOnly)return destination;const slash=destination.lastIndexOf("/"),dir=slash>=0?destination.slice(0,slash):"",name=slash>=0?destination.slice(slash+1):destination;destination=dir?dir+"/"+destinationName(name,2):destinationName(name,2)}if(destination.startsWith(source+"/")){const e=Error("不能移动或复制到自身或子目录");e.status=400;throw e}const endpoint=copyOnly?"/chfs/copy":"/chfs/move";try{await api(endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});return destination}catch(e){if(e.status!==400)throw e;const slash=destination.lastIndexOf("/"),dir=slash>=0?destination.slice(0,slash):"",name=slash>=0?destination.slice(slash+1):destination;for(let i=2;i<=20;i++){const candidate=dir?dir+"/"+destinationName(name,i):destinationName(name,i);try{await api(endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination:candidate})});return candidate}catch(err){if(err.status!==400)throw err}}throw e}}
|
||||
async function transferItem(source,copyOnly){const dstDir=await chooseDestination(copyOnly?"复制到":"移动到");if(dstDir===null)return;const name=source.split("/").pop(),base=String(dstDir).replace(/^\/+|\/+$/g,""),destination=base?(base+"/"+name):name,action=copyOnly?"复制":"移动";await runOperation(action+"中",async()=>{try{const actual=await transferWithConflict(source,destination,copyOnly);await refresh();toast(actual===destination?action+"完成":action+"完成,已自动重命名为 "+actual.split("/").pop())}catch(e){fail(action+"失败:"+e.message)}})}
|
||||
async function bulkMove(){await bulkTransfer(false)}
|
||||
async function bulkCopy(){await bulkTransfer(true)}
|
||||
async function bulkTransfer(copyOnly){if(!selected.size)return;const dstDir=await chooseDestination(copyOnly?"复制选中项目到":"移动选中项目到");if(dstDir===null)return;const sources=[...selected],base=String(dstDir).replace(/^\/+|\/+$/g,""),action=copyOnly?"复制":"移动";toast(action+" "+sources.length+" 项…");const failed=[];let done=0;for(const source of sources){const name=source.split("/").pop(),destination=base?(base+"/"+name):name;try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});done++}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('<h2>部分操作失败</h2><div class="notice">'+esc(action+"完成 "+done+" 项,失败 "+failed.length+" 项。\n\n"+failed.join("\n"))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast(action+"完成,共 "+done+" 项")}
|
||||
async function bulkTransfer(copyOnly){if(!selected.size)return;const dstDir=await chooseDestination(copyOnly?"复制选中项目到":"移动选中项目到");if(dstDir===null)return;const sources=[...selected],base=String(dstDir).replace(/^\/+|\/+$/g,""),action=copyOnly?"复制":"移动";await runOperation(action+" "+sources.length+" 项",async()=>{const failed=[],renamed=[];let done=0;for(const source of sources){const name=source.split("/").pop(),destination=base?(base+"/"+name):name;try{const actual=await transferWithConflict(source,destination,copyOnly);done++;if(actual!==destination)renamed.push(name+" → "+actual.split("/").pop())}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length||renamed.length){show('<h2>'+action+'完成</h2><div class="notice">'+esc(action+"成功 "+done+" 项,失败 "+failed.length+" 项"+(renamed.length?"。\n\n自动重命名:\n"+renamed.join("\n"):"。")+(failed.length?"。\n\n失败:\n"+failed.join("\n"):""))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}else toast(action+"完成,共 "+done+" 项")})}
|
||||
async function bulkDelete(){if(!selected.size)return;const paths=[...selected];if(!await confirmBox("确认删除",paths.length===1?"确定删除 "+paths[0]+"?":"确定删除这 "+paths.length+" 项?删除文件夹时会同时删除其中内容。"))return;await runOperation("删除中",async()=>{try{const body=new URLSearchParams();paths.forEach(p=>body.append("filepath",p));await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body});selected.clear();await refresh();toast(paths.length===1?"已删除":"已删除 "+paths.length+" 项")}catch(e){fail("删除失败:"+e.message)}})}
|
||||
|
||||
|
||||
async function bulkDelete(){if(!selected.size)return;if(!await confirmBox("批量删除","确定删除已选择的 "+selected.size+" 项?"))return;const targets=[...selected];toast("正在删除 "+targets.length+" 项…");const failed=[];let done=0;for(const p of targets){try{await api('/chfs/rmfiles',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({filepath:p})});done++}catch(e){failed.push(p+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('<h2>部分删除失败</h2><div class="notice">'+esc("已删除 "+done+" 项,失败 "+failed.length+" 项。\n\n"+failed.join("\n"))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast("删除完成,共 "+done+" 项")}
|
||||
async function loadSideRoots(){try{const d=await api("/chfs/roots"),roots=d.roots||[];$("sideRoots").innerHTML=roots.map(r=>'<button class="side-item '+(r.path===current?'active':'')+'" data-root-path="'+esc(r.path)+'" onclick="jumpRoot('+JSON.stringify(r.path)+')" ondragover="sideDragOver(event,'+JSON.stringify(r.path)+')" ondragleave="sideDragLeave(event)" ondrop="sideDrop(event,'+JSON.stringify(r.path)+')">'+svgIcon('folder')+' '+esc(r.name)+'</button>').join('')||'<div class="meta" style="padding:8px">没有可进入的位置</div>'}catch{}}
|
||||
async function showRoots(){try{const d=await api("/chfs/roots");const roots=d.roots||[];let html='<h2>选择位置</h2><div>';for(const r of roots)html+='<button class="ghost" style="display:block;width:100%;text-align:left;margin:8px 0" onclick="jumpRoot('+JSON.stringify(r.path)+')"><b>'+esc(r.name)+'</b><span class="meta"> · '+esc(r.path||"/")+' · '+esc(r.mask)+'</span></button>';if(!roots.length)html+='<div class="empty">没有可进入的共享位置</div>';html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){fail(e.message)}}
|
||||
function sideDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.dataTransfer.dropEffect='move';e.currentTarget.classList.add('drop-target')}
|
||||
function sideDragLeave(e){e.currentTarget.classList.remove('drop-target')}
|
||||
async function sideDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};if(!paths.length)return;toast('正在移动 '+paths.length+' 项…');let done=0;for(const source of paths){const name=source.split('/').pop(),destination=target?target+'/'+name:name;try{await api('/chfs/move',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({source,destination})});done++}catch{}}selected.clear();await refresh();toast('已移动 '+done+' 项')}
|
||||
async function sideDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};if(!paths.length)return;await runOperation('正在移动 '+paths.length+' 项',async()=>{const failed=[],renamed=[];let done=0;for(const source of paths){const name=source.split('/').pop(),destination=target?target+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);done++;if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop())}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('<h2>移动完成</h2><div class="notice">'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast('已移动 '+done+' 项')})}
|
||||
|
||||
function jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()}
|
||||
function renderBreadcrumb(){const el=$("crumb"),parts=current?current.split("/").filter(Boolean):[];const rootName=navigationRoot?(navigationRoot===me?.home?'我的空间':'当前位置'): '全部文件';let html='<button class="crumb-seg '+(!current||current===navigationRoot?'current':'')+'" onclick="navigateTo('+JSON.stringify(navigationRoot||'')+')">'+esc(rootName)+'</button>';let acc='';for(let i=0;i<parts.length;i++){acc=acc?acc+'/'+parts[i]:parts[i];html+='<span class="crumb-sep">/</span><button class="crumb-seg '+(i===parts.length-1?'current':'')+'" onclick="navigateTo('+JSON.stringify(acc)+')">'+esc(parts[i])+'</button>'}el.innerHTML=html}
|
||||
@@ -104,8 +112,8 @@ function openDir(p){current=p;refresh()}function goUp(){if(!current||current===n
|
||||
function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")}
|
||||
function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)}
|
||||
async function shareItem(p){const url=new URL("/chfs/shared/"+encodeURI(p),location.href).href;try{await navigator.clipboard.writeText(url);toast("分享链接已复制")}catch{show("<h2>分享链接</h2><div class=\"field\"><input id=\"shareUrl\" value=\""+esc(url)+"\" readonly></div><div class=\"row\"><button class=\"ghost\" onclick=\"closeModal()\">关闭</button><button onclick=\"navigator.clipboard.writeText($(\"shareUrl\").value);closeModal()\">复制</button></div>")}}
|
||||
async function renameItem(p){const name=p.split("/").pop();const n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");toast("正在重命名…");try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});await refresh();toast("已重命名")}catch(e){fail(e.message)}}
|
||||
async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;toast("正在删除…");try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("已删除")}catch(e){fail(e.message)}}
|
||||
async function renameItem(p){const name=p.split("/").pop(),n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");await runOperation("正在重命名",async()=>{try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});selected.clear();await refresh();toast("已重命名")}catch(e){if(e.status===400){const retry=await ask("目标已存在","请输入新的名称",n);if(retry&&retry!==n){try{const retryDest=(p.split("/").slice(0,-1).concat(retry)).join("/");await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:retryDest})});selected.clear();await refresh();toast("已重命名")}catch(err){fail("重命名失败:"+err.message)}}}else fail("重命名失败:"+e.message)}})}
|
||||
async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;await runOperation("正在删除",async()=>{try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});selected.delete(p);await refresh();toast("已删除")}catch(e){fail("删除失败:"+e.message)}})}
|
||||
async function newFolder(){const n=await ask("新建文件夹","文件夹名称");if(!n)return;const p=current?current+"/"+n:n;toast("正在创建文件夹…");try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("文件夹已创建")}catch(e){fail(e.message)}}
|
||||
async function newText(){const n=await ask("新建文本","文件名","new.txt");if(!n)return;const text=await ask("新建文本","文本内容");if(text===null)return;toast("正在创建文本…");try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});await refresh();toast("文本已创建")}catch(e){fail(e.message)}}
|
||||
$("upload").addEventListener("change",async e=>{await uploadFiles(e.target.files);e.target.value=""})
|
||||
@@ -149,7 +157,7 @@ async function login(){try{await fetch("/chfs/session",{method:"POST",headers:{"
|
||||
async function logout(){await fetch("/chfs/session",{method:"DELETE"});location.reload()}
|
||||
function toast(message,error=false){const host=$("toastHost"),el=document.createElement("div");el.className='toast'+(error?' error':'');el.textContent=message;host.appendChild(el);setTimeout(()=>el.remove(),2800)}
|
||||
function fail(message){toast(message,true)}
|
||||
function keyboardNavigation(e){const tag=(e.target?.tagName||"").toLowerCase();if(tag==="input"||tag==="textarea"||tag==="select"||tag==="button"||tag==="a"||e.target?.isContentEditable)return;if(e.key==="Escape"){if($("modal").classList.contains("show"))closeModal();else closeContext();return}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="a"){e.preventDefault();toggleAll(true);return}if(e.key==="Enter"){e.preventDefault();openSelected();return}if(e.key==="Delete"&&selected.size){e.preventDefault();bulkDelete();return}if(e.key==="Backspace"&¤t){e.preventDefault();goUp()}}
|
||||
function keyboardNavigation(e){const tag=(e.target?.tagName||"").toLowerCase();if(tag==="input"||tag==="textarea"||tag==="select"||tag==="button"||tag==="a"||e.target?.isContentEditable)return;if(e.key==="Escape"){if($("modal").classList.contains("show"))closeModal();else closeContext();return}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="a"){e.preventDefault();toggleAll(true);return}if(e.key==="Enter"){e.preventDefault();openSelected();return}if(e.key==="Delete"&&selected.size){e.preventDefault();bulkDelete();return}if(e.key==="Backspace"&¤t){e.preventDefault();goUp();return}if((e.key==="ArrowDown"||e.key==="ArrowUp")&&lastItems.length){e.preventDefault();const paths=selectablePaths(),anchor=selectionAnchor&&paths.includes(selectionAnchor)?selectionAnchor:[...selected][0],idx=Math.max(0,paths.indexOf(anchor)),next=Math.max(0,Math.min(paths.length-1,idx+(e.key==="ArrowDown"?1:-1)));selectItem(paths[next],true,e);if(e.shiftKey)selectionAnchor=anchor;return}if(e.key==="Home"&&lastItems.length){e.preventDefault();selectItem(selectablePaths()[0],true,e);return}if(e.key==="End"&&lastItems.length){e.preventDefault();selectItem(selectablePaths().at(-1),true,e)}}
|
||||
addEventListener("keydown",keyboardNavigation)
|
||||
function show(html,preview=false){$("dialog").innerHTML=html;$("dialog").classList.toggle("preview-dialog",preview);$("modal").classList.add("show")}function closeModal(){$("modal").classList.remove("show");$("dialog").classList.remove("preview-dialog")}
|
||||
function ask(title,label,value="",type="text"){return new Promise(resolve=>{show('<h2>'+esc(title)+'</h2><div class="field"><label>'+esc(label)+'</label><input id="askInput" type="'+type+'" value="'+esc(value)+'"></div><div class="row"><button class="ghost" onclick="closeModal();window._askResolve(null)">取消</button><button onclick="window._askResolve($("askInput").value)">确定</button></div>');window._askResolve=v=>{closeModal();resolve(v)}})}
|
||||
|
||||
Reference in New Issue
Block a user