227 lines
94 KiB
HTML
227 lines
94 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>
|
||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%232563eb'/%3E%3Cpath d='M17 18h30v7H24v7h18v7H24v7h23v7H17z' fill='white'/%3E%3C/svg%3E">
|
||
<style>
|
||
:root{font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f5f7fb}
|
||
*{box-sizing:border-box}[hidden]{display:none!important}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-stage-touch{touch-action:pan-y;user-select:none}.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 .name>span:last-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.file-row .action-col{width:54px}.file-row .actions{min-width:38px}.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{position:relative;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}.editor-wrap{width:min(1000px,100%);height:70vh;display:grid;grid-template-columns:auto minmax(0,1fr);background:#111821;border:1px solid #273243;border-radius:10px;overflow:hidden}.editor-lines{margin:0;padding:16px 12px;text-align:right;user-select:none;color:#617086;background:#0d131b;border-right:1px solid #273243;font:13px/1.6 ui-monospace,SFMono-Regular,monospace;overflow:hidden}.editor-input{width:100%;height:100%;resize:none;border:0;outline:0;padding:16px;background:#111821;color:#dce5f2;font:13px/1.6 ui-monospace,SFMono-Regular,monospace;tab-size:2}.editor-status{display:flex;align-items:center;gap:8px;margin-right:auto}.editor-dirty{color:var(--primary);font-weight:600}.media-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.media-toolbar label{font-size:12px;color:var(--muted)}.media-toolbar select{padding:7px 9px;border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text)}
|
||
.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){table{table-layout:fixed}.check-col{width:42px}.action-col{width:52px}.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:0 0 100%;min-width:0}.toolbar .toolgroup:nth-of-type(2){order:2;flex:0 0 100%;flex-wrap:nowrap;overflow-x:auto;justify-content:flex-start;scrollbar-width:none}.toolbar .toolgroup:nth-of-type(2)::-webkit-scrollbar{display:none}.toolbar .toolgroup:nth-of-type(2)>button,.toolbar .toolgroup:nth-of-type(2)>label{flex:0 0 auto}.toolbar .toolgroup:last-child{order:4;flex:0 0 100%;margin-left:0;justify-content:flex-end}.toolbar button{padding:8px 10px}.file-row td{padding:10px 9px}.file-row td:first-child{width:42px}.file-row td:last-child{width:52px;padding-left:5px;padding-right:5px}.file-row .actions{opacity:1}.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)}
|
||
/* 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)}.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;touch-action:manipulation}.tile .selectbox,.file-row .selectbox{touch-action:manipulation}.side-item{touch-action:manipulation}.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}.sidebar{scrollbar-width:none}.sidebar::-webkit-scrollbar{display:none}}@media(max-width:700px){main{margin:12px auto;padding:0 10px}.top{height:56px}.brand{font-size:18px}.path{max-width:46vw}.dialog{padding:16px;border-radius:14px}.dialog.preview-dialog{width:100%;height:calc(100vh - 12px);max-height:calc(100vh - 12px);border-radius:14px}.preview-stage{padding:14px;max-height:calc(100vh - 118px)}.preview-stage img{max-height:64vh}.preview-stage video{max-height:64vh}.gallery-nav{width:38px;height:38px}.gallery-prev{left:8px}.gallery-next{right:8px}.gallery-stage-touch{padding-left:8px;padding-right:8px}.editor-wrap{height:calc(100vh - 190px)}.editor-input,.editor-lines{font-size:12px}.grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:9px;padding:10px}.tile{min-height:164px;padding:9px;border-radius:13px}.tile .thumb{height:86px;margin-bottom:8px}.tile .tile-meta{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile .tile-meta span+span{display:none}.tile .tile-check,.tile .tile-action{opacity:1}.bulk{bottom:8px}.toast-host{right:10px;bottom:10px}.toast{min-width:0;width:calc(100vw - 20px)}.row{flex-wrap:wrap}.row button{min-height:40px}.context{min-width:190px}.context button{padding:11px 10px}}.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="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="secondary" onclick="showRoots()">位置</button></div>
|
||
<span class="divider"></span>
|
||
<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><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 id="selectAll" 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,{credentials:"same-origin",cache:"no-store",...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 attrJSON(v){return esc(JSON.stringify(v))}
|
||
function effectiveMask(f){if(!f)return me?.username?currentMask:(currentMask||"");return String(me?.username?f.mask:(f.guestMask??f.mask)||"")}
|
||
function can(action,f){const m=effectiveMask(f);if(action==="read"||action==="download"||action==="share")return m.includes("R");if(action==="write"||action==="rename"||action==="move"||action==="copy")return m.includes("W");if(action==="delete")return m.includes("D");return false}
|
||
function canTargetWrite(f){return !!f?.dir&&can("write",f)}
|
||
function canSelection(action){if(!me?.username||!selected.size)return false;const source=searchMode?searchItems:lastItems;return [...selected].every(p=>{const f=source.find(x=>(searchMode?searchPath(x):(current?current+"/":"")+x.name)===p);return can(action,f)})}
|
||
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=can("write");$("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(!me?.username)return;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}if(searchMode){searchItems=sortItems(searchItems);renderSearch(searchItems);return}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=can("read",f),canWrite=can("write",f),canDelete=can("delete",f),selectedNow=selected.has(p),drag=' draggable="true"';const perms='<span class="perm-dot '+(canRead?'read':'')+'"></span><span class="perm-dot '+(canWrite?'write':'')+'"></span><span class="perm-dot '+(canDelete?'delete':'')+'"></span>';return '<tr class="file-row '+(selectedNow?'selected':'')+'"'+drag+' data-file-path="'+esc(p)+'" onclick="handleItemClick(event,'+attrJSON(p)+')" ondblclick="activateItem('+attrJSON(p)+')" ondragstart="fileDragStart(event,'+attrJSON(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+attrJSON(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+attrJSON(p)+')"><td><input class="selectbox row-select" type="checkbox" '+(me?.username?(selectedNow?"checked":""):'')+' '+(!me?.username?'hidden':'')+' onclick="event.stopPropagation()" onchange="selectItem('+attrJSON(p)+',this.checked,event)"></td><td><div class="name"><span class="file-kind">'+iconFor(f)+'</span><span>'+esc(f.name)+'</span></div></td><td class="size-cell">'+(f.dir?"—":fmt(f.size))+'</td><td class="time-cell">'+esc(f.modified||"")+'</td><td class="perm-cell">'+perms+'</td><td><div class="actions"><button class="iconbtn" onclick="event.stopPropagation();itemMenu(event,'+attrJSON(p)+')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button></div></td></tr>'}).join(""):'<tr><td colspan="6">'+emptyState("这个文件夹还是空的","可以上传文件,或者创建一个新的文件夹。","upload")+'</td></tr>'}
|
||
function renderGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),type=fileType(f),selectedNow=selected.has(p),drag=' draggable="true"';let thumb=iconFor(f);if(type==="image"&&canRead)thumb='<img src="/chfs/thumbnail?file='+encodeURIComponent(p)+'" loading="lazy" alt="">';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');const meta=f.dir?'文件夹':fmt(f.size);return '<div class="tile '+tileClass+(selectedNow?' selected':'')+'"'+drag+' data-file-path="'+esc(p)+'" onclick="handleItemClick(event,'+attrJSON(p)+')" ondblclick="activateItem('+attrJSON(p)+')" oncontextmenu="itemMenu(event,'+attrJSON(p)+');return false" ondragstart="fileDragStart(event,'+attrJSON(p)+')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,'+attrJSON(p)+')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,'+attrJSON(p)+')"><input class="selectbox tile-check" type="checkbox" '+(me?.username?(selectedNow?'checked':''):'')+' '+(!me?.username?'hidden':'')+' onclick="event.stopPropagation();selectItem('+attrJSON(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,'+attrJSON(p)+')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button><div class="thumb">'+thumb+'</div><div class="tile-title">'+iconFor(f)+'<div class="file-name">'+esc(f.name)+'</div></div><div class="tile-meta"><span>'+meta+'</span>'+(f.modified?'<span>'+esc(f.modified)+'</span>':'')+'</div></div>'}).join(''):emptyState("这个文件夹还是空的","上传文件或创建内容后,它们会显示在这里。","upload")}
|
||
function fileDragStart(e,p){const paths=selected.has(p)?[...selected]:[p];e.dataTransfer.effectAllowed="move";e.dataTransfer.setData("text/chfs-paths",JSON.stringify(paths));e.currentTarget.classList.add("dragging")}
|
||
function fileDragEnd(e){e.currentTarget.classList.remove("dragging");document.querySelectorAll('.drop-target').forEach(el=>el.classList.remove('drop-target'))}
|
||
function folderDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!canTargetWrite(f))return;e.preventDefault();if(target===current||target===""||[...selected].some(p=>p===target||target.startsWith(p+'/')))return;e.dataTransfer.dropEffect="move";e.currentTarget.classList.add('drop-target')}
|
||
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,'');await runOperation('移动 '+paths.length+' 项',async()=>{let done=0,failed=[],renamed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop());done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('<h2>移动完成</h2><div class="notice">'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else toast('已移动 '+done+' 项')})}
|
||
function handleItemClick(e,p){e.preventDefault();if(!me.username){guestItemActions(p);return}activateItem(p)}
|
||
function activateItem(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&can("read",f))openDir(p);else if(can("read",f))previewFile(p)}
|
||
function sharedURL(p){return new URL("/chfs/shared/"+p.split("/").map(encodeURIComponent).join("/"),location.href).href}
|
||
function guestItemActions(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f||!can("read",f)){toast("当前项目没有访问权限",true);return}if(f.dir){openDir(p);return}const url=sharedURL(p);let html='<h2>'+esc(f.name)+'</h2><div class="notice">访客可以直接下载或复制分享链接。</div><div class="row" style="justify-content:flex-start"><button onclick="location.href='+attrJSON(sharedURL(p))+'">下载</button><button class="ghost" onclick="guestCopyShare('+attrJSON(url)+')">复制分享链接</button></div>';show(html)}
|
||
async function guestCopyShare(url){try{await navigator.clipboard.writeText(url);toast("分享链接已复制");closeModal()}catch{show('<h2>分享链接</h2><div class="field"><input value="'+esc(url)+'" readonly></div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>')}}
|
||
function toggleTile(p){selectItem(p,!selected.has(p))}
|
||
function openSelected(){if(selected.size!==1)return;const p=[...selected][0];if(searchMode){const f=searchItems.find(x=>x.path===p);if(f)openSearchResult(f);return}const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&can("read",f))openDir(p);else if(can("read",f))previewFile(p)}
|
||
function toggleAll(v){if(!me?.username)return;const source=searchMode?searchItems:lastItems;source.forEach(f=>{const p=searchMode?f.path:(current?current+"/":"")+f.name;if(v)selected.add(p);else selected.delete(p)});searchMode?updateSelection():render(lastItems)}
|
||
function clearSelection(){selected.clear();updateSelection();if(searchMode){renderSearch(searchItems);return}renderGrid(lastItems);renderList(lastItems)}
|
||
function updateSelection(){const isGuest=!me?.username;if(isGuest)selected.clear();const n=selected.size;$("bulk").classList.toggle("show",n>0&&!isGuest);$("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=$("selectAll");if(header){header.hidden=isGuest;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"}
|
||
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="";
|
||
async function chooseDestination(title){destinationPath=current;return await new Promise(async resolve=>{destinationResolve=resolve;try{const d=await api("/chfs/roots"),roots=d.roots||[];const writableCurrent=currentMask.includes("W");if(!writableCurrent){const first=roots.find(r=>String(r.mask||"").includes("W"));if(first)destinationPath=first.path||""}await renderDestinationPicker(title)}catch(e){destinationResolve=null;fail("无法打开目标目录:"+e.message);resolve(null)}})}
|
||
async function renderDestinationPicker(title){const data=await api("/chfs/files?filepath="+encodeURIComponent(destinationPath));const dirs=(data.files||[]).filter(f=>canTargetWrite(f));const parent=destinationPath?destinationPath.split("/").slice(0,-1).join("/"):null;let html='<h2>'+esc(title)+'</h2><div class="notice">当前目标:<b>'+esc(destinationPath||"全部文件")+'</b></div><div class="field" style="max-height:42vh;overflow:auto">';if(parent!==null)html+='<button class="ghost" style="display:block;width:100%;text-align:left;margin:5px 0" onclick="destinationPath='+attrJSON(parent)+';renderDestinationPicker('+attrJSON(title)+')">上一级</button>';for(const d of dirs){const p=destinationPath?(destinationPath+"/"+d.name):d.name;html+='<button class="ghost" style="display:block;width:100%;text-align:left;margin:5px 0" onclick="destinationPath='+attrJSON(p)+';renderDestinationPicker('+attrJSON(title)+')">'+svgIcon("folder")+' '+esc(d.name)+'</button>'}if(!dirs.length)html+='<div class="meta" style="padding:10px">这里没有可写入的子目录。</div>';html+='</div><div class="row"><button class="ghost" onclick="resolveDestination(null)">取消</button><button onclick="resolveDestination('+attrJSON(destinationPath)+')">选择此目录</button></div>';show(html)}
|
||
function resolveDestination(path){const resolve=destinationResolve;destinationResolve=null;closeModal();if(resolve)resolve(path)}
|
||
let operationBusy=false;
|
||
async function runOperation(label,fn){if(operationBusy)return null;operationBusy=true;toast(label+"…");try{return await fn()}finally{operationBusy=false}}
|
||
function destinationName(name,index){const dot=name.lastIndexOf("."),base=dot>0?name.slice(0,dot):name,ext=dot>0?name.slice(dot):"";return base+" ("+index+")"+ext}
|
||
async function transferWithConflict(source,destination,copyOnly){if(source===destination){if(!copyOnly)return destination;const slash=destination.lastIndexOf("/"),dir=slash>=0?destination.slice(0,slash):"",name=slash>=0?destination.slice(slash+1):destination;destination=dir?dir+"/"+destinationName(name,2):destinationName(name,2)}if(destination.startsWith(source+"/")){const e=Error("不能移动或复制到自身或子目录");e.status=400;throw e}const endpoint=copyOnly?"/chfs/copy":"/chfs/move";try{await api(endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination})});return destination}catch(e){if(e.status!==400)throw e;const slash=destination.lastIndexOf("/"),dir=slash>=0?destination.slice(0,slash):"",name=slash>=0?destination.slice(slash+1):destination;for(let i=2;i<=20;i++){const candidate=dir?dir+"/"+destinationName(name,i):destinationName(name,i);try{await api(endpoint,{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({source,destination:candidate})});return candidate}catch(err){if(err.status!==400)throw err}}throw e}}
|
||
async function transferItem(source,copyOnly){const dstDir=await chooseDestination(copyOnly?"复制到":"移动到");if(dstDir===null)return;const name=source.split("/").pop(),base=String(dstDir).replace(/^\/+|\/+$/g,""),destination=base?(base+"/"+name):name,action=copyOnly?"复制":"移动";await runOperation(action+"中",async()=>{try{const actual=await transferWithConflict(source,destination,copyOnly);await refresh();toast(actual===destination?action+"完成":action+"完成,已自动重命名为 "+actual.split("/").pop())}catch(e){fail(action+"失败:"+e.message)}})}
|
||
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?"复制":"移动";await runOperation(action+" "+sources.length+" 项",async()=>{const failed=[],renamed=[];let done=0;for(const source of sources){const name=source.split("/").pop(),destination=base?(base+"/"+name):name;try{const actual=await transferWithConflict(source,destination,copyOnly);done++;if(actual!==destination)renamed.push(name+" → "+actual.split("/").pop())}catch(e){failed.push(name+": "+e.message)}}selected.clear();await refresh();if(failed.length||renamed.length){show('<h2>'+action+'完成</h2><div class="notice">'+esc(action+"成功 "+done+" 项,失败 "+failed.length+" 项"+(renamed.length?"。\n\n自动重命名:\n"+renamed.join("\n"):"。")+(failed.length?"。\n\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;const paths=[...selected];if(!await confirmBox("确认删除",paths.length===1?"确定删除 "+paths[0]+"?":"确定删除这 "+paths.length+" 项?删除文件夹时会同时删除其中内容。"))return;await runOperation("删除中",async()=>{try{const body=new URLSearchParams();paths.forEach(p=>body.append("filepath",p));await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body});selected.clear();await refresh();toast(paths.length===1?"已删除":"已删除 "+paths.length+" 项")}catch(e){fail("删除失败:"+e.message)}})}
|
||
|
||
|
||
async function loadSideRoots(){try{const d=await api("/chfs/roots"),roots=d.roots||[];$("sideRoots").innerHTML=roots.map(r=>{const writable=!!me?.username&&String(r.mask||"").includes("W");return '<button class="side-item '+(r.path===current?'active':'')+'" data-root-path="'+esc(r.path)+'" onclick="jumpRoot('+attrJSON(r.path)+')" ondragover="sideDragOver(event,'+attrJSON(r.path)+','+writable+')" ondragleave="sideDragLeave(event)" ondrop="sideDrop(event,'+attrJSON(r.path)+','+writable+')">'+svgIcon('folder')+' '+esc(r.name)+'</button>'}).join('')||'<div class="meta" style="padding:8px">当前账号没有其他位置</div>'}catch(e){$("sideRoots").innerHTML='<div class="meta" style="padding:8px">位置加载失败</div>'}}
|
||
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('+attrJSON(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,writable){if(!e.dataTransfer?.types.includes('text/chfs-paths')||!writable)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,writable){if(!e.dataTransfer?.types.includes('text/chfs-paths')||!writable)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;await runOperation('正在移动 '+paths.length+' 项',async()=>{const failed=[],renamed=[];let done=0;for(const source of paths){const name=source.split('/').pop(),destination=target?target+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);done++;if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop())}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('<h2>移动完成</h2><div class="notice">'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>');else 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('+attrJSON(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('+attrJSON(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){const f=searchMode?searchItems.find(x=>searchPath(x)===p):lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!can("download",f))return toast("当前项目没有下载权限",true);location.href=sharedURL(p)}
|
||
function downloadDir(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!can("download",f))return toast("当前项目没有下载权限",true);location.href="/chfs/downloaddir/"+encodeURI(p)}
|
||
async function shareItem(p){const url=sharedURL(p);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(),n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");await runOperation("正在重命名",async()=>{try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});selected.clear();await refresh();toast("已重命名")}catch(e){if(e.status===400){const retry=await ask("目标已存在","请输入新的名称",n);if(retry&&retry!==n){try{const retryDest=(p.split("/").slice(0,-1).concat(retry)).join("/");await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:retryDest})});selected.clear();await refresh();toast("已重命名")}catch(err){fail("重命名失败:"+err.message)}}}else fail("重命名失败:"+e.message)}})}
|
||
async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;await runOperation("正在删除",async()=>{try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});selected.delete(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)}}
|
||
$("uploadBtn").addEventListener("click",e=>{e.preventDefault();if($("uploadBtn").disabled){toast("当前位置没有上传权限",true);return}$("upload").click()});$("upload").addEventListener("change",async e=>{await uploadFiles(e.target.files);e.target.value=""})
|
||
async function uploadFiles(files){if(!can("write")){toast("当前位置没有上传权限",true);return} const list=[...files].map(file=>({file,relativePath:file.webkitRelativePath||file.name}));if(!list.length)return;await uploadEntries(list)}
|
||
async function uploadEntries(entries){if(!can("write")){toast("当前位置没有上传权限",true);return} 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('<div class="preview-head"><span class="preview-title">上传文件</span><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 style="padding:0 22px 22px"><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></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=[];selected.clear();refresh();return}try{const d=await api("/chfs/search?str="+encodeURIComponent(s));renderSearch(d.files||[])}catch(x){fail(x.message)}})
|
||
function searchPath(f){return f.path||f.name||""}
|
||
function searchName(f){const p=searchPath(f);return f.name||p.split("/").pop()}
|
||
function searchParent(f){const p=searchPath(f);return p.includes("/")?p.slice(0,p.lastIndexOf("/")):"全部文件"}
|
||
function searchCan(f,mask){if(mask==="R")return can("read",f);if(mask==="W")return can("write",f);if(mask==="D")return can("delete",f);return false}
|
||
function renderSearch(items){searchMode=true;searchItems=sortItems(items);selected=new Set([...selected].filter(p=>searchItems.some(f=>searchPath(f)===p)));$("crumb").textContent="搜索结果";$("files").parentElement.hidden=localStorage.getItem("chfs-view")==="grid";$("grid").hidden=localStorage.getItem("chfs-view")!=="grid";renderSearchList(searchItems);renderSearchGrid(searchItems);updateSelection();renderSortState()}
|
||
function renderSearchList(items){const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),canWrite=searchCan(f,"W"),canDelete=searchCan(f,"D"),selectedNow=selected.has(p),perms='<span class="perm-dot '+(canRead?'read':'')+'"></span><span class="perm-dot '+(canWrite?'write':'')+'"></span><span class="perm-dot '+(canDelete?'delete':'')+'"></span>';return '<tr class="file-row '+(selectedNow?'selected':'')+'" data-search-path="'+esc(p)+'" onclick="selectSearchItem('+attrJSON(p)+',true,event)" ondblclick="openSearchResult('+attrJSON(f)+')" oncontextmenu="searchMenu(event,'+attrJSON(f)+');return false"><td><input class="selectbox row-select" type="checkbox" '+(me?.username?(selectedNow?'checked':''):'')+' '+(!me?.username?'hidden':'')+' onclick="event.stopPropagation()" onchange="selectSearchItem('+attrJSON(p)+',this.checked,event)"></td><td><div class="name"><span class="file-kind">'+iconFor(f)+'</span><span>'+esc(name)+'</span></div></td><td class="size-cell">'+(f.dir?"—":fmt(f.size))+'</td><td class="time-cell"><span>'+esc(f.modified||"")+'</span><div class="meta">'+esc(searchParent(f))+'</div></td><td class="perm-cell"><span class="perm-dot '+(canRead?'read':'')+'"></span><span class="perm-dot '+(canWrite?'write':'')+'"></span><span class="perm-dot '+(canDelete?'delete':'')+'"></span></td><td><div class="actions"><button class="iconbtn" onclick="event.stopPropagation();searchMenu(event,'+attrJSON(f)+')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button></div></td></tr>'}).join(""):'<tr><td colspan="6">'+emptyState("没有找到匹配文件","换一个关键词试试,或者清空搜索框返回当前位置。")+'</td></tr>'}
|
||
function renderSearchGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),type=fileType(f),selectedNow=selected.has(p);let thumb=iconFor(f);if(type==="image"&&canRead)thumb='<img src="/chfs/thumbnail?file='+encodeURIComponent(p)+'" loading="lazy" alt="">';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');return '<div class="tile '+tileClass+(selectedNow?' selected':'')+'" data-file-path="'+esc(p)+'" onclick="selectSearchItem('+attrJSON(p)+',true,event)" ondblclick="openSearchResult('+attrJSON(f)+')" oncontextmenu="searchMenu(event,'+attrJSON(f)+');return false"><input class="selectbox tile-check" type="checkbox" '+(me?.username?(selectedNow?'checked':''):'')+' '+(!me?.username?'hidden':'')+' onclick="event.stopPropagation();selectSearchItem('+attrJSON(p)+',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();searchMenu(event,'+attrJSON(f)+')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button><div class="thumb">'+thumb+'</div><div class="tile-title">'+iconFor(f)+'<div class="file-name">'+esc(name)+'</div></div><div class="tile-meta"><span>'+esc(searchParent(f)||"全部文件")+'</span>'+(f.modified?'<span>'+esc(f.modified)+'</span>':'')+'</div></div>'}).join(""):'<div class="empty-state search-empty"><div class="empty-icon">'+svgIcon("file")+'</div><h3>没有找到匹配文件</h3><p>换一个关键词试试,或者清空搜索框返回当前位置。</p></div>'}
|
||
function selectSearchItem(p,checked,e){if(!me?.username)return;if(checked){if(e?.shiftKey){const paths=searchItems.map(searchPath),anchor=selectionAnchor&&paths.includes(selectionAnchor)?selectionAnchor:[...selected][0],a=paths.indexOf(anchor),b=paths.indexOf(p);if(a>=0&&b>=0){const lo=Math.min(a,b),hi=Math.max(a,b);if(!(e.ctrlKey||e.metaKey))selected.clear();for(let i=lo;i<=hi;i++)selected.add(paths[i])}else selected.add(p)}else if(e?.ctrlKey||e?.metaKey)selected.add(p);else{selected.clear();selected.add(p)}selectionAnchor=p}else{selected.delete(p);selectionAnchor=p}updateSelection();renderSearch(searchItems)}
|
||
function toggleSearchItem(p,checked,e){selectSearchItem(p,checked,e)}
|
||
function searchMenu(e,f){e.preventDefault();e.stopPropagation();const p=searchPath(f);if(!selected.has(p)){selected.clear();selected.add(p)}updateSelection();let html='';if(!me?.username){if(can("read",f))html+='<button onclick="downloadFile('+attrJSON(p)+');closeContext()">下载</button><button onclick="shareItem('+attrJSON(p)+');closeContext()">分享</button>'}else{const canRead=can("read",f),canWrite=can("write",f),canDelete=can("delete",f);if(canRead)html+='<button onclick="downloadFile('+attrJSON(p)+');closeContext()">下载</button>';if(f.dir&&canRead)html+='<button onclick="downloadDir('+attrJSON(p)+');closeContext()">下载 ZIP</button>';if(canWrite)html+='<button onclick="renameItem('+attrJSON(p)+');closeContext()">重命名</button><button onclick="transferItem('+attrJSON(p)+',false);closeContext()">移动到…</button><button onclick="transferItem('+attrJSON(p)+',true);closeContext()">复制到…</button>';if(canDelete)html+='<button onclick="removeItem('+attrJSON(p)+');closeContext()">删除</button>';if(f.guestMask?.includes("R"))html+='<button onclick="shareItem('+attrJSON(p)+');closeContext()">分享</button>'}if(!html)html='<div class="meta" style="padding:8px">没有可用操作</div>';$("context").innerHTML=html;$("context").style.left=Math.min(e.clientX,innerWidth-190)+"px";$("context").style.top=Math.min(e.clientY,innerHeight-220)+"px";$("context").style.display="block"}
|
||
function openSearchResult(f){if(f.dir){current=f.path;navigationRoot=me.home&&f.path.startsWith(me.home)?me.home:f.path.split("/")[0]||"";searchMode=false;$("search").value="";selected.clear();refresh()}else previewFile(f.path)}
|
||
let galleryFiles=[],galleryIndex=0,activeMedia=null;
|
||
async function openGallery(p){const dir=dirOf(p);let items=dir===current?lastItems:null;if(!items){try{const d=await api("/chfs/files?filepath="+encodeURIComponent(dir));items=d.files||[]}catch(e){return fail("无法加载图片目录:"+e.message)}}galleryFiles=items.filter(f=>!f.dir&&fileType(f)==="image"&&(f.mask||"").includes("R")).map(f=>({...f,path:f.path||(dir?(dir+"/"+f.name):f.name)}));galleryIndex=Math.max(0,galleryFiles.findIndex(f=>f.path===p));renderGallery()}
|
||
function previewFilesFor(p){const dir=dirOf(p),items=dir===current?lastItems:searchItems;return items.filter(f=>!f.dir&&fileType(f)==="image"&&(f.mask||"").includes("R")).map(f=>({...f,path:f.path||(dir?(dir+"/"+f.name):f.name)}))}
|
||
function galleryOpen(p){return openGallery(p)}
|
||
function dirOf(p){return String(p||'').split('/').slice(0,-1).join('/')}
|
||
function galleryMove(delta){if(!galleryFiles.length)return;galleryIndex=(galleryIndex+delta+galleryFiles.length)%galleryFiles.length;const f=galleryFiles[galleryIndex],path=f.path||(current?current+'/'+f.name:f.name);renderGallery(path)}
|
||
function preloadGallery(){for(const offset of [-1,1]){const f=galleryFiles[(galleryIndex+offset+galleryFiles.length)%galleryFiles.length];if(f){const img=new Image();img.src="/chfs/thumbnail?file="+encodeURIComponent(f.path)}}}
|
||
let galleryTouchX=0;function galleryTouchStart(e){galleryTouchX=e.changedTouches[0]?.clientX||0}function galleryTouchEnd(e){const x=e.changedTouches[0]?.clientX||0,d=x-galleryTouchX;if(Math.abs(d)>45)galleryMove(d<0?1:-1)}
|
||
function renderGallery(path){const f=galleryFiles[galleryIndex];if(!f)return;const p=path||((f.path)||((current?current+"/":"")+f.name)),n=f.name,src="/chfs/preview?file="+encodeURIComponent(p);preloadGallery();show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span><span class="meta">'+(galleryIndex+1)+' / '+galleryFiles.length+'</span><button class="iconbtn" onclick="location.href='+attrJSON(sharedURL(p))+'" title="下载">'+svgIcon("file")+'</button><button class="iconbtn" onclick="closeModal()" title="关闭"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><div class="preview-stage gallery-stage-touch" id="galleryStage"><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);const stage=$("galleryStage");stage?.addEventListener("touchstart",galleryTouchStart,{passive:true});stage?.addEventListener("touchend",galleryTouchEnd,{passive:true})}
|
||
function mediaKeydown(e){const mediaActive=!!activeMedia&&($('modal').classList.contains('show')||$('miniPlayer'));if(mediaActive){if(e.key===' '){e.preventDefault();activeMedia.paused?activeMedia.play():activeMedia.pause()}else if(e.key==='ArrowLeft'||e.key==='ArrowRight'){e.preventDefault();activeMedia.currentTime=Math.max(0,activeMedia.currentTime+(e.key==='ArrowLeft'?-5:5))}else if((e.key==='['||e.key===']')&&activeMedia.playbackRate){e.preventDefault();activeMedia.playbackRate=Math.max(.25,Math.min(2,activeMedia.playbackRate+(e.key==='['?-.25:.25)));toast("播放速度 "+activeMedia.playbackRate+"x")}}}
|
||
function setPlaybackRate(v){if(activeMedia){activeMedia.playbackRate=Number(v);toast("播放速度 "+v+"x")}}
|
||
addEventListener('keydown',mediaKeydown);
|
||
function editorLines(text){return Array.from({length:Math.max(1,text.split("\n").length)},(_,i)=>i+1).join("\n")}
|
||
function markEditorDirty(){const el=$("editorDirty");if(el)el.textContent="未保存";window._editorDirty=true}
|
||
function syncEditorLines(){const input=$("editText"),lines=$("editorLines");if(!input||!lines)return;lines.textContent=editorLines(input.value);lines.scrollTop=input.scrollTop}
|
||
function editText(p,content){window._editorDirty=false;show('<div class="preview-head"><span class="preview-title">编辑 · '+esc(p.split('/').pop())+'</span><span id="editorDirty" class="meta">已保存</span><button class="iconbtn" onclick="saveTextEdit('+attrJSON(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"><div class="editor-wrap"><pre id="editorLines" class="editor-lines">1</pre><textarea id="editText" class="editor-input" spellcheck="false" autocapitalize="off" autocomplete="off" autocorrect="off">'+esc(content)+'</textarea></div></div><div class="preview-foot"><div class="editor-status"><span class="meta">Tab 缩进 · Ctrl/Cmd + S 保存</span></div><button class="ghost" onclick="closeModal()">取消</button><button onclick="saveTextEdit('+attrJSON(p)+')">保存</button></div>',true);const input=$("editText");input.focus();syncEditorLines();input.addEventListener("input",()=>{markEditorDirty();syncEditorLines()});input.addEventListener("scroll",syncEditorLines);input.addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="s"){e.preventDefault();saveTextEdit(p);return}if(e.key==="Tab"){e.preventDefault();const a=input.selectionStart,b=input.selectionEnd,inputValue=input.value;if(e.shiftKey){const lineStart=inputValue.lastIndexOf("\n",a-1)+1;if(inputValue.slice(lineStart,lineStart+2)===" "){input.setSelectionRange(lineStart,lineStart+2);document.execCommand("delete");syncEditorLines()}}else{input.setRangeText(" ",a,b,"end");markEditorDirty();syncEditorLines()}}});}
|
||
async function saveTextEdit(p){const input=$("editText");const text=input?.value;if(text===undefined)return;const parts=p.split("/"),filename=parts.pop()||"",filepath=parts.join("/");const status=$("editorDirty");if(status)status.textContent="保存中…";try{await api("/chfs/updatetext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath,filename,text})});window._editorDirty=false;if(status)status.textContent="已保存";window._previewText=text;toast("已保存");}catch(e){if(status)status.textContent="保存失败";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){return openGallery(p)}let content='';if(media){show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span><div class="media-toolbar"><label>速度</label><select onchange="setPlaybackRate(this.value)"><option value="0.5">0.5x</option><option value="0.75">0.75x</option><option value="1" selected>1x</option><option value="1.25">1.25x</option><option value="1.5">1.5x</option><option value="2">2x</option></select></div><button class="ghost" onclick="togglePiP()" title="画中画">画中画</button><button class="iconbtn" onclick="location.href='+attrJSON(sharedURL(p))+'" title="下载">'+svgIcon('file')+'</button><button class="iconbtn" onclick="closeModal()" title="关闭"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><div class="preview-stage 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='+attrJSON(sharedURL(p))+'" title="下载">'+svgIcon('file')+'</button><button class="iconbtn" onclick="closeModal()" title="关闭"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><div class="preview-stage"><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 targetFile=searchMode?searchItems.find(f=>searchPath(f)===p):lastItems.find(f=>(current?current+'/':'')+f.name===p),canEdit=can("write",targetFile);show('<div class="preview-head"><span class="preview-title">'+esc(n)+'</span>'+(canEdit?'<button class="ghost" onclick="editText('+attrJSON(p)+',window._previewText)" title="编辑">编辑</button>':'')+'<button class="iconbtn" onclick="location.href='+attrJSON(sharedURL(p))+'" title="下载">'+svgIcon('file')+'</button><button class="iconbtn" onclick="closeModal()" title="关闭"><svg class="ui-icon" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg></button></div><div class="preview-stage"><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",credentials:"same-origin",cache:"no-store",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(){try{await api("/chfs/session",{method:"DELETE"})}finally{me={username:"",home:"",admin:false,managed:false};current="";navigationRoot="";selected.clear();updateUser();await refresh()}}
|
||
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($("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"&¤t&&!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=>{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)}}
|
||
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('+attrJSON(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>
|