feat: add desktop-style multi-selection

This commit is contained in:
OpenClaw Agent
2026-09-22 03:03:31 +08:00
parent d0cff9dcf0
commit c29ba6eb40
+7 -4
View File
@@ -56,15 +56,18 @@ function showState(title,text,type="error"){const icon=type==="lock"?'<path d="M
async function refresh(){showLoading();try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);currentMask=data.currentMask||"";const writable=currentMask.includes("W");$("newFolderBtn").disabled=!writable;$("newTextBtn").disabled=!writable;$("uploadBtn").disabled=!writable;$("search").disabled=!data.currentMask?.includes("R");render(data.files||[]);loadSideRoots();renderBreadcrumb();$("notice").innerHTML=""}catch(e){$("newFolderBtn").disabled=true;$("newTextBtn").disabled=true;$("uploadBtn").disabled=true;$("search").disabled=true;const status=e.status||0;if(status===401){showState("需要登录","当前会话已失效,请重新登录后继续。","lock")}else if(status===403){showState("没有访问权限","当前账号无权访问这个位置,请返回其他可访问位置。","lock")}else if(status===404){showState("位置不存在","这个文件夹可能已经被移动、删除,或当前链接已经失效。")}else if(status===0){showState("连接失败","暂时无法连接到 CHFS,请检查网络后重试。","offline")}else{showState("无法加载","服务器暂时无法读取这个位置。","error")}$("notice").innerHTML=""}}
function fileType(f){const n=f.name.toLowerCase();if(f.dir)return "folder";if(/\.(png|jpe?g|gif|webp|bmp|svg)$/.test(n))return "image";if(/\.(mp4|webm|mov|mkv)$/.test(n))return "video";if(/\.(mp3|wav|flac|m4a|ogg)$/.test(n))return "audio";if(/\.(pdf)$/.test(n))return "pdf";return "file"}
function svgIcon(type){const p={folder:'<path d="M3 6.5h6l2 2H21v9.5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',image:'<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8" cy="9" r="1.4"/><path d="m4 17 5-5 4 4 2-2 5 5"/>',video:'<rect x="3" y="5" width="13" height="14" rx="2"/><path d="m16 10 5-3v10l-5-3z"/>',audio:'<path d="M9 18V6l9-2v12"/><circle cx="6" cy="18" r="3"/><circle cx="15" cy="16" r="3"/>',pdf:'<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v5h5M8 16h8M8 12h6"/>',archive:'<path d="M7 3h10v18H7z"/><path d="M10 3v3h4V3M10 8h4v3h-4v3h4v3h-4"/>',document:'<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v5h5M9 12h6M9 16h6"/>',code:'<path d="m9 7-5 5 5 5M15 7l5 5-5 5M13 4l-2 16"/>',spreadsheet:'<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M4 8h16M10 8v13M15 8v13M4 13h16M4 17h16"/>',presentation:'<rect x="3" y="4" width="18" height="14" rx="2"/><path d="M12 18v3M8 21h8M7 14l3-4 3 2 4-5"/>',file:'<path d="M6 3h8l4 4v14H6z"/><path d="M14 3v5h5"/>'};return '<svg class="ui-icon" viewBox="0 0 24 24" aria-hidden="true">'+(p[type]||p.file)+'</svg>'}function iconFor(f){const n=f.name.toLowerCase();const t=fileType(f);if(t!=="file")return svgIcon(t);if(/\.(zip|7z|rar|tar|gz|bz2|xz)$/.test(n))return svgIcon("archive");if(/\.(xls|xlsx|ods)$/.test(n))return svgIcon("spreadsheet");if(/\.(ppt|pptx|odp)$/.test(n))return svgIcon("presentation");if(/\.(doc|docx|odt|rtf)$/.test(n))return svgIcon("document");if(/\.(js|ts|jsx|tsx|css|scss|html|htm|go|rs|py|java|c|cpp|h|hpp|sh|sql|json|yaml|yml|xml)$/.test(n))return svgIcon("code");return svgIcon("file")}
function selectItem(p,checked){if(checked)selected.add(p);else selected.delete(p);updateSelection();renderList(lastItems)}
let selectionAnchor=null;
function itemPath(f){return current?current+"/"+f.name:f.name}
function selectablePaths(){return lastItems.map(itemPath)}
function selectItem(p,checked,e){if(checked){const paths=selectablePaths();if(e?.shiftKey&&selectionAnchor){const a=paths.indexOf(selectionAnchor),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()}
let selected=new Set(),lastItems=[],sortKey="name",sortDir=1;
function sortItems(items){return [...items].sort((a,b)=>{if(a.dir!==b.dir)return a.dir?-1:1;let av=sortKey==="size"?Number(a.size||0):sortKey==="modified"?String(a.modified||""):String(a.name||"").toLocaleLowerCase();let bv=sortKey==="size"?Number(b.size||0):sortKey==="modified"?String(b.modified||""):String(b.name||"").toLocaleLowerCase();return av<bv?-sortDir:av>bv?sortDir:0})}
function sortBy(key){if(sortKey===key)sortDir*=-1;else{sortKey=key;sortDir=1}render(lastItems)}
function renderSortState(){["name","size","modified"].forEach(k=>{const el=$("sort-"+k);if(el)el.textContent=sortKey===k?(sortDir===1?"↑":"↓"):""})}
function emptyState(title,text,action=""){const writable=currentMask.includes("W");let buttons="";if(action==="folder"&&writable)buttons='<div class="row"><button onclick="newFolder()">新建文件夹</button><button class="ghost" onclick="newText()">新建文本</button></div>';if(action==="upload"&&writable)buttons='<div class="row"><button onclick="$(\"upload\").click()">上传文件</button><button class="ghost" onclick="newFolder()">新建文件夹</button></div>';return '<div class="empty-state"><div class="empty-icon">'+svgIcon("folder")+'</div><h3>'+esc(title)+'</h3><p>'+esc(text)+'</p>'+buttons+'</div>'}
function render(items){lastItems=sortItems(items);selected=new Set([...selected].filter(p=>lastItems.some(f=>(current?current+"/":"")+f.name===p)));renderList(lastItems);renderGrid(lastItems);updateSelection();renderSortState()}
function renderList(items){const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),canWrite=(f.mask||"").includes("W"),canDelete=(f.mask||"").includes("D"),selectedNow=selected.has(p),drag=' draggable="true"';const perms='<span class="perm-dot '+(canRead?'read':'')+'"></span><span class="perm-dot '+(canWrite?'write':'')+'"></span><span class="perm-dot '+(canDelete?'delete':'')+'"></span>';return '<tr class="file-row '+(selectedNow?'selected':'')+'"'+drag+' data-file-path="'+esc(p)+'" onclick="selectItem('+JSON.stringify(p)+',true)" ondblclick="activateItem('+JSON.stringify(p)+')" ondragstart="fileDragStart(event,'+JSON.stringify(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+JSON.stringify(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+JSON.stringify(p)+')"><td><input class="selectbox" type="checkbox" '+(selectedNow?"checked":"")+' onclick="event.stopPropagation()" onchange="selectItem('+JSON.stringify(p)+',this.checked)"></td><td><div class="name"><span class="file-kind">'+iconFor(f)+'</span><span>'+esc(f.name)+'</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">'+perms+'</td><td><div class="actions"><button class="iconbtn" onclick="itemMenu(event,'+JSON.stringify(p)+')" 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("这个文件夹还是空的","可以上传文件,或者创建一个新的文件夹。","upload")+'</td></tr>'}
function renderGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),type=fileType(f),selectedNow=selected.has(p),drag=' draggable="true"';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':'');const meta=f.dir?'文件夹':fmt(f.size);return '<div class="tile '+tileClass+(selectedNow?' selected':'')+'"'+drag+' data-file-path="'+esc(p)+'" onclick="selectItem('+JSON.stringify(p)+',true)" ondblclick="activateItem('+JSON.stringify(p)+')" oncontextmenu="itemMenu(event,'+JSON.stringify(p)+');return false" ondragstart="fileDragStart(event,'+JSON.stringify(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+JSON.stringify(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+JSON.stringify(p)+')"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectItem('+JSON.stringify(p)+',this.checked)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,'+JSON.stringify(p)+')" 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(f.name)+'</div></div><div class="tile-meta"><span>'+meta+'</span>'+(f.modified?'<span>'+esc(f.modified)+'</span>':'')+'</div></div>'}).join(''):emptyState("这个文件夹还是空的","上传文件或创建内容后,它们会显示在这里。","upload")}
function renderList(items){const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),canWrite=(f.mask||"").includes("W"),canDelete=(f.mask||"").includes("D"),selectedNow=selected.has(p),drag=' draggable="true"';const perms='<span class="perm-dot '+(canRead?'read':'')+'"></span><span class="perm-dot '+(canWrite?'write':'')+'"></span><span class="perm-dot '+(canDelete?'delete':'')+'"></span>';return '<tr class="file-row '+(selectedNow?'selected':'')+'"'+drag+' data-file-path="'+esc(p)+'" onclick="selectItem('+JSON.stringify(p)+',true,event)" ondblclick="activateItem('+JSON.stringify(p)+')" ondragstart="fileDragStart(event,'+JSON.stringify(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+JSON.stringify(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+JSON.stringify(p)+')"><td><input class="selectbox" type="checkbox" '+(selectedNow?"checked":"")+' onclick="event.stopPropagation()" onchange="selectItem('+JSON.stringify(p)+',this.checked,event)"></td><td><div class="name"><span class="file-kind">'+iconFor(f)+'</span><span>'+esc(f.name)+'</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">'+perms+'</td><td><div class="actions"><button class="iconbtn" onclick="itemMenu(event,'+JSON.stringify(p)+')" 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("这个文件夹还是空的","可以上传文件,或者创建一个新的文件夹。","upload")+'</td></tr>'}
function renderGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),type=fileType(f),selectedNow=selected.has(p),drag=' draggable="true"';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':'');const meta=f.dir?'文件夹':fmt(f.size);return '<div class="tile '+tileClass+(selectedNow?' selected':'')+'"'+drag+' data-file-path="'+esc(p)+'" onclick="selectItem('+JSON.stringify(p)+',true,event)" ondblclick="activateItem('+JSON.stringify(p)+')" oncontextmenu="itemMenu(event,'+JSON.stringify(p)+');return false" ondragstart="fileDragStart(event,'+JSON.stringify(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+JSON.stringify(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+JSON.stringify(p)+')"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectItem('+JSON.stringify(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,'+JSON.stringify(p)+')" 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(f.name)+'</div></div><div class="tile-meta"><span>'+meta+'</span>'+(f.modified?'<span>'+esc(f.modified)+'</span>':'')+'</div></div>'}).join(''):emptyState("这个文件夹还是空的","上传文件或创建内容后,它们会显示在这里。","upload")}
function fileDragStart(e,p){const paths=selected.has(p)?[...selected]:[p];e.dataTransfer.effectAllowed="move";e.dataTransfer.setData("text/chfs-paths",JSON.stringify(paths));e.currentTarget.classList.add("dragging")}
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')}
@@ -120,7 +123,7 @@ function setupDropZone(){const z=$("dropZone"),main=$("fileCard");let depth=0;[d
function uploadOne(file,row,relativePath=file.name,task=null){return new Promise(resolve=>{const xhr=new XMLHttpRequest(),fd=new FormData();if(task)task.xhr=xhr;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'};if(task)task.xhr=null;resolve(ok)};xhr.onerror=()=>{if(task&&task.state==='cancelled'){meta.textContent=fmt(file.size)+' · 已取消'}else{meta.textContent='网络错误';bar.style.width='0'};if(task)task.xhr=null;resolve(false)};xhr.onabort=()=>{if(task)task.xhr=null;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;const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=f.path,name=f.name||p.split("/").pop(),parent=p.includes("/")?p.slice(0,p.lastIndexOf("/")):"当前位置";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(name)+'</span></div></td><td class="size-cell">'+(f.dir?"—":fmt(f.size))+'</td><td class="time-cell"><span>'+esc(f.modified||"")+'</span><div class="meta">'+esc(parent)+'</div></td><td class="perm-cell"><span class="meta">搜索结果</span></td><td><div class="actions"><button class="iconbtn" 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></td></tr>'}).join(""):'<tr><td colspan="6">'+emptyState("没有找到匹配文件","换一个关键词试试,或者清空搜索框返回当前位置。")+'</td></tr>';updateSelection()}
function renderSearch(items){searchMode=true;searchItems=items;selected.clear();$("crumb").textContent="搜索结果";$("files").parentElement.hidden=false;$("grid").hidden=true;const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=f.path,name=f.name||p.split("/").pop(),parent=p.includes("/")?p.slice(0,p.lastIndexOf("/")):"当前位置";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,event)"></td><td><div class="name"><span class="file-kind">'+iconFor(f)+'</span><span>'+esc(name)+'</span></div></td><td class="size-cell">'+(f.dir?"—":fmt(f.size))+'</td><td class="time-cell"><span>'+esc(f.modified||"")+'</span><div class="meta">'+esc(parent)+'</div></td><td class="perm-cell"><span class="meta">搜索结果</span></td><td><div class="actions"><button class="iconbtn" 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></td></tr>'}).join(""):'<tr><td colspan="6">'+emptyState("没有找到匹配文件","换一个关键词试试,或者清空搜索框返回当前位置。")+'</td></tr>';updateSelection()}
function selectSearchItem(p){selected.clear();if(p)selected.add(p);updateSelection()}
function toggleSearchItem(p,checked){if(checked)selected.add(p);else selected.delete(p);updateSelection()}
function searchMenu(e,f){e.preventDefault();selectSearchItem(f.path);let html='';html+='<button onclick="downloadFile('+JSON.stringify(f.path)+');closeContext()">下载</button>';if(f.dir)html+='<button onclick="downloadDir('+JSON.stringify(f.path)+');closeContext()">下载 ZIP</button>';html+='<button onclick="openSearchResult('+JSON.stringify(f)+');closeContext()">打开</button>';$("context").innerHTML=html;$("context").style.left=Math.min(e.clientX,innerWidth-190)+'px';$("context").style.top=Math.min(e.clientY,innerHeight-160)+'px';$("context").style.display='block'}