feat: improve file operation feedback

This commit is contained in:
OpenClaw Agent
2026-09-22 02:52:08 +08:00
parent 63bf371e6d
commit cd980777b5
+9 -9
View File
@@ -77,12 +77,12 @@ 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;try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});refresh()}catch(e){show('<h2>操作失败</h2><div class="notice">'+esc(e.message)+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}}
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>')}}
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 base=String(dstDir).replace(/^\/+|\/+$/g,"");const failed=[];for(const source of [...selected]){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})})}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('<h2>部分操作失败</h2><div class="notice">'+esc(failed.join("\n"))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}
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 bulkDelete(){if(!selected.size)return;if(!await confirmBox("批量删除","确定删除已选择的 "+selected.size+" 项?"))return;for(const p of [...selected]){try{await api('/chfs/rmfiles',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({filepath:p})})}catch(e){fail(p+': '+e.message)}}selected.clear();refresh()}
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':'')+'" onclick="jumpRoot('+JSON.stringify(r.path)+')">'+svgIcon(r.home?'folder':'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 jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()}
@@ -92,18 +92,18 @@ 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("/");try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});refresh()}catch(e){fail(e.message)}}
async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){fail(e.message)}}
async function newFolder(){const n=await ask("新建文件夹","文件夹名称");if(!n)return;const p=current?current+"/"+n:n;try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}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;try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});refresh()}catch(e){fail(e.message)}}
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 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=""})
async function uploadFiles(files){const list=[...files].map(file=>({file,relativePath:file.webkitRelativePath||file.name}));if(!list.length)return;await uploadEntries(list)}
async function uploadEntries(entries){show('<h2>上传文件</h2><div class="notice">正在上传 '+entries.length+' 个文件;目录结构会按原路径保留。</div><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>');const box=$("uploadList");for(const item of entries){const row=document.createElement("div");row.className="upload-row";row.innerHTML='<b>'+esc(item.relativePath)+'</b><span class="meta">'+fmt(item.file.size)+' · 准备上传</span><div class="progress"><i></i></div>';box.appendChild(row);await uploadOne(item.file,row,item.relativePath)}refresh()}
async function uploadEntries(entries){show('<h2>上传文件</h2><div class="notice">正在上传 '+entries.length+' 个文件;目录结构会按原路径保留。</div><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>');const box=$("uploadList");let failed=0;for(const item of entries){const row=document.createElement("div");row.className="upload-row";row.innerHTML='<b>'+esc(item.relativePath)+'</b><span class="meta">'+fmt(item.file.size)+' · 准备上传</span><div class="progress"><i></i></div>';box.appendChild(row);const ok=await uploadOne(item.file,row,item.relativePath);if(!ok)failed++}await refresh();if(failed)toast(failed+" 个文件上传失败",true);else toast("上传完成,共 "+entries.length+" 个文件")}
async function collectDroppedEntries(items){const out=[];async function walk(entry,prefix=""){if(entry.isFile){await new Promise(resolve=>entry.file(file=>{out.push({file,relativePath:prefix+file.name});resolve()},()=>resolve()));return}if(entry.isDirectory){const reader=entry.createReader();const all=[];while(true){const batch=await new Promise(resolve=>reader.readEntries(resolve,()=>resolve([])));if(!batch.length)break;all.push(...batch)}for(const child of all)await walk(child,prefix+entry.name+'/')}}for(const item of items){const entry=item.webkitGetAsEntry?.();if(entry)await walk(entry);else{const file=item.getAsFile?.();if(file)out.push({file,relativePath:file.name})}}return out}
function showMobileTransfer(){const u=new URL(location.href);u.hash='folder='+encodeURIComponent(current);show('<h2>手机传输</h2><div class="notice">用手机扫描二维码打开当前文件夹。登录后即可按你的权限上传、下载和管理文件。</div><img class="qr" src="/chfs/qrcode?url='+encodeURIComponent(u.href)+'" alt="QR Code"><div class="field"><input value="'+esc(u.href)+'" readonly></div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}
function setupDropZone(){const z=$("dropZone"),main=$("fileCard");let depth=0;[document,main].forEach(el=>{el.addEventListener('dragenter',e=>{if(!e.dataTransfer?.types.includes('Files'))return;e.preventDefault();depth++;z.style.display='block';z.classList.add('drop-active')});el.addEventListener('dragover',e=>{if(e.dataTransfer?.types.includes('Files'))e.preventDefault()});el.addEventListener('dragleave',e=>{if(!e.dataTransfer?.types.includes('Files'))return;depth--;if(depth<=0){depth=0;z.classList.remove('drop-active')}});el.addEventListener('drop',async e=>{if(!e.dataTransfer?.types.includes('Files'))return;e.preventDefault();depth=0;z.classList.remove('drop-active');z.style.display='none';const entries=await collectDroppedEntries(e.dataTransfer.items||[]);if(entries.length)await uploadEntries(entries);else if(e.dataTransfer.files?.length)await uploadFiles(e.dataTransfer.files)})})}
function uploadOne(file,row,relativePath=file.name){return new Promise(resolve=>{const xhr=new XMLHttpRequest(),fd=new FormData();const clean=String(relativePath||file.name).replace(/\\/g,'/').replace(/^\/+|\/+$/g,'');const parts=clean.split('/');const name=parts.pop()||file.name;const folder=parts.length?[current,...parts].filter(Boolean).join('/') : current;fd.append("folder",folder);fd.append("file",file,name);const meta=row.querySelector('.meta'),bar=row.querySelector('i');xhr.open('POST','/chfs/upload');xhr.upload.onprogress=e=>{if(e.lengthComputable){const pct=Math.round(e.loaded/e.total*100);bar.style.width=pct+'%';meta.textContent=fmt(file.size)+' · '+pct+'%'}};xhr.onload=()=>{if(xhr.status>=200&&xhr.status<300){bar.style.width='100%';meta.textContent=fmt(file.size)+' · 完成'}else{meta.textContent='上传失败';bar.style.width='0'}};xhr.onerror=()=>{meta.textContent='网络错误';bar.style.width='0'};xhr.onloadend=resolve;xhr.send(fd)})}
function uploadOne(file,row,relativePath=file.name){return new Promise(resolve=>{const xhr=new XMLHttpRequest(),fd=new FormData();const clean=String(relativePath||file.name).replace(/\\/g,'/').replace(/^\/+|\/+$/g,'');const parts=clean.split('/');const name=parts.pop()||file.name;const folder=parts.length?[current,...parts].filter(Boolean).join('/') : current;fd.append("folder",folder);fd.append("file",file,name);const meta=row.querySelector('.meta'),bar=row.querySelector('i');xhr.open('POST','/chfs/upload');xhr.upload.onprogress=e=>{if(e.lengthComputable){const pct=Math.round(e.loaded/e.total*100);bar.style.width=pct+'%';meta.textContent=fmt(file.size)+' · '+pct+'%'}};xhr.onload=()=>{const ok=xhr.status>=200&&xhr.status<300;if(ok){bar.style.width='100%';meta.textContent=fmt(file.size)+' · 完成'}else{meta.textContent='上传失败'+(xhr.status?' · '+xhr.status:'');bar.style.width='0'};resolve(ok)};xhr.onerror=()=>{meta.textContent='网络错误';bar.style.width='0';resolve(false)};xhr.send(fd)})}
let searchMode=false,searchItems=[];
$("search").addEventListener("keydown",async e=>{if(e.key!=="Enter")return;const s=e.target.value.trim();if(!s){searchMode=false;searchItems=[];refresh();return}try{const d=await api("/chfs/search?str="+encodeURIComponent(s));renderSearch(d.files||[])}catch(x){fail(x.message)}})
function renderSearch(items){searchMode=true;searchItems=items;selected.clear();$("crumb").textContent="搜索结果";$("files").parentElement.hidden=false;$("grid").hidden=true;$("bulk").classList.remove("show");const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=f.path;return '<tr class="file-row" data-search-path="'+esc(p)+'" onclick="selectSearchItem('+JSON.stringify(p)+')" ondblclick="openSearchResult('+JSON.stringify(f)+')" oncontextmenu="searchMenu(event,'+JSON.stringify(f)+');return false"><td><input class="selectbox" type="checkbox" onclick="event.stopPropagation()" onchange="toggleSearchItem('+JSON.stringify(p)+',this.checked)"></td><td><div class="name"><span class="file-kind">'+iconFor(f)+'</span><span>'+esc(f.path)+'</span></div></td><td class="size-cell">'+(f.dir?"—":fmt(f.size))+'</td><td class="time-cell">'+esc(f.modified||"")+'</td><td class="perm-cell"><span class="meta">搜索结果</span></td><td><div class="actions"><button class="iconbtn" onclick="searchMenu(event,'+JSON.stringify(f)+')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button></div></td></tr>'}).join(""):'<tr><td colspan="6">'+emptyState("没有找到匹配文件","换一个关键词试试,或者清空搜索框返回当前位置。")+'</td></tr>'}