174 lines
71 KiB
HTML
174 lines
71 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>__CHFS_TITLE__</title>
|
||
<style>
|
||
:root{font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f5f7fb}
|
||
*{box-sizing:border-box}body{margin:0}.top{height:64px;background:#fff;border-bottom:1px solid #e5e9f2;display:flex;align-items:center;padding:0 22px;gap:18px;position:sticky;top:0;z-index:2}.brand{font-weight:750;font-size:20px}.path{color:#718096;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.spacer{flex:1}
|
||
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}.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.drop-target{background:color-mix(in srgb,var(--primary) 12%,var(--surface));box-shadow:inset 0 0 0 2px var(--primary)}.tile.drop-target{border-color:var(--primary);box-shadow:0 0 0 3px color-mix(in srgb,var(--primary) 16%,transparent)}.side-item.drop-target{background:color-mix(in srgb,var(--primary) 12%,var(--surface2));color:var(--primary)}.dragging{opacity:.45}.gallery-nav{position:absolute;top:50%;transform:translateY(-50%);width:42px;height:42px;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;border:1px solid rgba(255,255,255,.22);backdrop-filter:blur(8px);display:flex;align-items:center;justify-content:center}.gallery-nav .ui-icon{width:22px;height:22px}.gallery-prev{left:16px}.gallery-next{right:16px}.gallery-count{position:absolute;left:50%;bottom:14px;transform:translateX(-50%);padding:5px 9px;border-radius:99px;background:rgba(0,0,0,.48);color:#fff;font-size:12px}.preview-stage.media-stage{position:relative}.preview-stage video:focus{outline:2px solid var(--primary);outline-offset:2px}.media-hint{position:absolute;left:18px;top:14px;color:#aab5c5;font-size:12px;pointer-events:none}.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}}
|
||
|
||
: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)}
|
||
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)}
|
||
.iconbtn{width:38px;height:38px;padding:0;background:var(--surface2);color:var(--text);display:inline-flex;align-items:center;justify-content:center}.ui-icon{width:18px;height:18px;display:block;stroke:currentColor;fill:none;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.ui-icon.fill{fill:currentColor;stroke:none}.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(190px,1fr));gap:14px;padding:16px}.tile{position:relative;border:1px solid var(--border);border-radius:16px;padding:12px;background:var(--surface);min-height:192px;cursor:pointer;transition:border-color .16s,box-shadow .16s,transform .16s}.tile:hover{border-color:var(--primary);box-shadow:0 10px 28px rgba(20,30,50,.08);transform:translateY(-1px)}.tile.selected{border-color:var(--primary);box-shadow:0 0 0 2px rgba(37,99,235,.14)}.tile .thumb{height:112px;display:flex;align-items:center;justify-content:center;border-radius:11px;background:var(--surface2);overflow:hidden;margin-bottom:11px;color:var(--muted);transition:background .16s}.tile:hover .thumb{background:var(--surface2)}.tile .thumb .ui-icon{width:44px;height:44px;stroke-width:1.35}.tile .thumb img{width:100%;height:100%;object-fit:cover;display:block}.tile .tile-title{display:flex;align-items:center;gap:7px;min-width:0;padding:0 2px}.tile .tile-title .ui-icon{width:16px;height:16px;flex:0 0 auto}.tile .file-name{font-size:14px;font-weight:560;line-height:1.35}.tile .tile-meta{display:flex;gap:8px;padding:6px 2px 0;color:var(--muted);font-size:12px}.tile .tile-check{position:absolute;left:10px;top:10px;opacity:0;transition:opacity .14s}.tile:hover .tile-check,.tile.selected .tile-check{opacity:1}.tile .tile-action{position:absolute;right:9px;top:9px;width:30px;height:30px;padding:0;border:1px solid var(--border);background:var(--surface);color:var(--text);opacity:0;transition:opacity .14s}.tile:hover .tile-action,.tile.selected .tile-action{opacity:1}.tile .tile-action .ui-icon{width:15px;height:15px}.tile.folder-tile .thumb{background:var(--surface2)}.tile.media-tile .thumb{background:#0d1218;color:#d7e0ec}.tile.media-tile .thumb .ui-icon{stroke-width:1.45}.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)}.upload-row .upload-head{display:flex;align-items:center;gap:8px;min-width:0}.upload-row .upload-name{min-width:0;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.upload-row .upload-actions{display:flex;gap:5px}.upload-row .upload-actions button{padding:5px 8px;font-size:12px}.upload-summary{display:flex;align-items:center;gap:10px;padding:10px 0}.upload-summary .count{margin-right:auto}.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}}.toast-host{position:fixed;right:18px;bottom:18px;z-index:20;display:grid;gap:8px}.toast{min-width:240px;max-width:min(420px,calc(100vw - 36px));padding:11px 14px;border:1px solid var(--border);border-radius:11px;background:var(--surface);box-shadow:var(--shadow);color:var(--text);animation:toastIn .18s ease}@keyframes toastIn{from{transform:translateY(8px);opacity:0}to{transform:none;opacity:1}}.toast.error{border-color:var(--danger)}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:58px 24px;min-height:260px;color:var(--muted)}.empty-state .empty-icon{width:58px;height:58px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:16px;background:var(--surface2);margin-bottom:14px;color:var(--muted)}.empty-state .empty-icon .ui-icon{width:27px;height:27px;stroke-width:1.45}.empty-state h3{margin:0 0 6px;color:var(--text);font-size:16px}.empty-state p{margin:0;max-width:420px;font-size:13px;line-height:1.6}.empty-state .row{margin-top:16px}.empty-state.search-empty{min-height:300px}.state-icon{width:58px;height:58px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:16px;background:var(--surface2);margin:0 auto 14px;color:var(--muted)}.state-icon .ui-icon{width:27px;height:27px;stroke-width:1.45}.state-actions{display:flex;gap:8px;justify-content:center;margin-top:16px}.loading-state{padding:24px}.skeleton-row{height:48px;border-bottom:1px solid var(--border);background:linear-gradient(90deg,var(--surface2),var(--surface),var(--surface2));background-size:240% 100%;animation:shimmer 1.2s infinite}@keyframes shimmer{from{background-position:200% 0}to{background-position:-40% 0}}.state-error{padding:46px 24px}.state-error h3{margin:0 0 7px;color:var(--text);font-size:16px}.state-error p{margin:0;color:var(--muted);font-size:13px;line-height:1.6}.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>
|
||
</head>
|
||
<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>
|
||
</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>
|
||
<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="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)" 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>
|
||
const $=id=>document.getElementById(id);let current="",me=null,currentMask="",navigationRoot="";
|
||
async function api(url,opt={}){const r=await fetch(url,opt);if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};const e=Error(t);e.status=r.status;throw e}return r.status===204?null:r.json()}
|
||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]))}
|
||
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()}
|
||
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 showLoading(){const row='<div class="skeleton-row"></div>'.repeat(6);$("files").innerHTML='<tr><td colspan="6"><div class="loading-state">'+row+'</div></td></tr>';$("grid").innerHTML='<div class="loading-state">'+row+'</div>'}
|
||
function showState(title,text,type="error"){const icon=type==="lock"?'<path d="M7 11V8a5 5 0 0 1 10 0v3"/><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M12 15v3"/>':type==="offline"?'<path d="m4 4 16 16M9 9a5 5 0 0 1 6 6M6 6a9 9 0 0 1 12 0M3 3l18 18"/>':'<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/>';$("files").innerHTML='<tr><td colspan="6"><div class="empty-state state-error"><div class="state-icon"><svg class="ui-icon" viewBox="0 0 24 24">'+icon+'</svg></div><h3>'+esc(title)+'</h3><p>'+esc(text)+'</p><div class="state-actions"><button onclick="refresh()">重试</button></div></div></td></tr>';$("grid").innerHTML='<div class="empty-state state-error"><div class="state-icon"><svg class="ui-icon" viewBox="0 0 24 24">'+icon+'</svg></div><h3>'+esc(title)+'</h3><p>'+esc(text)+'</p><div class="state-actions"><button onclick="refresh()">重试</button></div></div>'}
|
||
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")}
|
||
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,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')}
|
||
function folderDragLeave(e){e.currentTarget.classList.remove('drop-target')}
|
||
async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');toast('正在移动 '+paths.length+' 项…');let done=0,failed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{await api('/chfs/move',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({source,destination})});done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length)fail('移动完成 '+done+' 项,失败 '+failed.length+' 项');else toast('已移动 '+done+' 项')}
|
||
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)}
|
||
function toggleTile(p){selectItem(p,!selected.has(p))}
|
||
function openSelected(){if(selected.size!==1)return;const p=[...selected][0],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)}
|
||
function toggleAll(v){lastItems.forEach(f=>{const p=current?current+"/"+f.name:f.name;if(v)selected.add(p);else selected.delete(p)});render(lastItems)}
|
||
function clearSelection(){selected.clear();updateSelection();renderGrid(lastItems);renderList(lastItems)}
|
||
function updateSelection(){const n=selected.size;$("bulk").classList.toggle("show",n>0);$("selectedCount").textContent=n?("已选择 "+n+" 项"):"";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=lastItems[i];if(f){const p=current?current+"/"+f.name: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();if(!selected.has(p)){selected.clear();selected.add(p);updateSelection();renderList(lastItems);renderGrid(lastItems)}const f=lastItems.find(x=>(current?current+'/':'')+x.name===p);const canRead=(f?.mask||'').includes('R'),canWrite=(f?.mask||'').includes('W'),canDelete=(f?.mask||'').includes('D');let html='';if(canRead)html+='<button onclick="downloadFile('+JSON.stringify(p)+');closeContext()">下载</button>';if(f?.dir&&canRead)html+='<button onclick="downloadDir('+JSON.stringify(p)+');closeContext()">下载 ZIP</button>';if(canWrite)html+='<button onclick="renameItem('+JSON.stringify(p)+');closeContext()">重命名</button><button onclick="transferItem('+JSON.stringify(p)+',false);closeContext()">移动到…</button><button onclick="transferItem('+JSON.stringify(p)+',true);closeContext()">复制到…</button>';if(canDelete)html+='<button onclick="removeItem('+JSON.stringify(p)+');closeContext()">删除</button>';if(f?.guestMask?.includes('R'))html+='<button onclick="shareItem('+JSON.stringify(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'}
|
||
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}
|
||
async function chooseDestination(title){return await ask(title,"目标目录(留空表示当前目录)",current)}
|
||
async function transferItem(source,copyOnly){const dstDir=await chooseDestination(copyOnly?"复制到":"移动到");if(dstDir===null)return;const name=source.split("/").pop();const base=String(dstDir).replace(/^\/+|\/+$/g,"");const destination=base?(base+"/"+name):name,action=copyOnly?"复制":"移动";toast(action+"中…");try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});await refresh();toast(action+"完成")}catch(e){show('<h2>'+action+'失败</h2><div class="notice">'+esc(e.message)+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}}
|
||
async function bulkMove(){await bulkTransfer(false)}
|
||
async function bulkCopy(){await bulkTransfer(true)}
|
||
async function bulkTransfer(copyOnly){if(!selected.size)return;const dstDir=await chooseDestination(copyOnly?"复制选中项目到":"移动选中项目到");if(dstDir===null)return;const sources=[...selected],base=String(dstDir).replace(/^\/+|\/+$/g,""),action=copyOnly?"复制":"移动";toast(action+" "+sources.length+" 项…");const failed=[];let done=0;for(const source of sources){const name=source.split("/").pop(),destination=base?(base+"/"+name):name;try{await api(copyOnly?"/chfs/copy":"/chfs/move",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});done++}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('<h2>部分操作失败</h2><div class="notice">'+esc(action+"完成 "+done+" 项,失败 "+failed.length+" 项。\n\n"+failed.join("\n"))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast(action+"完成,共 "+done+" 项")}
|
||
|
||
async function bulkDelete(){if(!selected.size)return;if(!await confirmBox("批量删除","确定删除已选择的 "+selected.size+" 项?"))return;const targets=[...selected];toast("正在删除 "+targets.length+" 项…");const failed=[];let done=0;for(const p of targets){try{await api('/chfs/rmfiles',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({filepath:p})});done++}catch(e){failed.push(p+": "+e.message)}}selected.clear();await refresh();if(failed.length)show('<h2>部分删除失败</h2><div class="notice">'+esc("已删除 "+done+" 项,失败 "+failed.length+" 项。\n\n"+failed.join("\n"))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast("删除完成,共 "+done+" 项")}
|
||
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':'')+'" data-root-path="'+esc(r.path)+'" onclick="jumpRoot('+JSON.stringify(r.path)+')" ondragover="sideDragOver(event,'+JSON.stringify(r.path)+')" ondragleave="sideDragLeave(event)" ondrop="sideDrop(event,'+JSON.stringify(r.path)+')">'+svgIcon('folder')+' '+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){fail(e.message)}}
|
||
function sideDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.dataTransfer.dropEffect='move';e.currentTarget.classList.add('drop-target')}
|
||
function sideDragLeave(e){e.currentTarget.classList.remove('drop-target')}
|
||
async function sideDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};if(!paths.length)return;toast('正在移动 '+paths.length+' 项…');let done=0;for(const source of paths){const name=source.split('/').pop(),destination=target?target+'/'+name:name;try{await api('/chfs/move',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({source,destination})});done++}catch{}}selected.clear();await refresh();toast('已移动 '+done+' 项')}
|
||
|
||
function jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()}
|
||
function renderBreadcrumb(){const el=$("crumb"),parts=current?current.split("/").filter(Boolean):[];const rootName=navigationRoot?(navigationRoot===me?.home?'我的空间':'当前位置'): '全部文件';let html='<button class="crumb-seg '+(!current||current===navigationRoot?'current':'')+'" onclick="navigateTo('+JSON.stringify(navigationRoot||'')+')">'+esc(rootName)+'</button>';let acc='';for(let i=0;i<parts.length;i++){acc=acc?acc+'/'+parts[i]:parts[i];html+='<span class="crumb-sep">/</span><button class="crumb-seg '+(i===parts.length-1?'current':'')+'" onclick="navigateTo('+JSON.stringify(acc)+')">'+esc(parts[i])+'</button>'}el.innerHTML=html}
|
||
function navigateTo(p){if(navigationRoot){if(p!==navigationRoot&&!p.startsWith(navigationRoot+'/'))return}else if(p!==''&&!me?.admin)return;current=p;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 downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")}
|
||
function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)}
|
||
async function shareItem(p){const url=new URL("/chfs/shared/"+encodeURI(p),location.href).href;try{await navigator.clipboard.writeText(url);toast("分享链接已复制")}catch{show("<h2>分享链接</h2><div class=\"field\"><input id=\"shareUrl\" value=\""+esc(url)+"\" readonly></div><div class=\"row\"><button class=\"ghost\" onclick=\"closeModal()\">关闭</button><button onclick=\"navigator.clipboard.writeText($(\"shareUrl\").value);closeModal()\">复制</button></div>")}}
|
||
async function renameItem(p){const name=p.split("/").pop();const n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");toast("正在重命名…");try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});await refresh();toast("已重命名")}catch(e){fail(e.message)}}
|
||
async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;toast("正在删除…");try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("已删除")}catch(e){fail(e.message)}}
|
||
async function newFolder(){const n=await ask("新建文件夹","文件夹名称");if(!n)return;const p=current?current+"/"+n:n;toast("正在创建文件夹…");try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("文件夹已创建")}catch(e){fail(e.message)}}
|
||
async function newText(){const n=await ask("新建文本","文件名","new.txt");if(!n)return;const text=await ask("新建文本","文本内容");if(text===null)return;toast("正在创建文本…");try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});await refresh();toast("文本已创建")}catch(e){fail(e.message)}}
|
||
$("upload").addEventListener("change",async e=>{await uploadFiles(e.target.files);e.target.value=""})
|
||
async function uploadFiles(files){const list=[...files].map(file=>({file,relativePath:file.webkitRelativePath||file.name}));if(!list.length)return;await uploadEntries(list)}
|
||
async function uploadEntries(entries){const tasks=entries.map((item,index)=>({id:"upload-"+Date.now()+"-"+index,file:item.file,relativePath:item.relativePath,state:"queued",xhr:null,row:null}));window._uploadTasks=tasks;show('<h2>上传文件</h2><div class="notice">共 '+tasks.length+' 个文件,最多同时上传 3 个。关闭窗口不会中断上传。</div><div id="uploadSummary" class="upload-summary"><span class="count">准备上传</span><span class="meta"></span></div><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>');const box=$("uploadList");tasks.forEach(task=>{const row=document.createElement("div");row.className="upload-row";row.innerHTML='<div class="upload-head"><b class="upload-name" title="'+esc(task.relativePath)+'">'+esc(task.relativePath)+'</b><div class="upload-actions"><button class="ghost" data-upload-action="cancel">取消</button><button class="ghost" data-upload-action="retry" hidden>重试</button></div></div><span class="meta">'+fmt(task.file.size)+' · 排队中</span><div class="progress"><i></i></div>';row.querySelector('[data-upload-action="cancel"]').onclick=()=>cancelUploadTask(task);row.querySelector('[data-upload-action="retry"]').onclick=()=>retryUploadTask(task);task.row=row;box.appendChild(row)});await runUploadQueue(tasks,3);await refresh();const failed=tasks.filter(t=>t.state==="failed").length,cancelled=tasks.filter(t=>t.state==="cancelled").length,done=tasks.filter(t=>t.state==="done").length;if(failed||cancelled)toast("上传完成:成功 "+done+",失败 "+failed+",取消 "+cancelled,failed>0);else toast("上传完成,共 "+done+" 个文件")}
|
||
async function runUploadQueue(tasks,limit){let cursor=0;async function worker(){while(cursor<tasks.length){const task=tasks[cursor++];if(task.state!=="queued")continue;task.state="uploading";setUploadTaskState(task,"uploading");const ok=await uploadOne(task.file,task.row,task.relativePath,task);if(ok){task.state="done";setUploadTaskState(task,"done")}else if(task.state!=="cancelled"){task.state="failed";setUploadTaskState(task,"failed")};updateUploadSummary(tasks)}}await Promise.all(Array.from({length:Math.min(limit,tasks.length)},()=>worker()));updateUploadSummary(tasks)}
|
||
function setUploadTaskState(task,state){if(!task.row)return;const meta=task.row.querySelector('.meta'),cancel=task.row.querySelector('[data-upload-action="cancel"]'),retry=task.row.querySelector('[data-upload-action="retry"]');if(state==="uploading"){meta.textContent=fmt(task.file.size)+' · 上传中';cancel.hidden=false;retry.hidden=true}else if(state==="done"){meta.textContent=fmt(task.file.size)+' · 完成';cancel.hidden=true;retry.hidden=true}else if(state==="failed"){retry.hidden=false;cancel.hidden=true}else if(state==="cancelled"){meta.textContent=fmt(task.file.size)+' · 已取消';cancel.hidden=true;retry.hidden=false}else{meta.textContent=fmt(task.file.size)+' · 排队中';cancel.hidden=false;retry.hidden=true}}
|
||
function updateUploadSummary(tasks){const el=$("uploadSummary");if(!el)return;const done=tasks.filter(t=>t.state==="done").length,failed=tasks.filter(t=>t.state==="failed").length,cancelled=tasks.filter(t=>t.state==="cancelled").length,active=tasks.filter(t=>t.state==="uploading").length,queued=tasks.filter(t=>t.state==="queued").length;el.querySelector('.count').textContent=done+failed+cancelled+' / '+tasks.length+' 个完成';el.querySelector('.meta').textContent=active?'正在上传 '+active+' 个'+(queued?',排队 '+queued+' 个':''):failed?'有 '+failed+' 个失败':cancelled?'有 '+cancelled+' 个已取消':'全部完成'}
|
||
function cancelUploadTask(task){if(task.state==="queued"){task.state="cancelled";setUploadTaskState(task,"cancelled");updateUploadSummary(window._uploadTasks||[]);return}if(task.state==="uploading"){task.state="cancelled";if(task.xhr)task.xhr.abort();setUploadTaskState(task,"cancelled");updateUploadSummary(window._uploadTasks||[])}}
|
||
function retryUploadTask(task){if(task.state!=="failed"&&task.state!=="cancelled")return;task.state="uploading";setUploadTaskState(task,"uploading");uploadOne(task.file,task.row,task.relativePath,task).then(ok=>{if(task.state==="cancelled")return;task.state=ok?"done":"failed";setUploadTaskState(task,task.state);updateUploadSummary(window._uploadTasks||[])})}
|
||
async function collectDroppedEntries(items){const out=[];async function walk(entry,prefix=""){if(entry.isFile){await new Promise(resolve=>entry.file(file=>{out.push({file,relativePath:prefix+file.name});resolve()},()=>resolve()));return}if(entry.isDirectory){const reader=entry.createReader();const all=[];while(true){const batch=await new Promise(resolve=>reader.readEntries(resolve,()=>resolve([])));if(!batch.length)break;all.push(...batch)}for(const child of all)await walk(child,prefix+entry.name+'/')}}for(const item of items){const entry=item.webkitGetAsEntry?.();if(entry)await walk(entry);else{const file=item.getAsFile?.();if(file)out.push({file,relativePath:file.name})}}return out}
|
||
|
||
function showMobileTransfer(){const u=new URL(location.href);u.hash='folder='+encodeURIComponent(current);show('<h2>手机传输</h2><div class="notice">用手机扫描二维码打开当前文件夹。登录后即可按你的权限上传、下载和管理文件。</div><img class="qr" src="/chfs/qrcode?url='+encodeURIComponent(u.href)+'" alt="QR Code"><div class="field"><input value="'+esc(u.href)+'" readonly></div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}
|
||
function setupDropZone(){const z=$("dropZone"),main=$("fileCard");let depth=0;[document,main].forEach(el=>{el.addEventListener('dragenter',e=>{if(!e.dataTransfer?.types.includes('Files'))return;e.preventDefault();depth++;z.style.display='block';z.classList.add('drop-active')});el.addEventListener('dragover',e=>{if(e.dataTransfer?.types.includes('Files'))e.preventDefault()});el.addEventListener('dragleave',e=>{if(!e.dataTransfer?.types.includes('Files'))return;depth--;if(depth<=0){depth=0;z.classList.remove('drop-active')}});el.addEventListener('drop',async e=>{if(!e.dataTransfer?.types.includes('Files'))return;e.preventDefault();depth=0;z.classList.remove('drop-active');z.style.display='none';const entries=await collectDroppedEntries(e.dataTransfer.items||[]);if(entries.length)await uploadEntries(entries);else if(e.dataTransfer.files?.length)await uploadFiles(e.dataTransfer.files)})})}
|
||
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,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'}
|
||
function openSearchResult(f){if(f.dir){current=f.path;navigationRoot=me.home||"";searchMode=false;$("search").value="";refresh()}else previewFile(f.path)}
|
||
let galleryFiles=[],galleryIndex=0,activeMedia=null;
|
||
function previewFilesFor(p){const dir=p.includes('/')?p.split('/').slice(0,-1).join('/'):'',items=dir===current?lastItems:searchItems;return items.filter(f=>!f.dir&&fileType(f)==='image'&&(f.mask||'').includes('R'))}
|
||
function galleryOpen(p){galleryFiles=previewFilesFor(p);galleryIndex=Math.max(0,galleryFiles.findIndex(f=>(dirOf(f.path||f.name)===current?f.name:f.path)===p));renderGallery()}
|
||
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 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);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='+JSON.stringify('/chfs/shared/'+encodeURI(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))}}if($('modal').classList.contains('show')&&galleryFiles.length&&(e.key==='ArrowLeft'||e.key==='ArrowRight')){e.preventDefault();galleryMove(e.key==='ArrowLeft'?-1:1)}}
|
||
addEventListener('keydown',mediaKeydown);
|
||
function editText(p,content){show('<div class="preview-head"><span class="preview-title">编辑 · '+esc(p.split('/').pop())+'</span><button class="iconbtn" onclick="saveTextEdit('+JSON.stringify(p)+')" title="保存">保存</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" style="display:block"><textarea id="editText" style="width:100%;height:65vh;resize:none;border:1px solid var(--border);border-radius:10px;padding:16px;background:var(--surface);color:var(--text);font:13px/1.6 ui-monospace,SFMono-Regular,monospace;outline:0">'+esc(content)+'</textarea></div><div class="preview-foot"><span class="meta" style="margin-right:auto">Ctrl/Cmd + S 保存</span><button class="ghost" onclick="closeModal()">取消</button><button onclick="saveTextEdit('+JSON.stringify(p)+')">保存</button></div>',true);$('editText').focus();$('editText').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'){e.preventDefault();saveTextEdit(p)}})}
|
||
async function saveTextEdit(p){const text=$('editText')?.value;if(text===undefined)return;const parts=p.split('/'),filename=parts.pop()||'';const filepath=parts.join('/');try{await api('/chfs/updatetext',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({filepath,filename,text})});toast('已保存');closeModal();refresh()}catch(e){fail(e.message)}}
|
||
async function togglePiP(){if(!activeMedia||!('pictureInPictureEnabled' in document)||!document.pictureInPictureEnabled)return fail('当前浏览器不支持画中画');try{if(document.pictureInPictureElement)await document.exitPictureInPicture();else if(activeMedia.requestPictureInPicture)await activeMedia.requestPictureInPicture()}catch(e){fail('无法进入画中画')} }
|
||
function startMiniPlayer(url,name){let host=$('miniPlayer');if(!host){host=document.createElement('div');host.id='miniPlayer';host.style='position:fixed;right:18px;bottom:18px;z-index:30;width:min(380px,calc(100vw - 36px));padding:12px 14px;border:1px solid var(--border);border-radius:14px;background:var(--surface);box-shadow:var(--shadow);display:grid;gap:8px';document.body.appendChild(host)}host.innerHTML='<div style="display:flex;align-items:center;gap:8px"><span class="preview-title">'+esc(name)+'</span><button class="iconbtn" style="width:30px;height:30px" onclick="document.getElementById(\'miniPlayer\')?.remove();activeMedia=null" title="关闭"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><audio controls autoplay style="width:100%"><source src="'+url+'"></audio>';activeMedia=host.querySelector('audio');closeModal();activeMedia?.focus()}
|
||
async function previewFile(p){const n=p.split('/').pop(),ext=n.toLowerCase().split('.').pop(),url='/chfs/preview?file='+encodeURIComponent(p),media=['mp4','webm','mov','mkv'].includes(ext),audio=['mp3','wav','flac','m4a','ogg'].includes(ext),image=['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext),pdf=ext==='pdf',text=['txt','md','markdown','json','yaml','yml','xml','csv','log','js','ts','jsx','tsx','css','scss','html','htm','go','rs','py','java','c','cpp','h','hpp','sh','sql','toml','ini','conf'].includes(ext);if(image){galleryFiles=previewFilesFor(p);galleryIndex=Math.max(0,galleryFiles.findIndex(f=>(f.path||((current?current+'/':'')+f.name))===p));return renderGallery(p)}let content='';if(media){show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span><button class="ghost" onclick="togglePiP()" title="画中画">画中画</button><button class="iconbtn" onclick="location.href='+JSON.stringify('/chfs/shared/'+encodeURI(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 media-stage"><video controls autoplay tabindex="0"><source src="'+url+'"></video><span class="media-hint">空格暂停 · ← / → 快进 5 秒</span></div><div class="preview-foot"><button class="ghost" onclick="closeModal()">关闭</button></div>',true);activeMedia=$('.preview-stage video');activeMedia?.focus();return}if(audio){startMiniPlayer(url,n);return}if(pdf){show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span><button class="iconbtn" onclick="location.href='+JSON.stringify('/chfs/shared/'+encodeURI(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"><iframe src="'+url+'"></iframe></div><div class="preview-foot"><button class="ghost" onclick="closeModal()">关闭</button></div>',true);return}if(text){try{const r=await fetch(url);if(!r.ok)throw Error();content=await r.text();const canEdit=currentMask.includes('W');show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span>'+(canEdit?'<button class="ghost" onclick="editText('+JSON.stringify(p)+',window._previewText)" title="编辑">编辑</button>':'')+'<button class="iconbtn" onclick="location.href='+JSON.stringify('/chfs/shared/'+encodeURI(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"><pre class="preview-text">'+esc(content)+'</pre></div><div class="preview-foot"><button class="ghost" onclick="closeModal()">关闭</button></div>',true);window._previewText=content}catch{show('<div class="preview-empty">无法读取文本内容</div>')}}else show('<div class="preview-empty">暂不支持该文件类型的在线预览</div>')}
|
||
|
||
function changePassword(){show('<h2>修改密码</h2><div class="field"><input id="cp" type="password" placeholder="当前密码"><input id="npw" type="password" placeholder="新密码"></div><div class="row"><button class="ghost" onclick="closeModal()">取消</button><button onclick="savePassword()">保存</button></div>')}
|
||
async function savePassword(){try{await api("/chfs/password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({currentPassword:$("cp").value,password:$("npw").value})});toast("密码已更新");closeModal()}catch(e){fail(e.message)}}
|
||
function openLogin(){show('<h2>登录</h2><div class="field"><label>用户名</label><input id="u" autocomplete="username"></div><div class="field"><label>密码</label><input id="p" type="password" autocomplete="current-password"></div><div class="row"><button class="ghost" onclick="closeModal()">取消</button><button onclick="login()">登录</button></div>')}
|
||
async function login(){try{await fetch("/chfs/session",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({user:$("u").value,pwd:$("p").value})}).then(async r=>{if(!r.ok)throw Error((await r.json()).error||"登录失败")});closeModal();await init()}catch(e){fail(e.message)}}
|
||
async function logout(){await fetch("/chfs/session",{method:"DELETE"});location.reload()}
|
||
function toast(message,error=false){const host=$("toastHost"),el=document.createElement("div");el.className='toast'+(error?' error':'');el.textContent=message;host.appendChild(el);setTimeout(()=>el.remove(),2800)}
|
||
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(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"&¤t){e.preventDefault();goUp()}}
|
||
addEventListener("keydown",keyboardNavigation)
|
||
function show(html,preview=false){$("dialog").innerHTML=html;$("dialog").classList.toggle("preview-dialog",preview);$("modal").classList.add("show")}function closeModal(){$("modal").classList.remove("show");$("dialog").classList.remove("preview-dialog")}
|
||
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()})
|
||
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('+JSON.stringify(u.username)+')">权限/状态</button> <button class="ghost" onclick="resetUser('+JSON.stringify(u.username)+')">重置密码</button> <button class="danger" onclick="deleteUser('+JSON.stringify(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)}}
|
||
async function editUser(name){const d=await api("/chfs/users");const u=d.users.find(x=>x.username===name);if(!u)return;window._editUser=u;window._editRules=[...(u.rules||[])];show('<h2>'+esc(u.username)+'</h2><div class="notice">主目录:'+esc(u.home)+'<br>主目录始终保留 RWD;下面的“共享目录”用于给用户开放其他位置,也可以单独设置拒绝规则。</div><label><input id="ue" type="checkbox" '+(u.enabled?"checked":"")+'> 账号启用</label><h3>共享目录</h3><div id="sharedRules"></div><div class="field"><input id="rp" placeholder="目录,例如 public 或 shared/team"><div class="row" style="justify-content:flex-start;margin-top:0"><button class="ghost" onclick="setRulePreset(1)">只读</button><button class="ghost" onclick="setRulePreset(2)">读写</button><button class="ghost" onclick="setRulePreset(3)">完全控制</button><button class="ghost" onclick="setRulePreset(0)">禁止访问</button></div><label><input id="rc" type="checkbox"> 仅作用于目录内部,不改变目录本身</label><button onclick="addRule()">添加共享目录</button></div><details style="margin-top:14px"><summary>高级规则</summary><div class="meta" style="margin:8px 0">可对已授权目录增加更细的拒绝规则,例如禁止访问 public/private。</div><div id="rules"></div></details><div class="row"><button class="ghost" onclick="showUsers()">取消</button><button onclick="saveUser('+JSON.stringify(u.username)+')">保存</button></div>');renderEditRules()}
|
||
function isHomeRule(r){return String(r.dir||"").replace(/^\/+|\/+$/g,"")===String(window._editUser.home||"").replace(/^\/+|\/+$/g,"")}
|
||
function setRulePreset(level){const mask=["","r","rw","rwd"][level];window._rulePreset={mask,deny:level===0};$("rp").focus();$("rp").dataset.mask=mask;$("rp").dataset.deny=level===0?"1":"0"}
|
||
function addRule(){const dir=$("rp").value.trim().replace(/^\/+|\/+$/g,"");if(!dir)return;const preset=$("rp").dataset.mask||"r";const rule={dir,mask:preset};if($("rp").dataset.deny==="1")rule.deny=true;if($("rc").checked)rule.childrenOnly=true;window._editRules=window._editRules.filter(r=>!(String(r.dir||"").replace(/^\/+|\/+$/g,"")===dir&&!isHomeRule(r)));window._editRules.push(rule);$("rp").value="";delete $("rp").dataset.mask;delete $("rp").dataset.deny;$("rc").checked=false;renderEditRules()}
|
||
function removeRule(i){if(i<0||isHomeRule(window._editRules[i]))return;window._editRules.splice(i,1);renderEditRules()}
|
||
function renderEditRules(){const shared=window._editRules.filter(r=>!isHomeRule(r));$("sharedRules").innerHTML=shared.length?shared.map(r=>{const i=window._editRules.indexOf(r);const flags=(r.deny?"禁止":"允许")+" · "+(r.mask||"无权限")+(r.childrenOnly?" · 仅子项":"");return '<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:9px 0;margin:0"><span><b>'+esc(r.dir)+'</b><br><span class="meta">'+esc(flags)+'</span></span><button class="danger" onclick="removeRule('+i+')">移除</button></div>'}).join(""):'<div class="empty" style="padding:18px">还没有共享目录权限</div>';$("rules").innerHTML=window._editRules.map((r,i)=>isHomeRule(r)?'':'<div class="row" style="justify-content:space-between;border-top:1px solid #edf0f5;padding:8px 0"><span><b>'+esc(r.dir)+'</b> · '+esc((r.deny?"拒绝":"允许")+" · "+(r.mask||"禁止")+(r.childrenOnly?" · 仅子项":""))+'</span><button class="danger" onclick="removeRule('+i+')">移除</button></div>').join("")}
|
||
async function saveUser(u){try{await api("/chfs/users",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:u,enabled:$("ue").checked,rules:window._editRules})});showUsers()}catch(e){fail(e.message)}}
|
||
async function deleteUser(u){if(!await confirmBox("删除用户","删除用户 "+u+"?用户目录不会自动删除。"))return;try{await api("/chfs/users?username="+encodeURIComponent(u),{method:"DELETE"});showUsers()}catch(e){fail(e.message)}}
|
||
function toggleTheme(){const dark=document.documentElement.classList.toggle("dark");localStorage.setItem("chfs-theme",dark?"dark":"light");renderChromeIcons()}function renderChromeIcons(){const dark=document.documentElement.classList.contains("dark");$("themeIcon").innerHTML='<svg class="ui-icon" viewBox="0 0 24 24">'+(dark?'<path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5z"/>':'<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>')+'</svg>';$('listIcon').innerHTML='<svg class="ui-icon" viewBox="0 0 24 24"><path d="M4 6h2M9 6h11M4 12h2M9 12h11M4 18h2M9 18h11"/></svg>';$('gridIcon').innerHTML='<svg class="ui-icon" viewBox="0 0 24 24"><rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/><rect x="4" y="14" width="6" height="6" rx="1"/><rect x="14" y="14" width="6" height="6" rx="1"/></svg>'}
|
||
if(localStorage.getItem("chfs-theme")==="dark"||(localStorage.getItem("chfs-theme")===null&&matchMedia("(prefers-color-scheme: dark)").matches))document.documentElement.classList.add("dark");
|
||
setView(localStorage.getItem("chfs-view")||"list");renderChromeIcons();setupDropZone();init();
|
||
</script>
|
||
</body></html>
|