@@ -21,7 +21,7 @@ body{background:var(--bg);color:var(--text);transition:background .2s,color .2s}
button . ghost { background : var ( - - surface2 ) ; color : var ( - - text ) } button . danger { background : var ( - - danger ) } . meta , . path { color : var ( - - muted ) }
. 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 : 38 px ; height : 38 px ; padding : 0 ; background : var ( - - surface2 ) ; color : var ( - - text ) } . viewbtn . active { background : var ( - - primary ) ; color : #fff } . selectbox { width : 18 px ; height : 18 px ; accent-color : var ( - - primary ) }
. grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 170 px , 1 fr ) ) ; gap : 12 px ; padding : 14 px } . tile { position : relative ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; padding : 14 px ; background : var ( - - surface ) ; min-height : 145 px ; cursor : pointer } . tile : hover { border-color : var ( - - primary ) } . tile . selected { outline : 2 px solid var ( - - primary ) } . tile . thumb { height : 82 px ; display : flex ; align-items : center ; justify-content : center ; font-size : 42 px ; border-radius : 9 px ; background : var ( - - surface2 ) ; overflow : hidden ; margin-bottom : 9 px } . tile . thumb img { width : 100 % ; height : 100 % ; object-fit : cover } . progress { height : 8 px ; background : var ( - - surface2 ) ; border-radius : 99 px ; overflow : hidden } . progress i { display : block ; height : 100 % ; width : 0 ; background : var ( - - primary ) ; transition : width .15 s } . upload-row { display : grid ; gap : 7 px ; padding : 10 px 0 ; border-bottom : 1 px solid var ( - - border ) } . tile . tilecheck { position : absolute ; top : 9 px ; left : 9 px } . tile . tilemenu { position : absolute ; right : 7 px ; top : 7 px } . file-name { white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis } . bulk { display : none ; align-items : center ; gap : 8 px ; margin-bottom : 12 px } . bulk . show { display : flex } . bulk . count { font-weight : 650 ; margin-right : auto } . context { position : fixed ; z-index : 10 ; display : none ; min-width : 170 px ; background : var ( - - surface ) ; border : 1 px solid var ( - - border ) ; box-shadow : var ( - - shadow ) ; border-radius : 10 px ; padding : 5 px } . context button { display : block ; width : 100 % ; text-align : left ; background : transparent ; color : var ( - - text ) ; border-radius : 7 px } . context button : hover { background : var ( - - surface2 ) }
. grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 170 px , 1 fr ) ) ; gap : 12 px ; padding : 14 px } . tile { position : relative ; border : 1 px solid var ( - - border ) ; border-radius : 12 px ; padding : 14 px ; background : var ( - - surface ) ; min-height : 145 px ; cursor : pointer } . tile : hover { border-color : var ( - - primary ) } . tile . selected { outline : 2 px solid var ( - - primary ) } . tile . thumb { height : 82 px ; display : flex ; align-items : center ; justify-content : center ; font-size : 42 px ; border-radius : 9 px ; background : var ( - - surface2 ) ; overflow : hidden ; margin-bottom : 9 px } . tile . thumb img { width : 100 % ; height : 100 % ; object-fit : cover } . progress { height : 8 px ; background : var ( - - surface2 ) ; border-radius : 99 px ; overflow : hidden } . progress i { display : block ; height : 100 % ; width : 0 ; background : var ( - - primary ) ; transition : width .15 s } . upload-row { display : grid ; gap : 7 px ; padding : 10 px 0 ; border-bottom : 1 px solid var ( - - border ) } . drop-active { border-color : var ( - - primary ) !important ; background : var ( - - surface2 ) !important } . qr { width : min ( 320 px , 80 vw ) ; aspect-ratio : 1 ; margin : auto ; display : block ; border-radius : 12 px ; background : #fff } . drop-hint { color : var ( - - muted ) ; font-size : 13 px ; margin-top : 7 px } . tile . tilecheck { position : absolute ; top : 9 px ; left : 9 px } . tile . tilemenu { position : absolute ; right : 7 px ; top : 7 px } . file-name { white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis } . bulk { display : none ; align-items : center ; gap : 8 px ; margin-bottom : 12 px } . bulk . show { display : flex } . bulk . count { font-weight : 650 ; margin-right : auto } . context { position : fixed ; z-index : 10 ; display : none ; min-width : 170 px ; background : var ( - - surface ) ; border : 1 px solid var ( - - border ) ; box-shadow : var ( - - shadow ) ; border-radius : 10 px ; padding : 5 px } . context button { display : block ; width : 100 % ; text-align : left ; background : transparent ; color : var ( - - text ) ; border-radius : 7 px } . context button : hover { background : var ( - - surface2 ) }
< / style >
< / head >
< body >
@@ -31,11 +31,12 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
< button class = "ghost" id = "logoutBtn" onclick = "logout()" hidden > 退出< / button >
< / header >
< main >
< 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" >
< button onclick = "goUp()" > 上一级< / button > < button onclick = "refresh()" > 刷新< / button > < button class = "ghost" onclick = "showRoots()" > 位置< / button >
< button id = "newFolderBtn" onclick = "newFolder()" > 新建文件夹< / button > < button id = "newTextBtn" onclick = "newText()" > 新建文本< / button >
< label > < button id = "uploadBtn" > 上传< / button > < input id = "upload" type = "file" multiple hidden > < / label >
< label > < button id = "uploadBtn" > 上传< / button > < button class = "ghost" onclick = "showMobileTransfer()" > 手机传输 < / button > < input id = "upload" type = "file" multiple hidden > < / label >
< input id = "search" placeholder = "搜索文件名,回车执行" >
< button id = "usersBtn" onclick = "showUsers()" hidden > 用户管理< / button > < button class = "ghost" id = "pwdBtn" onclick = "changePassword()" hidden > 修改密码< / button > < button class = "iconbtn" id = "themeBtn" onclick = "toggleTheme()" title = "主题" > ◐< / button > < button class = "iconbtn viewbtn active" id = "listBtn" onclick = "setView(" list " ) " title = "列表" > ☷< / button > < button class = "iconbtn viewbtn" id = "gridBtn" onclick = "setView(" grid " ) " title = "网格" > ▦< / button >
< / div >
@@ -65,7 +66,7 @@ function setView(v){localStorage.setItem('chfs-view',v);$("files").parentElement
function itemMenu ( e , p ) { e . preventDefault ( ) ; const f = lastItems . find ( x => ( current ? current + '/' : '' ) + x . name === p ) ; const canRead = ( f ? . mask || '' ) . includes ( 'R' ) , canWrite = ( f ? . mask || '' ) . includes ( 'W' ) , canDelete = ( f ? . mask || '' ) . includes ( 'D' ) ; let html = '' ; if ( canRead ) html += '<button onclick="downloadFile(' + JSON . stringify ( p ) + ');closeContext()">下载</button>' ; if ( f ? . dir && canRead ) html += '<button onclick="downloadDir(' + JSON . stringify ( p ) + ');closeContext()">下载 ZIP</button>' ; if ( canWrite ) html += '<button onclick="renameItem(' + JSON . stringify ( p ) + ');closeContext()">重命名</button>' ; if ( canDelete ) html += '<button onclick="removeItem(' + JSON . stringify ( p ) + ');closeContext()">删除</button>' ; if ( f ? . guestMask ? . includes ( 'R' ) ) html += '<button onclick="shareItem(' + JSON . stringify ( p ) + ');closeContext()">分享</button>' ; if ( ! html ) html = '<div class="meta" style="padding:8px">没有可用操作</div>' ; $ ( "context" ) . innerHTML = html ; $ ( "context" ) . style . left = Math . min ( e . clientX , innerWidth - 190 ) + 'px' ; $ ( "context" ) . style . top = Math . min ( e . clientY , innerHeight - 220 ) + 'px' ; $ ( "context" ) . style . display = 'block' }
function closeContext ( ) { $ ( "context" ) . style . display = 'none' }
addEventListener ( 'click' , e => { if ( ! e . target . closest ( '#context' ) && ! e . target . closest ( '.tilemenu' ) ) closeContext ( ) } ) ;
function bulkDownload ( ) { if ( ! selected . size ) return ; const first = [ ... selected ] [ 0 ] ; if ( selected . size === 1 ) { downloadFile ( first ) ; return } location . href = '/chfs/downloaddir/ ' + encodeURI ( first . split ( '/' ) . slice ( 0 , - 1 ) . join ( '/' ) ) }
function bulkDownload ( ) { if ( ! selected . size ) return ; const q = [ ... selected ] . map ( p => 'path=' + encodeURIComponent ( p ) ) . join ( '&' ) ; location . href = '/chfs/downloadzip? ' + q }
async function bulkDelete ( ) { if ( ! selected . size ) return ; if ( ! await confirmBox ( "批量删除" , "确定删除已选择的 " + selected . size + " 项?" ) ) return ; for ( const p of [ ... selected ] ) { try { await api ( '/chfs/rmfiles' , { method : 'POST' , headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } , body : new URLSearchParams ( { filepath : p } ) } ) } catch ( e ) { alert ( p + ': ' + e . message ) } } selected . clear ( ) ; refresh ( ) }
async function showRoots ( ) { try { const d = await api ( "/chfs/roots" ) ; const roots = d . roots || [ ] ; let html = '<h2>选择位置</h2><div>' ; for ( const r of roots ) html += '<button class="ghost" style="display:block;width:100%;text-align:left;margin:8px 0" onclick="jumpRoot(' + JSON . stringify ( r . path ) + ')"><b>' + esc ( r . name ) + '</b><span class="meta"> · ' + esc ( r . path || "/" ) + ' · ' + esc ( r . mask ) + '</span></button>' ; if ( ! roots . length ) html += '<div class="empty">没有可进入的共享位置</div>' ; html += '</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>' ; show ( html ) } catch ( e ) { alert ( e . message ) } }
function jumpRoot ( p ) { current = p ; closeModal ( ) ; refresh ( ) }
@@ -77,7 +78,10 @@ async function renameItem(p){const name=p.split("/").pop();const n=await ask("
async function removeItem ( p ) { if ( ! await confirmBox ( "确认删除" , "确定删除 " + p + "? " ) ) return ; try { await api ( "/chfs/rmfiles" , { method : "POST" , headers : { "Content-Type" : "application/x-www-form-urlencoded" } , body : new URLSearchParams ( { filepath : p } ) } ) ; refresh ( ) } catch ( e ) { alert ( e . message ) } }
async function newFolder ( ) { const n = await ask ( "新建文件夹" , "文件夹名称" ) ; if ( ! n ) return ; const p = current ? current + "/" + n : n ; try { await api ( "/chfs/newdir" , { method : "POST" , headers : { "Content-Type" : "application/x-www-form-urlencoded" } , body : new URLSearchParams ( { filepath : p } ) } ) ; refresh ( ) } catch ( e ) { alert ( e . message ) } }
async function newText ( ) { const n = await ask ( "新建文本" , "文件名" , "new.txt" ) ; if ( ! n ) return ; const text = await ask ( "新建文本" , "文本内容" ) ; if ( text === null ) return ; try { await api ( "/chfs/newtext" , { method : "POST" , headers : { "Content-Type" : "application/x-www-form-urlencoded" } , body : new URLSearchParams ( { filepath : current , title : n , text : text || "" } ) } ) ; refresh ( ) } catch ( e ) { alert ( e . message ) } }
$ ( "upload" ) . addEventListener ( "change" , async e => { const files = [ ... e . target . files ] ; if ( ! files . length ) return ; show ( '<h2>上传文件</h2><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>' ) ; const box = $ ( "uploadList" ) ; for ( const file of files ) { const row = document . createElement ( "div" ) ; row . className = "upload-row" ; row . innerHTML = '<b>' + esc ( file . name ) + '</b><span class="meta">' + fmt ( file . size ) + ' · 准备上传</span><div class="progress"><i></i></div>' ; box . appendChild ( row ) ; await uploadOne ( file , row ) } e . target . value = "" ; refresh ( ) } )
$ ( "upload" ) . addEventListener ( "change" , async e => { await uploadFiles ( e . target . files ) ; e . target . value = "" } )
async function uploadFiles ( files ) { const list = [ ... files ] ; if ( ! list . length ) return ; show ( '<h2>上传文件</h2><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>' ) ; const box = $ ( "uploadList" ) ; for ( const file of list ) { const row = document . createElement ( "div" ) ; row . className = "upload-row" ; row . innerHTML = '<b>' + esc ( file . name ) + '</b><span class="meta">' + fmt ( file . size ) + ' · 准备上传</span><div class="progress"><i></i></div>' ; box . appendChild ( row ) ; await uploadOne ( file , row ) } refresh ( ) }
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 ? . files ? . length ) return ; e . preventDefault ( ) ; depth = 0 ; z . classList . remove ( 'drop-active' ) ; z . style . display = 'none' ; await uploadFiles ( e . dataTransfer . files ) } ) } ) }
function uploadOne ( file , row ) { return new Promise ( resolve => { const xhr = new XMLHttpRequest ( ) , fd = new FormData ( ) ; fd . append ( "folder" , current ) ; fd . append ( "file" , file ) ; 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 = ( ) => { if ( xhr . status >= 200 && xhr . status < 300 ) { bar . style . width = '100%' ; meta . textContent = fmt ( file . size ) + ' · 完成' } else { meta . textContent = '上传失败' ; bar . style . width = '0' } } ; xhr . onerror = ( ) => { meta . textContent = '网络错误' ; bar . style . width = '0' } ; xhr . onloadend = resolve ; xhr . send ( fd ) } ) }
$ ( "search" ) . addEventListener ( "keydown" , async e => { if ( e . key !== "Enter" ) return ; const s = e . target . value . trim ( ) ; if ( ! s ) { refresh ( ) ; return } try { const d = await api ( "/chfs/search?str=" + encodeURIComponent ( s ) ) ; renderSearch ( d . files || [ ] ) } catch ( x ) { alert ( x . message ) } } )
function renderSearch ( items ) { $ ( "crumb" ) . textContent = "搜索结果" ; const body = $ ( "files" ) ; body . innerHTML = "" ; for ( const f of items ) { const row = document . createElement ( "tr" ) ; row . innerHTML = '<td><div class="name">🔎 <a href="#" onclick="openDir(' + JSON . stringify ( f . dir ? f . path : f . path . split ( "/" ) . slice ( 0 , - 1 ) . join ( "/" ) ) . replace ( /^"/ , '\\"' ) + ');return false">' + esc ( f . path ) + '</a></div></td><td class="meta">' + ( f . dir ? "—" : fmt ( f . size ) ) + '</td><td class="meta">' + esc ( f . modified || "" ) + '</td><td></td>' ; body . appendChild ( row ) } }