ui: standardize button hierarchy and modal interactions

This commit is contained in:
OpenClaw Agent
2026-09-22 15:32:40 +08:00
parent e6806de5f6
commit a2113f831d
+32 -9
View File
@@ -18,6 +18,29 @@ table{width:100%;border-collapse:separate;border-spacing:0}th,td{text-align:left
:root{--bg:#f5f7fb;--surface:#fff;--surface2:#f8fafc;--text:#172033;--muted:#7b8798;--border:#e5e9f2;--primary:#2563eb;--danger:#dc2626;--shadow:0 8px 30px rgba(20,30,50,.08)}
html.dark{--bg:#0f141c;--surface:#171e28;--surface2:#202936;--text:#e7edf7;--muted:#9aa7b8;--border:#2c3746;--primary:#60a5fa;--danger:#f87171;--shadow:0 12px 35px rgba(0,0,0,.3)}
/* Button system: primary / secondary / ghost / danger */
button{display:inline-flex;align-items:center;justify-content:center;gap:6px;font:inherit;font-size:13.5px;font-weight:500;line-height:1;padding:8px 14px;border-radius:8px;border:1px solid var(--border);background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.04);cursor:pointer;transition:background .15s ease,border-color .15s ease,color .15s ease,box-shadow .15s ease,transform .08s ease;user-select:none}
button:hover{background:var(--surface2);border-color:color-mix(in srgb,var(--border) 70%,var(--text));box-shadow:0 2px 6px rgba(20,30,50,.07)}
button:active{transform:translateY(1px);box-shadow:none}
button:focus-visible{outline:2px solid color-mix(in srgb,var(--primary) 65%,transparent);outline-offset:2px}
button:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none}
button.primary,button.primary-action{background:var(--primary);border-color:var(--primary);color:#fff;box-shadow:0 2px 5px color-mix(in srgb,var(--primary) 28%,transparent)}
button.primary:hover,button.primary-action:hover{background:color-mix(in srgb,var(--primary) 88%,#000);border-color:color-mix(in srgb,var(--primary) 88%,#000);box-shadow:0 4px 10px color-mix(in srgb,var(--primary) 30%,transparent)}
button.ghost{background:transparent;border-color:transparent;color:var(--text);box-shadow:none}
button.ghost:hover{background:var(--surface2);border-color:transparent;box-shadow:none}
button.danger{background:color-mix(in srgb,var(--danger) 10%,var(--surface));border-color:color-mix(in srgb,var(--danger) 30%,transparent);color:var(--danger);box-shadow:none}
button.danger:hover{background:var(--danger);border-color:var(--danger);color:#fff;box-shadow:0 3px 8px color-mix(in srgb,var(--danger) 25%,transparent)}
button.secondary{background:var(--surface);border-color:var(--border);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.04)}
button.iconbtn{width:38px;height:38px;padding:0;flex:0 0 auto}
.view-switch{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:9px;overflow:hidden;background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.04)}
.view-switch .viewbtn{width:36px;height:34px;border:0;border-radius:0;box-shadow:none;background:transparent;color:var(--muted)}
.view-switch .viewbtn:hover{background:var(--surface2);color:var(--text);box-shadow:none}
.view-switch .viewbtn.active{background:var(--primary);color:#fff}
.view-switch .viewbtn+ .viewbtn{border-left:1px solid var(--border)}
.context{padding:5px;border-radius:10px}
.context button{display:flex;align-items:center;min-height:36px;padding:8px 10px;border:0;background:transparent;box-shadow:none}
.context button:hover{background:var(--surface2);border:0;box-shadow:none}
body{background:var(--bg);color:var(--text);transition:background .2s,color .2s}.top,.card,.dialog{background:var(--surface);border-color:var(--border)}
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)}
@@ -28,19 +51,19 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
<body>
<header class="top">
<div class="brand" id="brand">CHFS</div><div class="path crumbbar" id="crumb"></div><div class="spacer"></div>
<span id="who" class="meta"></span><button class="ghost" id="loginBtn" onclick="openLogin()">登录</button>
<button class="ghost" id="logoutBtn" onclick="logout()" hidden>退出</button>
<span id="who" class="meta"></span><button class="secondary" id="loginBtn" onclick="openLogin()">登录</button>
<button class="secondary" id="logoutBtn" onclick="logout()" hidden>退出</button>
</header>
<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="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div>
<div class="toolbar">
<div class="toolgroup"><button class="ghost iconbtn" onclick="goUp()" title="上一级"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m14 6-6 6 6 6M8 12h12"/></svg></button><button class="ghost iconbtn" onclick="refresh()" title="刷新"><svg class="ui-icon" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0 1 4M20 5v6h-6"/></svg></button><button class="ghost" onclick="showRoots()">位置</button></div>
<div class="toolgroup"><button class="ghost iconbtn" onclick="goUp()" title="上一级"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m14 6-6 6 6 6M8 12h12"/></svg></button><button class="ghost iconbtn" onclick="refresh()" title="刷新"><svg class="ui-icon" viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0 1 4M20 5v6h-6"/></svg></button><button class="secondary" onclick="showRoots()">位置</button></div>
<span class="divider"></span>
<div class="toolgroup"><button id="newFolderBtn" onclick="newFolder()">新建文件夹</button><button id="newTextBtn" class="ghost" onclick="newText()">新建文本</button><label><button id="uploadBtn" class="primary-action">上传</button><input id="upload" type="file" multiple hidden></label><button class="ghost" onclick="showMobileTransfer()">手机传输</button></div>
<div class="toolgroup"><button id="newFolderBtn" class="secondary" onclick="newFolder()">新建文件夹</button><button id="newTextBtn" class="secondary" onclick="newText()">新建文本</button><label><button id="uploadBtn" class="primary">上传</button><input id="upload" type="file" multiple hidden></label><button class="secondary" onclick="showMobileTransfer()">手机传输</button></div>
<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 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><span class="view-switch"><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></span></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)" 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>
@@ -90,7 +113,7 @@ function clearSelection(){selected.clear();updateSelection();if(searchMode){rend
function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0&&!!me?.username);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";const bulkBtns=$("bulk")?.querySelectorAll("button");if(bulkBtns?.length){bulkBtns[0].hidden=!canSelection("move");bulkBtns[1].hidden=!canSelection("copy");bulkBtns[2].hidden=!canSelection("download");bulkBtns[3].hidden=!canSelection("delete");}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();e.stopPropagation();if(!selected.has(p)){selected.clear();selected.add(p);updateSelection()}const f=lastItems.find(x=>(current?current+'/':'')+x.name===p);const guest=!me.username;if(guest){const canRead=(f?.guestMask||f?.mask||'').includes('R');let html='';if(canRead)html+='<button onclick="downloadFile('+attrJSON(p)+');closeContext()">下载</button>';if(f?.dir&&canRead)html+='<button onclick="downloadDir('+attrJSON(p)+');closeContext()">下载 ZIP</button>';if(canRead)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';return}const canRead=(f?.mask||'').includes('R'),canWrite=(f?.mask||'').includes('W'),canDelete=(f?.mask||'').includes('D');let html='';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'}
function closeContext(){$("context").style.display='none'}
function closeContext(){$("context").style.display="none"}
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="";
@@ -182,9 +205,9 @@ function fail(message){toast(message,true)}
function keyboardNavigation(e){const tag=(e.target?.tagName||"").toLowerCase();if(tag==="input"||tag==="textarea"||tag==="select"||tag==="button"||tag==="a"||e.target?.isContentEditable)return;if($("modal").classList.contains("show")&&$("galleryStage")&&galleryFiles.length){if(e.key==="ArrowLeft"){e.preventDefault();galleryMove(-1);return}if(e.key==="ArrowRight"){e.preventDefault();galleryMove(1);return}if(e.key==="Home"){e.preventDefault();galleryIndex=0;renderGallery();return}if(e.key==="End"){e.preventDefault();galleryIndex=galleryFiles.length-1;renderGallery();return}}if(e.key==="Escape"){if($("modal").classList.contains("show"))closeModal();else closeContext();return}if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="a"){e.preventDefault();toggleAll(true);return}if(e.key==="Enter"){e.preventDefault();openSelected();return}if(e.key==="Delete"&&selected.size){e.preventDefault();bulkDelete();return}if(e.key==="Backspace"&&current&&!searchMode){e.preventDefault();goUp();return}if((e.key==="ArrowDown"||e.key==="ArrowUp")){const source=searchMode?searchItems:lastItems;if(!source.length)return;e.preventDefault();const paths=searchMode?source.map(searchPath):selectablePaths(),anchor=selectionAnchor&&paths.includes(selectionAnchor)?selectionAnchor:[...selected][0],idx=Math.max(0,paths.indexOf(anchor)),next=Math.max(0,Math.min(paths.length-1,idx+(e.key==="ArrowDown"?1:-1)));if(searchMode)selectSearchItem(paths[next],true,e);else selectItem(paths[next],true,e);return}if(e.key==="Home"||e.key==="End"){const source=searchMode?searchItems:lastItems;if(!source.length)return;e.preventDefault();const paths=searchMode?source.map(searchPath):selectablePaths(),p=e.key==="Home"?paths[0]:paths.at(-1);if(searchMode)selectSearchItem(p,true,e);else selectItem(p,true,e)}}
addEventListener("keydown",keyboardNavigation)
function show(html,preview=false){$("dialog").innerHTML=html;$("dialog").classList.toggle("preview-dialog",preview);$("modal").classList.add("show")}async function closeModal(){if(window._editorDirty&&$("editText")){const ok=await confirmBox("放弃未保存修改?","当前文本还有未保存的修改,关闭后这些修改将丢失。");if(!ok)return false}window._editorDirty=false;$("modal").classList.remove("show");$("dialog").classList.remove("preview-dialog");return true}
function ask(title,label,value="",type="text"){return new Promise(resolve=>{show('<h2>'+esc(title)+'</h2><div class="field"><label>'+esc(label)+'</label><input id="askInput" type="'+type+'" value="'+esc(value)+'"></div><div class="row"><button class="ghost" onclick="closeModal();window._askResolve(null)">取消</button><button onclick="window._askResolve($("askInput").value)">确定</button></div>');window._askResolve=v=>{closeModal();resolve(v)}})}
function confirmBox(title,message){return new Promise(resolve=>{show('<h2>'+esc(title)+'</h2><div class="notice">'+esc(message)+'</div><div class="row"><button class="ghost" onclick="closeModal();window._confirmResolve(false)">取消</button><button class="danger" onclick="closeModal();window._confirmResolve(true)">确定</button></div>');window._confirmResolve=v=>resolve(v)})}
$("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()})
function ask(title,label,value="",type="text"){return new Promise(resolve=>{let settled=false;const finish=async v=>{if(settled)return;const closed=await closeModal();if(!closed)return;settled=true;window._askResolve=null;resolve(v)};window._askResolve=finish;show('<h2>'+esc(title)+'</h2><div class="field"><label>'+esc(label)+'</label><input id="askInput" type="'+type+'" value="'+esc(value)+'"></div><div class="row"><button class="secondary" onclick="window._askResolve?.(null)">取消</button><button class="primary" onclick="window._askResolve?.($("askInput").value)">确定</button></div>')})}
function confirmBox(title,message){return new Promise(resolve=>{let settled=false;const finish=async v=>{if(settled)return;const closed=await closeModal();if(!closed)return;settled=true;window._confirmResolve=null;resolve(v)};window._confirmResolve=finish;show('<h2>'+esc(title)+'</h2><div class="notice">'+esc(message)+'</div><div class="row"><button class="secondary" onclick="window._confirmResolve?.(false)">取消</button><button class="danger" onclick="window._confirmResolve?.(true)">确定</button></div>')})}
document.addEventListener("pointerdown",e=>{const c=$("context");if(c?.style.display==="block"&&!c.contains(e.target))closeContext()});$("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()})
async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<div style="padding:10px 0;border-bottom:1px solid #edf0f5"><b>'+esc(u.username)+'</b> · '+(u.enabled?"启用":"已禁用")+' · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="ghost" onclick="editUser('+attrJSON(u.username)+')">权限/状态</button> <button class="ghost" onclick="resetUser('+attrJSON(u.username)+')">重置密码</button> <button class="danger" onclick="deleteUser('+attrJSON(u.username)+')">删除</button>':"")+"</div>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){fail(e.message)}}
async function createUser(){try{await api("/chfs/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:$("nu").value,password:$("np").value})});showUsers()}catch(e){fail(e.message)}}
async function resetUser(u){const p=await ask("重置密码","为 "+u+" 设置新密码","","password");if(!p)return;try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,password:p})});toast("密码已更新")}catch(e){fail(e.message)}}