@@ -46,7 +46,7 @@ button.ghost{background:var(--surface2);color:var(--text)}button.danger{backgrou
< div class = "modal" id = "modal" > < div class = "dialog" id = "dialog" > < / div > < / div >
< script >
const $ = id => document . getElementById ( id ) ; let current = "" , me = null , currentMask = "" , navigationRoot = "" ;
async function api ( url , opt = { } ) { const r = await fetch ( url , opt ) ; if ( ! r . ok ) { let t = "请求失败" ; try { t = ( await r . json ( ) ) . error || t } catch { } ; const e = Error ( t ) ; e . status = r . status ; throw e } return r . status === 204 ? null : r . json ( ) }
async function api ( url , opt = { } ) { const r = await fetch ( url , { 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 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 ( ) }
@@ -66,13 +66,14 @@ function sortBy(key){if(sortKey===key)sortDir*=-1;else{sortKey=key;sortDir=1}if(
function renderSortState ( ) { [ "name" , "size" , "modified" ] . forEach ( k => { const el = $ ( "sort-" + k ) ; if ( el ) el . textContent = sortKey === k ? ( sortDir === 1 ? "↑" : "↓" ) : "" } ) }
function emptyState ( title , text , action = "" ) { const writable = currentMask . includes ( "W" ) ; let buttons = "" ; if ( action === "folder" && writable ) buttons = '<div class="row"><button onclick="newFolder()">新建文件夹</button><button class="ghost" onclick="newText()">新建文本</button></div>' ; if ( action === "upload" && writable ) buttons = '<div class="row"><button onclick="$(\"upload\").click()">上传文件</button><button class="ghost" onclick="newFolder()">新建文件夹</button></div>' ; return '<div class="empty-state"><div class="empty-icon">' + svgIcon ( "folder" ) + '</div><h3>' + esc ( title ) + '</h3><p>' + esc ( text ) + '</p>' + buttons + '</div>' }
function render ( items ) { lastItems = sortItems ( items ) ; selected = new Set ( [ ... selected ] . filter ( p => lastItems . some ( f => ( current ? current + "/" : "" ) + f . name === p ) ) ) ; renderList ( lastItems ) ; renderGrid ( lastItems ) ; updateSelection ( ) ; renderSortState ( ) }
function renderList ( items ) { const body = $ ( "files" ) ; body . innerHTML = items . length ? items . map ( f => { const p = current ? current + "/" + f . name : f . name , canRead = ( f . mask || "" ) . includes ( "R" ) , canWrite = ( f . mask || "" ) . includes ( "W" ) , canDelete = ( f . mask || "" ) . includes ( "D" ) , selectedNow = selected . has ( p ) , drag = ' draggable="true"' ; const perms = '<span class="perm-dot ' + ( canRead ? 'read' : '' ) + '"></span><span class="perm-dot ' + ( canWrite ? 'write' : '' ) + '"></span><span class="perm-dot ' + ( canDelete ? 'delete' : '' ) + '"></span>' ; return '<tr class="file-row ' + ( selectedNow ? 'selected' : '' ) + '"' + drag + ' data-file-path="' + esc ( p ) + '" onclick="selectItem( ' + JSON . stringify ( p ) + ',true,event )" ondblclick="activateItem(' + JSON . stringify ( p ) + ')" ondragstart="fileDragStart(event,' + JSON . stringify ( p ) + ')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,' + JSON . stringify ( p ) + ')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,' + JSON . stringify ( p ) + ')"><td><input class="selectbox" type="checkbox" ' + ( selectedNow ? "checked" : "" ) + ' onclick="event.stopPropagation()" onchange="selectItem(' + JSON . stringify ( p ) + ',this.checked,event)"></td><td><div class="name"><span class="file-kind">' + iconFor ( f ) + '</span><span>' + esc ( f . name ) + '</span></div></td><td class="size-cell">' + ( f . dir ? "—" : fmt ( f . size ) ) + '</td><td class="time-cell">' + esc ( f . modified || "" ) + '</td><td class="perm-cell">' + perms + '</td><td><div class="actions"><button class="iconbtn" onclick="itemMenu(event,' + JSON . stringify ( p ) + ')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button></div></td></tr>' } ) . join ( "" ) : '<tr><td colspan="6">' + emptyState ( "这个文件夹还是空的" , "可以上传文件,或者创建一个新的文件夹。" , "upload" ) + '</td></tr>' }
function renderGrid ( items ) { $ ( "grid" ) . innerHTML = items . length ? items . map ( f => { const p = current ? current + "/" + f . name : f . name , canRead = ( f . mask || "" ) . includes ( "R" ) , type = fileType ( f ) , selectedNow = selected . has ( p ) , drag = ' draggable="true"' ; let thumb = iconFor ( f ) ; if ( type === "image" && canRead ) thumb = '<img src="/chfs/preview?file=' + encodeURIComponent ( p ) + '" loading="lazy" alt="">' ; const tileClass = ( f . dir ? 'folder-tile ' : '' ) + ( [ 'video' , 'audio' ] . includes ( type ) ? 'media-tile' : '' ) ; const meta = f . dir ? '文件夹' : fmt ( f . size ) ; return '<div class="tile ' + tileClass + ( selectedNow ? ' selected' : '' ) + '"' + drag + ' data-file-path="' + esc ( p ) + '" onclick="selectItem( ' + JSON . stringify ( p ) + ',true,event )" ondblclick="activateItem(' + JSON . stringify ( p ) + ')" oncontextmenu="itemMenu(event,' + JSON . stringify ( p ) + ');return false" ondragstart="fileDragStart(event,' + JSON . stringify ( p ) + ')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,' + JSON . stringify ( p ) + ')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,' + JSON . stringify ( p ) + ')"><input class="selectbox tile-check" type="checkbox" ' + ( selectedNow ? 'checked' : '' ) + ' onclick="event.stopPropagation();selectItem(' + JSON . stringify ( p ) + ',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,' + JSON . stringify ( p ) + ')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button><div class="thumb">' + thumb + '</div><div class="tile-title">' + iconFor ( f ) + '<div class="file-name">' + esc ( f . name ) + '</div></div><div class="tile-meta"><span>' + meta + '</span>' + ( f . modified ? '<span>' + esc ( f . modified ) + '</span>' : '' ) + '</div></div>' } ) . join ( '' ) : emptyState ( "这个文件夹还是空的" , "上传文件或创建内容后,它们会显示在这里。" , "upload" ) }
function renderList ( items ) { const body = $ ( "files" ) ; body . innerHTML = items . length ? items . map ( f => { const p = current ? current + "/" + f . name : f . name , canRead = ( f . mask || "" ) . includes ( "R" ) , canWrite = ( f . mask || "" ) . includes ( "W" ) , canDelete = ( f . mask || "" ) . includes ( "D" ) , selectedNow = selected . has ( p ) , drag = ' draggable="true"' ; const perms = '<span class="perm-dot ' + ( canRead ? 'read' : '' ) + '"></span><span class="perm-dot ' + ( canWrite ? 'write' : '' ) + '"></span><span class="perm-dot ' + ( canDelete ? 'delete' : '' ) + '"></span>' ; return '<tr class="file-row ' + ( selectedNow ? 'selected' : '' ) + '"' + drag + ' data-file-path="' + esc ( p ) + '" onclick="handleItemClick(event, ' + JSON . stringify ( p ) + ')" ondblclick="activateItem(' + JSON . stringify ( p ) + ')" ondragstart="fileDragStart(event,' + JSON . stringify ( p ) + ')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,' + JSON . stringify ( p ) + ')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,' + JSON . stringify ( p ) + ')"><td><input class="selectbox" type="checkbox" ' + ( selectedNow ? "checked" : "" ) + ' onclick="event.stopPropagation()" onchange="selectItem(' + JSON . stringify ( p ) + ',this.checked,event)"></td><td><div class="name"><span class="file-kind">' + iconFor ( f ) + '</span><span>' + esc ( f . name ) + '</span></div></td><td class="size-cell">' + ( f . dir ? "—" : fmt ( f . size ) ) + '</td><td class="time-cell">' + esc ( f . modified || "" ) + '</td><td class="perm-cell">' + perms + '</td><td><div class="actions"><button class="iconbtn" onclick="event.stopPropagation(); itemMenu(event,' + JSON . stringify ( p ) + ')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button></div></td></tr>' } ) . join ( "" ) : '<tr><td colspan="6">' + emptyState ( "这个文件夹还是空的" , "可以上传文件,或者创建一个新的文件夹。" , "upload" ) + '</td></tr>' }
function renderGrid ( items ) { $ ( "grid" ) . innerHTML = items . length ? items . map ( f => { const p = current ? current + "/" + f . name : f . name , canRead = ( f . mask || "" ) . includes ( "R" ) , type = fileType ( f ) , selectedNow = selected . has ( p ) , drag = ' draggable="true"' ; let thumb = iconFor ( f ) ; if ( type === "image" && canRead ) thumb = '<img src="/chfs/preview?file=' + encodeURIComponent ( p ) + '" loading="lazy" alt="">' ; const tileClass = ( f . dir ? 'folder-tile ' : '' ) + ( [ 'video' , 'audio' ] . includes ( type ) ? 'media-tile' : '' ) ; const meta = f . dir ? '文件夹' : fmt ( f . size ) ; return '<div class="tile ' + tileClass + ( selectedNow ? ' selected' : '' ) + '"' + drag + ' data-file-path="' + esc ( p ) + '" onclick="handleItemClick(event, ' + JSON . stringify ( p ) + ')" ondblclick="activateItem(' + JSON . stringify ( p ) + ')" oncontextmenu="itemMenu(event,' + JSON . stringify ( p ) + ');return false" ondragstart="fileDragStart(event,' + JSON . stringify ( p ) + ')" ondragend="fileDragEnd(event)" ondragover="folderDragOver(event,' + JSON . stringify ( p ) + ')" ondragleave="folderDragLeave(event)" ondrop="folderDrop(event,' + JSON . stringify ( p ) + ')"><input class="selectbox tile-check" type="checkbox" ' + ( selectedNow ? 'checked' : '' ) + ' onclick="event.stopPropagation();selectItem(' + JSON . stringify ( p ) + ',this.checked,event)"><button class="iconbtn tile-action" onclick="event.stopPropagation();itemMenu(event,' + JSON . stringify ( p ) + ')" title="更多操作"><svg class="ui-icon" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></svg></button><div class="thumb">' + thumb + '</div><div class="tile-title">' + iconFor ( f ) + '<div class="file-name">' + esc ( f . name ) + '</div></div><div class="tile-meta"><span>' + meta + '</span>' + ( f . modified ? '<span>' + esc ( f . modified ) + '</span>' : '' ) + '</div></div>' } ) . join ( '' ) : emptyState ( "这个文件夹还是空的" , "上传文件或创建内容后,它们会显示在这里。" , "upload" ) }
function fileDragStart ( e , p ) { const paths = selected . has ( p ) ? [ ... selected ] : [ p ] ; e . dataTransfer . effectAllowed = "move" ; e . dataTransfer . setData ( "text/chfs-paths" , JSON . stringify ( paths ) ) ; e . currentTarget . classList . add ( "dragging" ) }
function fileDragEnd ( e ) { e . currentTarget . classList . remove ( "dragging" ) ; document . querySelectorAll ( '.drop-target' ) . forEach ( el => el . classList . remove ( 'drop-target' ) ) }
function folderDragOver ( e , target ) { if ( ! e . dataTransfer ? . types . includes ( 'text/chfs-paths' ) ) return ; const f = lastItems . find ( x => ( current ? current + '/' : '' ) + x . name === target ) ; if ( ! f ? . dir || ! ( f . mask || '' ) . includes ( 'W' ) ) 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 ) { if ( window . matchMedia && window . matchMedia ( '(max-width:700px)' ) . matches ) { activateItem ( p ) ; return } selectItem ( p , true , e ) }
function activateItem ( p ) { const f = lastItems . find ( x => ( current ? current + "/" : "" ) + x . name === p ) ; if ( ! f ) return ; if ( f . dir && ( f . mask || "" ) . includes ( "R" ) ) openDir ( p ) ; else if ( ( f . mask || "" ) . includes ( "R" ) ) previewFile ( p ) }
function toggleTile ( p ) { selectItem ( p , ! selected . has ( p ) ) }
function openSelected ( ) { if ( selected . size !== 1 ) return ; const p = [ ... selected ] [ 0 ] ; 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 && ( f . mask || "" ) . includes ( "R" ) ) openDir ( p ) ; else if ( ( f . mask || "" ) . includes ( "R" ) ) previewFile ( p ) }
@@ -99,7 +100,7 @@ async function bulkTransfer(copyOnly){if(!selected.size)return;const dstDir=awai
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 = String ( r . mask || "" ) . includes ( "W" ) ; return '<button class="side-item ' + ( r . path === current ? 'active' : '' ) + '" data-root-path="' + esc ( r . path ) + '" onclick="jumpRoot(' + JSON . stringify ( r . path ) + ')" ondragover="sideDragOver(event,' + JSON . stringify ( r . path ) + ',' + writable + ')" ondragleave="sideDragLeave(event)" ondrop="sideDrop(event,' + JSON . stringify ( r . path ) + ',' + writable + ')">' + svgIcon ( 'folder' ) + ' ' + esc ( r . name ) + '</button>' } ) . join ( '' ) || '<div class="meta" style="padding:8px">没有可进入的 位置</div>' } catch { } }
async function loadSideRoots ( ) { try { const d = await api ( "/chfs/roots" ) , roots = d . roots || [ ] ; $ ( "sideRoots" ) . innerHTML = roots . map ( r => { const writable = String ( r . mask || "" ) . includes ( "W" ) ; return '<button class="side-item ' + ( r . path === current ? 'active' : '' ) + '" data-root-path="' + esc ( r . path ) + '" onclick="jumpRoot(' + JSON . stringify ( r . path ) + ')" ondragover="sideDragOver(event,' + JSON . stringify ( r . path ) + ',' + writable + ')" ondragleave="sideDragLeave(event)" ondrop="sideDrop(event,' + JSON . stringify ( 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(' + JSON . stringify ( r . path ) + ')"><b>' + esc ( r . name ) + '</b><span class="meta"> · ' + esc ( r . path || "/" ) + ' · ' + esc ( r . mask ) + '</span></button>' ; if ( ! roots . length ) html += '<div class="empty">没有可进入的共享位置</div>' ; html += '</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>' ; show ( html ) } catch ( e ) { fail ( e . message ) } }
function sideDragOver ( e , target , 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' ) }
@@ -116,7 +117,7 @@ async function renameItem(p){const name=p.split("/").pop(),n=await ask("重命
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 ) } }
$ ( "upload" ) . addEventListener ( "change" , async e => { await uploadFiles ( e . target . files ) ; e . target . value = "" } )
$ ( "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 ) { const list = [ ... files ] . map ( file => ( { file , relativePath : file . webkitRelativePath || file . name } ) ) ; if ( ! list . length ) return ; await uploadEntries ( list ) }
async function uploadEntries ( entries ) { const tasks = entries . map ( ( item , index ) => ( { id : "upload-" + Date . now ( ) + "-" + index , file : item . file , relativePath : item . relativePath , state : "queued" , xhr : null , row : null } ) ) ; window . _uploadTasks = tasks ; show ( '<h2>上传文件</h2><div class="notice">共 ' + tasks . length + ' 个文件,最多同时上传 3 个。关闭窗口不会中断上传。</div><div id="uploadSummary" class="upload-summary"><span class="count">准备上传</span><span class="meta"></span></div><div id="uploadList"></div><div class="row"><button class="ghost" onclick="closeModal()">后台继续</button></div>' ) ; const box = $ ( "uploadList" ) ; tasks . forEach ( task => { const row = document . createElement ( "div" ) ; row . className = "upload-row" ; row . innerHTML = '<div class="upload-head"><b class="upload-name" title="' + esc ( task . relativePath ) + '">' + esc ( task . relativePath ) + '</b><div class="upload-actions"><button class="ghost" data-upload-action="cancel">取消</button><button class="ghost" data-upload-action="retry" hidden>重试</button></div></div><span class="meta">' + fmt ( task . file . size ) + ' · 排队中</span><div class="progress"><i></i></div>' ; row . querySelector ( '[data-upload-action="cancel"]' ) . onclick = ( ) => cancelUploadTask ( task ) ; row . querySelector ( '[data-upload-action="retry"]' ) . onclick = ( ) => retryUploadTask ( task ) ; task . row = row ; box . appendChild ( row ) } ) ; await runUploadQueue ( tasks , 3 ) ; await refresh ( ) ; const failed = tasks . filter ( t => t . state === "failed" ) . length , cancelled = tasks . filter ( t => t . state === "cancelled" ) . length , done = tasks . filter ( t => t . state === "done" ) . length ; if ( failed || cancelled ) toast ( "上传完成:成功 " + done + ",失败 " + failed + ",取消 " + cancelled , failed > 0 ) ; else toast ( "上传完成,共 " + done + " 个文件" ) }
async function runUploadQueue ( tasks , limit ) { let cursor = 0 ; async function worker ( ) { while ( cursor < tasks . length ) { const task = tasks [ cursor ++ ] ; if ( task . state !== "queued" ) continue ; task . state = "uploading" ; setUploadTaskState ( task , "uploading" ) ; const ok = await uploadOne ( task . file , task . row , task . relativePath , task ) ; if ( ok ) { task . state = "done" ; setUploadTaskState ( task , "done" ) } else if ( task . state !== "cancelled" ) { task . state = "failed" ; setUploadTaskState ( task , "failed" ) } ; updateUploadSummary ( tasks ) } } await Promise . all ( Array . from ( { length : Math . min ( limit , tasks . length ) } , ( ) => worker ( ) ) ) ; updateUploadSummary ( tasks ) }
@@ -166,7 +167,7 @@ function changePassword(){show('<h2>修改密码</h2><div class="field"><input i
async function savePassword ( ) { try { await api ( "/chfs/password" , { method : "POST" , headers : { "Content-Type" : "application/json" } , body : JSON . stringify ( { currentPassword : $ ( "cp" ) . value , password : $ ( "npw" ) . value } ) } ) ; toast ( "密码已更新" ) ; closeModal ( ) } catch ( e ) { fail ( e . message ) } }
function openLogin ( ) { show ( '<h2>登录</h2><div class="field"><label>用户名</label><input id="u" autocomplete="username"></div><div class="field"><label>密码</label><input id="p" type="password" autocomplete="current-password"></div><div class="row"><button class="ghost" onclick="closeModal()">取消</button><button onclick="login()">登录</button></div>' ) }
async function login ( ) { try { await fetch ( "/chfs/session" , { method : "POST" , headers : { "Content-Type" : "application/x-www-form-urlencoded" } , body : new URLSearchParams ( { user : $ ( "u" ) . value , pwd : $ ( "p" ) . value } ) } ) . then ( async r => { if ( ! r . ok ) throw Error ( ( await r . json ( ) ) . error || "登录失败" ) } ) ; closeModal ( ) ; await init ( ) } catch ( e ) { fail ( e . message ) } }
async function logout ( ) { await fetch ( "/chfs/session" , { method : "DELETE" } ) ; location . reload ( ) }
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 ( e . key === "Escape" ) { if ( $ ( "modal" ) . classList . contains ( "show" ) ) closeModal ( ) ; else closeContext ( ) ; return } if ( ( e . ctrlKey || e . metaKey ) && e . key . toLowerCase ( ) === "a" ) { e . preventDefault ( ) ; toggleAll ( true ) ; return } if ( e . key === "Enter" ) { e . preventDefault ( ) ; openSelected ( ) ; return } if ( e . key === "Delete" && selected . size ) { e . preventDefault ( ) ; bulkDelete ( ) ; return } if ( e . key === "Backspace" && current && ! searchMode ) { e . preventDefault ( ) ; goUp ( ) ; return } if ( ( e . key === "ArrowDown" || e . key === "ArrowUp" ) ) { const source = searchMode ? searchItems : lastItems ; if ( ! source . length ) return ; e . preventDefault ( ) ; const paths = searchMode ? source . map ( searchPath ) : selectablePaths ( ) , anchor = selectionAnchor && paths . includes ( selectionAnchor ) ? selectionAnchor : [ ... selected ] [ 0 ] , idx = Math . max ( 0 , paths . indexOf ( anchor ) ) , next = Math . max ( 0 , Math . min ( paths . length - 1 , idx + ( e . key === "ArrowDown" ? 1 : - 1 ) ) ) ; if ( searchMode ) selectSearchItem ( paths [ next ] , true , e ) ; else selectItem ( paths [ next ] , true , e ) ; return } if ( e . key === "Home" || e . key === "End" ) { const source = searchMode ? searchItems : lastItems ; if ( ! source . length ) return ; e . preventDefault ( ) ; const paths = searchMode ? source . map ( searchPath ) : selectablePaths ( ) , p = e . key === "Home" ? paths [ 0 ] : paths . at ( - 1 ) ; if ( searchMode ) selectSearchItem ( p , true , e ) ; else selectItem ( p , true , e ) } }