fix: restore file manager interactions

This commit is contained in:
OpenClaw Agent
2026-09-22 05:07:31 +08:00
parent d8c938a0c8
commit 2690888a21
+5 -5
View File
@@ -73,7 +73,7 @@ function fileDragEnd(e){e.currentTarget.classList.remove("dragging");document.qu
function folderDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!(f.mask||'').includes('W'))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,'');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 handleItemClick(e,p){if(!me.username){guestItemActions(p);return}if(window.matchMedia&&window.matchMedia('(max-width:700px)').matches){activateItem(p);return}selectItem(p,true,e)}
function handleItemClick(e,p){e.preventDefault();if(!me.username){guestItemActions(p);return}activateItem(p)}
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 sharedURL(p){return new URL("/chfs/shared/"+p.split("/").map(encodeURIComponent).join("/"),location.href).href}
function guestItemActions(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f||!(f.mask||"").includes("R")){toast("当前项目没有访问权限",true);return}if(f.dir){openDir(p);return}const url=sharedURL(p);let html='<h2>'+esc(f.name)+'</h2><div class="notice">访客可以直接下载或复制分享链接。</div><div class="row" style="justify-content:flex-start"><button onclick="location.href='+JSON.stringify(sharedURL(p))+'">下载</button><button class="ghost" onclick="guestCopyShare('+JSON.stringify(url)+')">复制分享链接</button></div>';show(html)}
@@ -84,9 +84,9 @@ function toggleAll(v){const source=searchMode?searchItems:lastItems;source.forEa
function clearSelection(){selected.clear();updateSelection();if(searchMode){renderSearch(searchItems);return}renderGrid(lastItems);renderList(lastItems)}
function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";const source=searchMode?searchItems:lastItems;const header=$("files").closest("table")?.querySelector("thead .selectbox");if(header){const total=source.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=source[i];if(f){const p=searchMode?f.path:(current?current+"/":"")+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 itemMenu(e,p){e.preventDefault();e.stopPropagation();if(!selected.has(p)){selected.clear();selected.add(p);updateSelection()}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()});
addEventListener('click',e=>{if(!e.target.closest('#context')&&!e.target.closest('.tile-action')&&!e.target.closest('.actions'))closeContext()});
function bulkDownload(){if(!selected.size)return;const q=[...selected].map(p=>'path='+encodeURIComponent(p)).join('&');location.href='/chfs/downloadzip?'+q}
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)}})}
@@ -122,7 +122,7 @@ async function newFolder(){const n=await ask("新建文件夹","文件夹名称"
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)}}
$("uploadBtn").addEventListener("click",e=>{e.preventDefault();if($("uploadBtn").disabled){toast("当前位置没有上传权限",true);return}$("upload").click()});$("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){const tasks=entries.map((item,index)=>({id:"upload-"+Date.now()+"-"+index,file:item.file,relativePath:item.relativePath,state:"queued",xhr:null,row:null}));window._uploadTasks=tasks;show('<h2>上传文件</h2><div class="notice">共 '+tasks.length+' 个文件,最多同时上传 3 个。关闭窗口不会中断上传。</div><div id="uploadSummary" class="upload-summary"><span class="count">准备上传</span><span class="meta"></span></div><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>');const box=$("uploadList");tasks.forEach(task=>{const row=document.createElement("div");row.className="upload-row";row.innerHTML='<div class="upload-head"><b class="upload-name" title="'+esc(task.relativePath)+'">'+esc(task.relativePath)+'</b><div class="upload-actions"><button class="ghost" data-upload-action="cancel">取消</button><button class="ghost" data-upload-action="retry" hidden>重试</button></div></div><span class="meta">'+fmt(task.file.size)+' · 排队中</span><div class="progress"><i></i></div>';row.querySelector('[data-upload-action="cancel"]').onclick=()=>cancelUploadTask(task);row.querySelector('[data-upload-action="retry"]').onclick=()=>retryUploadTask(task);task.row=row;box.appendChild(row)});await runUploadQueue(tasks,3);await refresh();const failed=tasks.filter(t=>t.state==="failed").length,cancelled=tasks.filter(t=>t.state==="cancelled").length,done=tasks.filter(t=>t.state==="done").length;if(failed||cancelled)toast("上传完成:成功 "+done+",失败 "+failed+",取消 "+cancelled,failed>0);else toast("上传完成,共 "+done+" 个文件")}
async function uploadEntries(entries){const tasks=entries.map((item,index)=>({id:"upload-"+Date.now()+"-"+index,file:item.file,relativePath:item.relativePath,state:"queued",xhr:null,row:null}));window._uploadTasks=tasks;show('<div class="preview-head"><span class="preview-title">上传文件</span><button class="iconbtn" onclick="closeModal()" title="关闭"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><div style="padding:0 22px 22px"><div class="notice">共 '+tasks.length+' 个文件,最多同时上传 3 个。关闭窗口不会中断上传。</div><div id="uploadSummary" class="upload-summary"><span class="count">准备上传</span><span class="meta"></span></div><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div></div>');const box=$("uploadList");tasks.forEach(task=>{const row=document.createElement("div");row.className="upload-row";row.innerHTML='<div class="upload-head"><b class="upload-name" title="'+esc(task.relativePath)+'">'+esc(task.relativePath)+'</b><div class="upload-actions"><button class="ghost" data-upload-action="cancel">取消</button><button class="ghost" data-upload-action="retry" hidden>重试</button></div></div><span class="meta">'+fmt(task.file.size)+' · 排队中</span><div class="progress"><i></i></div>';row.querySelector('[data-upload-action="cancel"]').onclick=()=>cancelUploadTask(task);row.querySelector('[data-upload-action="retry"]').onclick=()=>retryUploadTask(task);task.row=row;box.appendChild(row)});await runUploadQueue(tasks,3);await refresh();const failed=tasks.filter(t=>t.state==="failed").length,cancelled=tasks.filter(t=>t.state==="cancelled").length,done=tasks.filter(t=>t.state==="done").length;if(failed||cancelled)toast("上传完成:成功 "+done+",失败 "+failed+",取消 "+cancelled,failed>0);else toast("上传完成,共 "+done+" 个文件")}
async function runUploadQueue(tasks,limit){let cursor=0;async function worker(){while(cursor<tasks.length){const task=tasks[cursor++];if(task.state!=="queued")continue;task.state="uploading";setUploadTaskState(task,"uploading");const ok=await uploadOne(task.file,task.row,task.relativePath,task);if(ok){task.state="done";setUploadTaskState(task,"done")}else if(task.state!=="cancelled"){task.state="failed";setUploadTaskState(task,"failed")};updateUploadSummary(tasks)}}await Promise.all(Array.from({length:Math.min(limit,tasks.length)},()=>worker()));updateUploadSummary(tasks)}
function setUploadTaskState(task,state){if(!task.row)return;const meta=task.row.querySelector('.meta'),cancel=task.row.querySelector('[data-upload-action="cancel"]'),retry=task.row.querySelector('[data-upload-action="retry"]');if(state==="uploading"){meta.textContent=fmt(task.file.size)+' · 上传中';cancel.hidden=false;retry.hidden=true}else if(state==="done"){meta.textContent=fmt(task.file.size)+' · 完成';cancel.hidden=true;retry.hidden=true}else if(state==="failed"){retry.hidden=false;cancel.hidden=true}else if(state==="cancelled"){meta.textContent=fmt(task.file.size)+' · 已取消';cancel.hidden=true;retry.hidden=false}else{meta.textContent=fmt(task.file.size)+' · 排队中';cancel.hidden=false;retry.hidden=true}}
function updateUploadSummary(tasks){const el=$("uploadSummary");if(!el)return;const done=tasks.filter(t=>t.state==="done").length,failed=tasks.filter(t=>t.state==="failed").length,cancelled=tasks.filter(t=>t.state==="cancelled").length,active=tasks.filter(t=>t.state==="uploading").length,queued=tasks.filter(t=>t.state==="queued").length;el.querySelector('.count').textContent=done+failed+cancelled+' / '+tasks.length+' 个完成';el.querySelector('.meta').textContent=active?'正在上传 '+active+' 个'+(queued?',排队 '+queued+' 个':''):failed?'有 '+failed+' 个失败':cancelled?'有 '+cancelled+' 个已取消':'全部完成'}
@@ -144,7 +144,7 @@ function renderSearchList(items){const body=$("files");body.innerHTML=items.leng
function renderSearchGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),type=fileType(f),selectedNow=selected.has(p);let thumb=iconFor(f);if(type==="image"&&canRead)thumb='<img src="/chfs/preview?file='+encodeURIComponent(p)+'" loading="lazy" alt="">';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');return '<div class="tile '+tileClass+(selectedNow?' selected':'')+'" data-file-path="'+esc(p)+'" onclick="selectSearchItem('+JSON.stringify(p)+',true,event)" ondblclick="openSearchResult('+JSON.stringify(f)+')" oncontextmenu="searchMenu(event,'+JSON.stringify(f)+');return false"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectSearchItem('+JSON.stringify(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();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 class="thumb">'+thumb+'</div><div class="tile-title">'+iconFor(f)+'<div class="file-name">'+esc(name)+'</div></div><div class="tile-meta"><span>'+esc(searchParent(f)||"全部文件")+'</span>'+(f.modified?'<span>'+esc(f.modified)+'</span>':'')+'</div></div>'}).join(""):'<div class="empty-state search-empty"><div class="empty-icon">'+svgIcon("file")+'</div><h3>没有找到匹配文件</h3><p>换一个关键词试试,或者清空搜索框返回当前位置。</p></div>'}
function selectSearchItem(p,checked,e){if(checked){if(e?.shiftKey){const paths=searchItems.map(searchPath),anchor=selectionAnchor&&paths.includes(selectionAnchor)?selectionAnchor:[...selected][0],a=paths.indexOf(anchor),b=paths.indexOf(p);if(a>=0&&b>=0){const lo=Math.min(a,b),hi=Math.max(a,b);if(!(e.ctrlKey||e.metaKey))selected.clear();for(let i=lo;i<=hi;i++)selected.add(paths[i])}else selected.add(p)}else if(e?.ctrlKey||e?.metaKey)selected.add(p);else{selected.clear();selected.add(p)}selectionAnchor=p}else{selected.delete(p);selectionAnchor=p}updateSelection();renderSearch(searchItems)}
function toggleSearchItem(p,checked,e){selectSearchItem(p,checked,e)}
function searchMenu(e,f){e.preventDefault();const p=searchPath(f);if(!selected.has(p)){selected.clear();selected.add(p)}updateSelection();let html='';const canRead=searchCan(f,"R"),canWrite=searchCan(f,"W"),canDelete=searchCan(f,"D");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 searchMenu(e,f){e.preventDefault();e.stopPropagation();const p=searchPath(f);if(!selected.has(p)){selected.clear();selected.add(p)}updateSelection();let html='';const canRead=searchCan(f,"R"),canWrite=searchCan(f,"W"),canDelete=searchCan(f,"D");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 openSearchResult(f){if(f.dir){current=f.path;navigationRoot=me.home&&f.path.startsWith(me.home)?me.home:f.path.split("/")[0]||"";searchMode=false;$("search").value="";selected.clear();refresh()}else previewFile(f.path)}
let galleryFiles=[],galleryIndex=0,activeMedia=null;
async function openGallery(p){const dir=dirOf(p);let items=dir===current?lastItems:null;if(!items){try{const d=await api("/chfs/files?filepath="+encodeURIComponent(dir));items=d.files||[]}catch(e){return fail("无法加载图片目录:"+e.message)}}galleryFiles=items.filter(f=>!f.dir&&fileType(f)==="image"&&(f.mask||"").includes("R")).map(f=>({...f,path:f.path||(dir?(dir+"/"+f.name):f.name)}));galleryIndex=Math.max(0,galleryFiles.findIndex(f=>f.path===p));renderGallery()}