fix: serve media previews with browser-compatible content

This commit is contained in:
OpenClaw Agent
2026-09-22 05:33:34 +08:00
parent 6b86eb4185
commit e3fef60d60
2 changed files with 38 additions and 11 deletions
+3 -3
View File
@@ -71,7 +71,7 @@ function renderSortState(){["name","size","modified"].forEach(k=>{const el=$("so
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=can("read",f),canWrite=can("write",f),canDelete=can("delete",f),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="handleItemClick(event,'+attrJSON(p)+')" ondblclick="activateItem('+attrJSON(p)+')" ondragstart="fileDragStart(event,'+attrJSON(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+attrJSON(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+attrJSON(p)+')"><td><input class="selectbox" type="checkbox" '+(selectedNow?"checked":"")+' onclick="event.stopPropagation()" onchange="selectItem('+attrJSON(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="event.stopPropagation();itemMenu(event,'+attrJSON(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="handleItemClick(event,'+attrJSON(p)+')" ondblclick="activateItem('+attrJSON(p)+')" oncontextmenu="itemMenu(event,'+attrJSON(p)+');return false" ondragstart="fileDragStart(event,'+attrJSON(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+attrJSON(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+attrJSON(p)+')"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectItem('+attrJSON(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,'+attrJSON(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 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/thumbnail?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="handleItemClick(event,'+attrJSON(p)+')" ondblclick="activateItem('+attrJSON(p)+')" oncontextmenu="itemMenu(event,'+attrJSON(p)+');return false" ondragstart="fileDragStart(event,'+attrJSON(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+attrJSON(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+attrJSON(p)+')"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectItem('+attrJSON(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,'+attrJSON(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;const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!canTargetWrite(f))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')}
@@ -145,7 +145,7 @@ function searchParent(f){const p=searchPath(f);return p.includes("/")?p.slice(0,
function searchCan(f,mask){if(mask==="R")return can("read",f);if(mask==="W")return can("write",f);if(mask==="D")return can("delete",f);return false}
function renderSearch(items){searchMode=true;searchItems=sortItems(items);selected=new Set([...selected].filter(p=>searchItems.some(f=>searchPath(f)===p)));$("crumb").textContent="搜索结果";$("files").parentElement.hidden=localStorage.getItem("chfs-view")==="grid";$("grid").hidden=localStorage.getItem("chfs-view")!=="grid";renderSearchList(searchItems);renderSearchGrid(searchItems);updateSelection();renderSortState()}
function renderSearchList(items){const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),canWrite=searchCan(f,"W"),canDelete=searchCan(f,"D"),selectedNow=selected.has(p),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':'')+'" data-search-path="'+esc(p)+'" onclick="selectSearchItem('+attrJSON(p)+',true,event)" ondblclick="openSearchResult('+attrJSON(f)+')" oncontextmenu="searchMenu(event,'+attrJSON(f)+');return false"><td><input class="selectbox" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation()" onchange="selectSearchItem('+attrJSON(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(searchParent(f))+'</div></td><td class="perm-cell"><span class="perm-dot '+(canRead?'read':'')+'"></span><span class="perm-dot '+(canWrite?'write':'')+'"></span><span class="perm-dot '+(canDelete?'delete':'')+'"></span></td><td><div class="actions"><button class="iconbtn" onclick="event.stopPropagation();searchMenu(event,'+attrJSON(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>'}
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('+attrJSON(p)+',true,event)" ondblclick="openSearchResult('+attrJSON(f)+')" oncontextmenu="searchMenu(event,'+attrJSON(f)+');return false"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectSearchItem('+attrJSON(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();searchMenu(event,'+attrJSON(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 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/thumbnail?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('+attrJSON(p)+',true,event)" ondblclick="openSearchResult('+attrJSON(f)+')" oncontextmenu="searchMenu(event,'+attrJSON(f)+');return false"><input class="selectbox tile-check" type="checkbox" '+(selectedNow?'checked':'')+' onclick="event.stopPropagation();selectSearchItem('+attrJSON(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();searchMenu(event,'+attrJSON(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();e.stopPropagation();const p=searchPath(f);if(!selected.has(p)){selected.clear();selected.add(p)}updateSelection();let html='';if(!me?.username){if(can("read",f))html+='<button onclick="downloadFile('+attrJSON(p)+');closeContext()">下载</button><button onclick="shareItem('+attrJSON(p)+');closeContext()">分享</button>'}else{const canRead=can("read",f),canWrite=can("write",f),canDelete=can("delete",f);if(canRead)html+='<button onclick="downloadFile('+attrJSON(p)+');closeContext()">下载</button>';if(f.dir&&canRead)html+='<button onclick="downloadDir('+attrJSON(p)+');closeContext()">下载 ZIP</button>';if(canWrite)html+='<button onclick="renameItem('+attrJSON(p)+');closeContext()">重命名</button><button onclick="transferItem('+attrJSON(p)+',false);closeContext()">移动到…</button><button onclick="transferItem('+attrJSON(p)+',true);closeContext()">复制到…</button>';if(canDelete)html+='<button onclick="removeItem('+attrJSON(p)+');closeContext()">删除</button>';if(f.guestMask?.includes("R"))html+='<button onclick="shareItem('+attrJSON(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"}
@@ -156,7 +156,7 @@ function previewFilesFor(p){const dir=dirOf(p),items=dir===current?lastItems:sea
function galleryOpen(p){return openGallery(p)}
function dirOf(p){return String(p||'').split('/').slice(0,-1).join('/')}
function galleryMove(delta){if(!galleryFiles.length)return;galleryIndex=(galleryIndex+delta+galleryFiles.length)%galleryFiles.length;const f=galleryFiles[galleryIndex],path=f.path||(current?current+'/'+f.name:f.name);renderGallery(path)}
function preloadGallery(){for(const offset of [-1,1]){const f=galleryFiles[(galleryIndex+offset+galleryFiles.length)%galleryFiles.length];if(f){const img=new Image();img.src="/chfs/preview?file="+encodeURIComponent(f.path)}}}
function preloadGallery(){for(const offset of [-1,1]){const f=galleryFiles[(galleryIndex+offset+galleryFiles.length)%galleryFiles.length];if(f){const img=new Image();img.src="/chfs/thumbnail?file="+encodeURIComponent(f.path)}}}
function renderGallery(path){const f=galleryFiles[galleryIndex];if(!f)return;const p=path||((f.path)||((current?current+"/":"")+f.name)),n=f.name,src="/chfs/preview?file="+encodeURIComponent(p);preloadGallery();show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span><span class="meta">'+(galleryIndex+1)+' / '+galleryFiles.length+'</span><button class="iconbtn" onclick="location.href='+attrJSON(sharedURL(p))+'" title="下载">'+svgIcon("file")+'</button><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 class="preview-stage"><img src="'+src+'" alt="'+esc(n)+'"><button class="gallery-nav gallery-prev" onclick="galleryMove(-1)" title="上一张"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m14 6-6 6 6 6"/></svg></button><button class="gallery-nav gallery-next" onclick="galleryMove(1)" title="下一张"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m10 6 6 6-6 6"/></svg></button><div class="gallery-count">'+(galleryIndex+1)+' / '+galleryFiles.length+'</div></div><div class="preview-foot"><button class="ghost" onclick="closeModal()">关闭</button></div>',true)}
function mediaKeydown(e){const mediaActive=!!activeMedia&&($('modal').classList.contains('show')||$('miniPlayer'));if(mediaActive){if(e.key===' '){e.preventDefault();activeMedia.paused?activeMedia.play():activeMedia.pause()}else if(e.key==='ArrowLeft'||e.key==='ArrowRight'){e.preventDefault();activeMedia.currentTime=Math.max(0,activeMedia.currentTime+(e.key==='ArrowLeft'?-5:5))}else if((e.key==='['||e.key===']')&&activeMedia.playbackRate){e.preventDefault();activeMedia.playbackRate=Math.max(.25,Math.min(2,activeMedia.playbackRate+(e.key==='['?-.25:.25)));toast("播放速度 "+activeMedia.playbackRate+"x")}}if($('modal').classList.contains('show')&&galleryFiles.length&&(e.key==='ArrowLeft'||e.key==='ArrowRight')){e.preventDefault();galleryMove(e.key==='ArrowLeft'?-1:1)}}
function setPlaybackRate(v){if(activeMedia){activeMedia.playbackRate=Number(v);toast("播放速度 "+v+"x")}}