feat: add file list sorting

This commit is contained in:
OpenClaw Agent
2026-09-22 02:45:19 +08:00
parent bd45018612
commit 75c51d7d94
+7 -4
View File
@@ -10,7 +10,7 @@
button,input{font:inherit}button{border:0;border-radius:9px;padding:9px 13px;cursor:pointer;background:#2563eb;color:#fff}button.ghost{background:#eef2f7;color:#334155}button.danger{background:#dc2626}
main{max-width:1180px;margin:26px auto;padding:0 18px}.toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:14px}.toolgroup{display:flex;align-items:center;gap:6px}.toolbar .searchbox{min-width:220px;flex:1;display:flex;align-items:center;gap:8px;padding:0 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface);transition:border-color .15s,box-shadow .15s}.toolbar .searchbox:focus-within{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 12%,transparent)}.toolbar .searchbox .ui-icon{width:16px;height:16px;color:var(--muted)}.toolbar .searchbox input{min-width:0;flex:1;padding:10px 0;border:0;outline:0;background:transparent;color:var(--text)}.toolbar .divider{width:1px;height:26px;background:var(--border);margin:0 2px}.toolbar .primary-action{box-shadow:0 2px 6px rgba(37,99,235,.18)}.toolbar .iconbtn{flex:0 0 auto}
.card{background:#fff;border:1px solid #e4e8f0;border-radius:14px;overflow:hidden;box-shadow:0 4px 18px rgba(20,30,50,.04)}
table{width:100%;border-collapse:separate;border-spacing:0}th,td{text-align:left;padding:12px 15px;border-bottom:1px solid #edf0f5}th{font-size:12px;color:#7b8798;background:#fafbfc;font-weight:650}tr:last-child td{border-bottom:0}.name{display:flex;align-items:center;gap:10px;min-width:0}.name a{color:#172033;text-decoration:none;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.meta{color:#8792a5;font-size:13px}.actions{display:flex;gap:7px;justify-content:flex-end;opacity:.55;transition:opacity .15s}.file-row:hover .actions,.file-row.selected .actions{opacity:1}.file-row{transition:background .12s}.file-row:hover{background:var(--surface2)}.file-row.selected{background:color-mix(in srgb,var(--primary) 7%,var(--surface))}.file-row:focus-within{box-shadow:inset 2px 0 0 var(--primary)}.tile:focus-visible,.file-row:focus-within{outline:2px solid color-mix(in srgb,var(--primary) 65%,transparent);outline-offset:-2px}.file-row .file-kind{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;background:var(--surface2);color:var(--muted);flex:0 0 auto}.file-row .file-kind .ui-icon{width:18px;height:18px}.file-row .size-cell,.file-row .time-cell{white-space:nowrap;color:var(--muted);font-size:12px}.file-row .perm-cell{white-space:nowrap}.perm-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--border);margin-right:5px}.perm-dot.read{background:var(--muted)}.perm-dot.write{background:var(--primary)}.perm-dot.delete{background:var(--danger)}.empty{padding:45px;text-align:center;color:#8a94a6}
table{width:100%;border-collapse:separate;border-spacing:0}th,td{text-align:left;padding:12px 15px;border-bottom:1px solid #edf0f5}th{font-size:12px;color:#7b8798;background:#fafbfc;font-weight:650}tr:last-child td{border-bottom:0}.name{display:flex;align-items:center;gap:10px;min-width:0}.name a{color:#172033;text-decoration:none;font-weight:550;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sorthead{display:inline-flex;align-items:center;gap:5px;padding:3px 5px;margin:-3px -5px;background:transparent;color:inherit;font-size:inherit;font-weight:inherit}.sorthead:hover{background:var(--surface);color:var(--text)}.sorthead span{font-size:10px;color:var(--primary);min-width:8px}.meta{color:#8792a5;font-size:13px}.actions{display:flex;gap:7px;justify-content:flex-end;opacity:.55;transition:opacity .15s}.file-row:hover .actions,.file-row.selected .actions{opacity:1}.file-row{transition:background .12s}.file-row:hover{background:var(--surface2)}.file-row.selected{background:color-mix(in srgb,var(--primary) 7%,var(--surface))}.file-row:focus-within{box-shadow:inset 2px 0 0 var(--primary)}.tile:focus-visible,.file-row:focus-within{outline:2px solid color-mix(in srgb,var(--primary) 65%,transparent);outline-offset:-2px}.file-row .file-kind{width:30px;height:30px;display:inline-flex;align-items:center;justify-content:center;border-radius:8px;background:var(--surface2);color:var(--muted);flex:0 0 auto}.file-row .file-kind .ui-icon{width:18px;height:18px}.file-row .size-cell,.file-row .time-cell{white-space:nowrap;color:var(--muted);font-size:12px}.file-row .perm-cell{white-space:nowrap}.perm-dot{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--border);margin-right:5px}.perm-dot.read{background:var(--muted)}.perm-dot.write{background:var(--primary)}.perm-dot.delete{background:var(--danger)}.empty{padding:45px;text-align:center;color:#8a94a6}
.modal{position:fixed;inset:0;background:rgba(15,23,42,.5);display:none;align-items:center;justify-content:center;padding:18px;z-index:4;backdrop-filter:blur(4px)}.modal.show{display:flex}.dialog{width:min(480px,100%);max-height:calc(100vh - 36px);overflow:auto;background:var(--surface);border:1px solid var(--border);border-radius:18px;padding:22px;box-shadow:0 24px 80px rgba(0,0,0,.18)}.dialog.preview-dialog{width:min(1000px,100%);padding:0;overflow:hidden}.dialog h2{margin-top:0}.field{display:grid;gap:7px;margin:13px 0}.field input{padding:10px 11px;border:1px solid #d7ddea;border-radius:9px}.row{display:flex;gap:9px;justify-content:flex-end;margin-top:18px}.preview-head{display:flex;align-items:center;gap:12px;padding:14px 16px;border-bottom:1px solid var(--border)}.preview-title{font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-right:auto}.preview-stage{min-height:180px;max-height:calc(100vh - 150px);overflow:auto;background:#0b0f14;padding:24px;display:flex;align-items:center;justify-content:center}.preview-stage img{max-width:100%;max-height:70vh;object-fit:contain}.preview-stage video{width:min(920px,100%);max-height:70vh;border-radius:10px}.preview-stage audio{width:min(720px,100%)}.preview-stage iframe{width:100%;height:70vh;border:0;background:#fff}.preview-text{width:min(920px,100%);max-height:70vh;overflow:auto;text-align:left;white-space:pre-wrap;background:#111821;color:#dce5f2;padding:18px;border-radius:10px;font:13px/1.6 ui-monospace,SFMono-Regular,monospace}.preview-empty{color:#aab5c5;text-align:center}.preview-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:8px}
.login{max-width:390px;margin:10vh auto}.notice{padding:12px 14px;background:#eff6ff;border-radius:10px;color:#315b9b;margin-bottom:14px}
@media(max-width:700px){.size-col,.time-col,.perm-col,.size-cell,.time-cell,.perm-cell{display:none}.top{padding:0 13px}.path{max-width:40vw}.actions button{padding:7px 9px}.toolbar{gap:6px;margin-bottom:10px}.toolbar .toolgroup{flex-wrap:wrap}.toolbar .divider{display:none}.toolbar .searchbox{order:3;flex-basis:100%;min-width:0}.toolbar .toolgroup:nth-of-type(2){flex:1}.toolbar .toolgroup:last-child{margin-left:auto}.toolbar button{padding:8px 10px}.file-row td{padding:10px 9px}.bulk{position:sticky;bottom:10px;z-index:3;padding:8px 10px;border:1px solid var(--border);border-radius:11px;background:color-mix(in srgb,var(--surface) 94%,transparent);box-shadow:var(--shadow);backdrop-filter:blur(10px);flex-wrap:wrap}.bulk .count{flex-basis:100%;margin-right:0}}
@@ -41,7 +41,7 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
<div class="searchbox"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="11" cy="11" r="6"/><path d="m16 16 5 5"/></svg><input id="search" placeholder="搜索文件名,回车执行"></div>
<div class="toolgroup"><button id="usersBtn" class="ghost" onclick="showUsers()" hidden>用户管理</button><button class="ghost" id="pwdBtn" onclick="changePassword()" hidden>修改密码</button><button class="iconbtn" id="themeBtn" onclick="toggleTheme()" title="主题"><span id="themeIcon"></span></button><button class="iconbtn viewbtn active" id="listBtn" onclick="setView('list')" title="列表"><span id="listIcon"></span></button><button class="iconbtn viewbtn" id="gridBtn" onclick="setView('grid')" title="网格"><span id="gridIcon"></span></button></div>
</div>
<div id="bulk" class="bulk"><span id="selectedCount" class="count"></span><button class="ghost" onclick="bulkMove()">移动</button><button class="ghost" onclick="bulkCopy()">复制</button><button class="ghost" onclick="bulkDownload()">下载 ZIP</button><button class="danger" onclick="bulkDelete()">删除</button><button class="ghost" onclick="clearSelection()">取消选择</button></div><div class="card" id="fileCard"><table><thead><tr><th class="check-col"><input class="selectbox" type="checkbox" onchange="toggleAll(this.checked)"></th><th>名称</th><th class="size-col">大小</th><th class="time-col">修改时间</th><th class="perm-col">权限</th><th class="action-col"></th></tr></thead><tbody id="files"></tbody></table><div id="grid" class="grid" hidden></div></div></section></div>
<div id="bulk" class="bulk"><span id="selectedCount" class="count"></span><button class="ghost" onclick="bulkMove()">移动</button><button class="ghost" onclick="bulkCopy()">复制</button><button class="ghost" onclick="bulkDownload()">下载 ZIP</button><button class="danger" onclick="bulkDelete()">删除</button><button class="ghost" onclick="clearSelection()">取消选择</button></div><div class="card" id="fileCard"><table><thead><tr><th class="check-col"><input class="selectbox" type="checkbox" onchange="toggleAll(this.checked)" title="全选"></th><th><button class="sorthead" onclick="sortBy('name')" title="按名称排序">名称 <span id="sort-name"></span></button></th><th class="size-col"><button class="sorthead" onclick="sortBy('size')" title="按大小排序">大小 <span id="sort-size"></span></button></th><th class="time-col"><button class="sorthead" onclick="sortBy('modified')" title="按修改时间排序">修改时间 <span id="sort-modified"></span></button></th><th class="perm-col">权限</th><th class="action-col"></th></tr></thead><tbody id="files"></tbody></table><div id="grid" class="grid" hidden></div></div></section></div>
</main><div id="context" class="context"></div><div id="toastHost" class="toast-host"></div>
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
<script>
@@ -55,8 +55,11 @@ async function refresh(){try{const q=encodeURIComponent(current);const data=awai
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 selected=new Set(),lastItems=[];
function render(items){lastItems=items;selected=new Set([...selected].filter(p=>items.some(f=>(current?current+"/":"")+f.name===p)));renderList(items);renderGrid(items);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 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.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);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':'')+'" onclick="selectItem('+JSON.stringify(p)+',true)" ondblclick="activateItem('+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" class="empty">这里还没有文件</td></tr>'}
function renderGrid(items){$("grid").innerHTML=items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("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':'');const meta=f.dir?'文件夹':fmt(f.size);return '<div class="tile '+tileClass+(selectedNow?' selected':'')+'" onclick="selectItem('+JSON.stringify(p)+',true)" ondblclick="activateItem('+JSON.stringify(p)+')" oncontextmenu="itemMenu(event,'+JSON.stringify(p)+');return false"><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('')||'<div class="empty">这里还没有文件</div>'}
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)}