feat: add persistent location navigation

This commit is contained in:
OpenClaw Agent
2026-09-22 02:16:50 +08:00
parent 942e8135f3
commit a5e2c297ad
+5 -3
View File
@@ -21,7 +21,7 @@ body{background:var(--bg);color:var(--text);transition:background .2s,color .2s}
button.ghost{background:var(--surface2);color:var(--text)}button.danger{background:var(--danger)}.meta,.path{color:var(--muted)} button.ghost{background:var(--surface2);color:var(--text)}button.danger{background:var(--danger)}.meta,.path{color:var(--muted)}
.card{box-shadow:var(--shadow)}th{background:var(--surface2);color:var(--muted)}th,td{border-color:var(--border)}.field input,.toolbar input,.field select{background:var(--surface);color:var(--text);border-color:var(--border)} .card{box-shadow:var(--shadow)}th{background:var(--surface2);color:var(--muted)}th,td{border-color:var(--border)}.field input,.toolbar input,.field select{background:var(--surface);color:var(--text);border-color:var(--border)}
.iconbtn{width:38px;height:38px;padding:0;background:var(--surface2);color:var(--text)}.viewbtn.active{background:var(--primary);color:#fff}.selectbox{width:18px;height:18px;accent-color:var(--primary)} .iconbtn{width:38px;height:38px;padding:0;background:var(--surface2);color:var(--text)}.viewbtn.active{background:var(--primary);color:#fff}.selectbox{width:18px;height:18px;accent-color:var(--primary)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;padding:14px}.tile{position:relative;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--surface);min-height:145px;cursor:pointer}.tile:hover{border-color:var(--primary)}.tile.selected{outline:2px solid var(--primary)}.tile .thumb{height:82px;display:flex;align-items:center;justify-content:center;font-size:42px;border-radius:9px;background:var(--surface2);overflow:hidden;margin-bottom:9px}.tile .thumb img{width:100%;height:100%;object-fit:cover}.progress{height:8px;background:var(--surface2);border-radius:99px;overflow:hidden}.progress i{display:block;height:100%;width:0;background:var(--primary);transition:width .15s}.upload-row{display:grid;gap:7px;padding:10px 0;border-bottom:1px solid var(--border)}.drop-active{border-color:var(--primary)!important;background:var(--surface2)!important}.qr{width:min(320px,80vw);aspect-ratio:1;margin:auto;display:block;border-radius:12px;background:#fff}.drop-hint{color:var(--muted);font-size:13px;margin-top:7px}.tile .tilecheck{position:absolute;top:9px;left:9px}.tile .tilemenu{position:absolute;right:7px;top:7px}.file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bulk{display:none;align-items:center;gap:8px;margin-bottom:12px}.bulk.show{display:flex}.bulk .count{font-weight:650;margin-right:auto}.context{position:fixed;z-index:10;display:none;min-width:170px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:10px;padding:5px}.context button{display:block;width:100%;text-align:left;background:transparent;color:var(--text);border-radius:7px}.context button:hover{background:var(--surface2)} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:12px;padding:14px}.tile{position:relative;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--surface);min-height:145px;cursor:pointer}.tile:hover{border-color:var(--primary)}.tile.selected{outline:2px solid var(--primary)}.tile .thumb{height:82px;display:flex;align-items:center;justify-content:center;font-size:42px;border-radius:9px;background:var(--surface2);overflow:hidden;margin-bottom:9px}.tile .thumb img{width:100%;height:100%;object-fit:cover}.progress{height:8px;background:var(--surface2);border-radius:99px;overflow:hidden}.progress i{display:block;height:100%;width:0;background:var(--primary);transition:width .15s}.upload-row{display:grid;gap:7px;padding:10px 0;border-bottom:1px solid var(--border)}.drop-active{border-color:var(--primary)!important;background:var(--surface2)!important}.qr{width:min(320px,80vw);aspect-ratio:1;margin:auto;display:block;border-radius:12px;background:#fff}.drop-hint{color:var(--muted);font-size:13px;margin-top:7px}.tile .tilecheck{position:absolute;top:9px;left:9px}.tile .tilemenu{position:absolute;right:7px;top:7px}.file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.bulk{display:none;align-items:center;gap:8px;margin-bottom:12px}.bulk.show{display:flex}.bulk .count{font-weight:650;margin-right:auto}.layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:16px}.sidebar{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:12px;height:max-content;position:sticky;top:82px}.side-title{font-size:12px;font-weight:700;color:var(--muted);padding:6px 9px}.side-item{width:100%;text-align:left;background:transparent;color:var(--text);margin:2px 0}.side-item.active{background:var(--surface2);color:var(--primary)}@media(max-width:900px){.layout{display:block}.sidebar{position:static;margin-bottom:14px;display:flex;gap:6px;overflow:auto}.side-title{display:none}.side-item{white-space:nowrap;width:auto}}.context{position:fixed;z-index:10;display:none;min-width:170px;background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);border-radius:10px;padding:5px}.context button{display:block;width:100%;text-align:left;background:transparent;color:var(--text);border-radius:7px}.context button:hover{background:var(--surface2)}
</style> </style>
</head> </head>
<body> <body>
@@ -31,6 +31,7 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
<button class="ghost" id="logoutBtn" onclick="logout()" hidden>退出</button> <button class="ghost" id="logoutBtn" onclick="logout()" hidden>退出</button>
</header> </header>
<main> <main>
<div class="layout"><aside class="sidebar"><div class="side-title">位置</div><div id="sideRoots"><div class="meta" style="padding:8px">加载中…</div></div></aside><section>
<div id="dropZone" class="card" style="display:none;padding:24px;text-align:center;margin-bottom:14px;border:2px dashed var(--border)">将文件或文件夹拖到这里上传</div> <div id="dropZone" class="card" style="display:none;padding:24px;text-align:center;margin-bottom:14px;border:2px dashed var(--border)">将文件或文件夹拖到这里上传</div>
<div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div> <div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div>
<div class="toolbar"> <div class="toolbar">
@@ -40,7 +41,7 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
<input id="search" placeholder="搜索文件名,回车执行"> <input id="search" placeholder="搜索文件名,回车执行">
<button id="usersBtn" onclick="showUsers()" hidden>用户管理</button><button class="ghost" id="pwdBtn" onclick="changePassword()" hidden>修改密码</button><button class="iconbtn" id="themeBtn" onclick="toggleTheme()" title="主题">◐</button><button class="iconbtn viewbtn active" id="listBtn" onclick="setView("list")" title="列表">☷</button><button class="iconbtn viewbtn" id="gridBtn" onclick="setView("grid")" title="网格">▦</button> <button id="usersBtn" onclick="showUsers()" hidden>用户管理</button><button class="ghost" id="pwdBtn" onclick="changePassword()" hidden>修改密码</button><button class="iconbtn" id="themeBtn" onclick="toggleTheme()" title="主题">◐</button><button class="iconbtn viewbtn active" id="listBtn" onclick="setView("list")" title="列表">☷</button><button class="iconbtn viewbtn" id="gridBtn" onclick="setView("grid")" title="网格">▦</button>
</div> </div>
<div id="bulk" class="bulk"><span id="selectedCount" class="count"></span><button class="danger" onclick="bulkDelete()">删除</button><button class="ghost" onclick="clearSelection()">取消选择</button></div><div class="card" id="fileCard"><table><thead><tr><th><input class="selectbox" type="checkbox" onchange="toggleAll(this.checked)"></th><th>名称</th><th>大小</th><th>修改时间</th><th></th></tr></thead><tbody id="files"></tbody></table><div id="grid" class="grid" hidden></div></div> <div id="bulk" class="bulk"><span id="selectedCount" class="count"></span><button class="danger" onclick="bulkDelete()">删除</button><button class="ghost" onclick="clearSelection()">取消选择</button></div><div class="card" id="fileCard"><table><thead><tr><th><input class="selectbox" type="checkbox" onchange="toggleAll(this.checked)"></th><th>名称</th><th>大小</th><th>修改时间</th><th></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> </main><div id="context" class="context"></div>
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div> <div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
<script> <script>
@@ -50,7 +51,7 @@ function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",
function fmt(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";if(n<1073741824)return (n/1048576).toFixed(1)+" MB";return (n/1073741824).toFixed(1)+" GB"} function fmt(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";if(n<1073741824)return (n/1048576).toFixed(1)+" MB";return (n/1073741824).toFixed(1)+" GB"}
async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:"",admin:false}};updateUser();current=me.home||"";navigationRoot=me.home||"";const h=new URLSearchParams(location.hash.slice(1));const shared=h.get("folder");if(shared!==null)current=shared;await refresh()} async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:"",admin:false}};updateUser();current=me.home||"";navigationRoot=me.home||"";const h=new URLSearchParams(location.hash.slice(1));const shared=h.get("folder");if(shared!==null)current=shared;await refresh()}
function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin;$("pwdBtn").hidden=!me.username||!me.managed} function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin;$("pwdBtn").hidden=!me.username||!me.managed}
async function refresh(){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||[]);$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}} async function refresh(){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();$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}}
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 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 iconFor(f){return {folder:"📁",image:"🖼️",video:"🎬",audio:"🎵",pdf:"📕",file:"📄"}[fileType(f)]} function iconFor(f){return {folder:"📁",image:"🖼️",video:"🎬",audio:"🎵",pdf:"📕",file:"📄"}[fileType(f)]}
function selectItem(p,checked){if(checked)selected.add(p);else selected.delete(p);updateSelection()} function selectItem(p,checked){if(checked)selected.add(p);else selected.delete(p);updateSelection()}
@@ -68,6 +69,7 @@ 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('.tilemenu'))closeContext()});
function bulkDownload(){if(!selected.size)return;const q=[...selected].map(p=>'path='+encodeURIComponent(p)).join('&');location.href='/chfs/downloadzip?'+q} function bulkDownload(){if(!selected.size)return;const q=[...selected].map(p=>'path='+encodeURIComponent(p)).join('&');location.href='/chfs/downloadzip?'+q}
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){alert(p+': '+e.message)}}selected.clear();refresh()} 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){alert(p+': '+e.message)}}selected.clear();refresh()}
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)+')">'+(r.home?'👤':'📂')+' '+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){alert(e.message)}} 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){alert(e.message)}}
function jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()} function jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()}
function openDir(p){current=p;refresh()}function goUp(){if(!current||current===navigationRoot)return;let p=current.split("/");p.pop();let n=p.join("/");if(navigationRoot&&!(n===navigationRoot||n.startsWith(navigationRoot+"/")))n=navigationRoot;current=n;refresh()} function openDir(p){current=p;refresh()}function goUp(){if(!current||current===navigationRoot)return;let p=current.split("/");p.pop();let n=p.join("/");if(navigationRoot&&!(n===navigationRoot||n.startsWith(navigationRoot+"/")))n=navigationRoot;current=n;refresh()}