From 1140ffc61fd32c3f73dec7c427998e78eb2f3ac9 Mon Sep 17 00:00:00 2001 From: pluvium27 Date: Sun, 26 Apr 2026 05:12:35 +0800 Subject: [PATCH] =?UTF-8?q?perf:=20=E5=A2=9E=E5=8A=A0=E5=86=85=E5=AD=98?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=8F=90=E5=8D=87=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 17 +- afi.go | 329 +++++++++++++++++++------------ multiedit_demo.txt | 6 - ui/script.js | 478 --------------------------------------------- ui/style.css | 387 ------------------------------------ 5 files changed, 213 insertions(+), 1004 deletions(-) delete mode 100644 multiedit_demo.txt diff --git a/README.md b/README.md index 3fb4184..ea5d080 100644 --- a/README.md +++ b/README.md @@ -123,18 +123,19 @@ QPS Performace from test: Server QPS Data QPS Bar (higher is better) Gzip Version Page Nginx 60,242 QPS ████████████████████████████████ Disabled 1.28.3 Default Index Apache 34,207 QPS █████████████████░░░░░░░░░░░░░░░ Disabled 2.4.66 Default Index -AFI 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.3 Web Interface (without CSS & JS embedded in bench) (net/http) +AFI 21,914 QPS ███████████░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.26 Web Interface (without CSS & JS embedded in bench) (net/http) +AFI(old) 15,262 QPS ████████░░░░░░░░░░░░░░░░░░░░░░░░ BestSpeed 26.4.3 Web Interface (without CSS & JS embedded in bench) (net/http) PyPy 4,169 QPS ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 7.3.21 Default Index (http.server) CPython 2,128 QPS █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ Disabled 3.14.3 Default Index (http.server) Latency from the same test: -Server Average Latency Bar (lower is better) Maximum -Nginx 0.91ms █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.49ms -Apache 1.43ms ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 41.05ms -AFI 4.69ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 67.67ms -CPython 18.78ms ████████████████████░░░░░░░░░░░░ 1.67s -PyPy 23.86ms ██████████████████████████░░░░░░ 1.67s - +Server Average Latency Bar (lower is better) Maximum +Nginx 0.91ms █░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 14.49ms ▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +Apache 1.43ms ██░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 41.05ms ▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +AFI 26.4.26 3.01ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 36.24ms ▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +AFI 26.4.3 4.69ms █████░░░░░░░░░░░░░░░░░░░░░░░░░░░ 67.67ms ▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ +CPython 18.78ms ████████████████████░░░░░░░░░░░░ 1.67s ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓... +PyPy 23.86ms ██████████████████████████░░░░░░ 1.67s ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓... Socket Errors from the same test: Server Number (lower is better) Nginx 0 diff --git a/afi.go b/afi.go index e274722..64b6f4f 100644 --- a/afi.go +++ b/afi.go @@ -3,6 +3,7 @@ package main import ( "archive/zip" "bufio" + "bytes" "compress/gzip" "crypto/md5" "crypto/sha1" @@ -28,6 +29,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" ) @@ -64,7 +66,7 @@ var skipHidden = flag.Bool("k", false, "跳过隐藏文件") // var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式 var title = flag.String("title", "%PATH%", "页面标题, 用 %PATH% 指代完整路径, %ITEM% 指代末端文件/目录名, 不会泄露根目录目录名") var authstr = flag.String("auth", `{"admin": "password"}`, "可写用户的认证数据 (也可以用环境变量 AFI_AUTH 设置, AFI_AUTH 是最优先且最安全的)") // 这个flag只能用来调试 不然服务器一个ps就泄露了 -var dav = flag.Bool("dav", true, "启用 WebDAV 接口") +var dav = flag.Bool("dav", false, "启用 WebDAV 接口") // rpcCall 定义RPC调用的JSON数据结构 // 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和) @@ -74,9 +76,32 @@ type rpcCall struct { } var rootPath = "" // 共享目录的根路径(文件系统中的绝对路径) -var handler http.Handler // HTTP处理器, 用于处理静态文件服务 var authTable = make(map[string]string) +// gzipWriterPool 复用 gzip.Writer,减少每次请求的分配开销 +var gzipWriterPool = sync.Pool{ + New: func() any { + w, err := gzip.NewWriterLevel(nil, gzip.BestSpeed) + if err != nil { + panic(err) + } + return w + }, +} + +// dirCacheEntry 缓存目录列表数据,避免重复 ReadDir + Stat + 排序 +type dirCacheEntry struct { + etag string // ETag 值 + folders []rowTemplate // 文件夹列表(不含 "../") + files []rowTemplate // 文件列表 + mtime time.Time // 目录修改时间(用于验证) +} + +var ( + dirCacheMu sync.RWMutex + dirCache = make(map[string]*dirCacheEntry, 128) +) + // check 检查错误, 如果错误不为nil则panic // 用于简化错误处理 func check(e error) { @@ -89,7 +114,7 @@ func check(e error) { // 用于捕获panic并返回500错误, 同时记录日志 // w: HTTP响应写入器 // s: 日志信息参数 -func exitPath(w http.ResponseWriter, s ...interface{}) { +func exitPath(w http.ResponseWriter, s ...any) { if r := recover(); r != nil { // 发生panic时记录错误并返回500状态码 log.Println("error", s, r) @@ -104,17 +129,19 @@ func exitPath(w http.ResponseWriter, s ...interface{}) { // 例如: 1024 -> "1.0k", 1048576 -> "1.0M" // bytes: 文件大小(字节) // 返回: 格式化后的字符串(如"1.5M") +var humanUnits = []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"} + func humanize(bytes int64) string { - b := float64(bytes) - u := 0 - for { - if b < 1024 { - // 根据当前数量级选择合适的单位 - return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}[u] - } - b = b / 1024 + if bytes < 1024 { + return strconv.FormatInt(bytes, 10) + "B" + } + f := float64(bytes) / 1024 + u := 1 + for f >= 1024 && u < len(humanUnits)-1 { + f /= 1024 u++ } + return strconv.FormatFloat(f, 'f', 1, 64) + humanUnits[u] } // parseDescriptIon 只打开一次 DESCRIPT.ION 并返回 map,大幅提升目录列表性能 @@ -176,131 +203,174 @@ func parseDescriptIon(dirPath string) map[string]string { // r: HTTP请求 // fullPath: 目录的绝对路径 // path: 请求的URL路径 -func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string) { - // 读取目录内容 - files, err := os.ReadDir(fullPath) - check(err) +// dirFi: 目录的 FileInfo(由调用者提供,避免重复 stat) +func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path string, dirFi fs.FileInfo) { + dirMtime := dirFi.ModTime() + // ETag 基于目录 mtime,目录内容变化时自动失效 + etag := fmt.Sprintf(`W/"%x"`, dirMtime.UnixNano()) - // 只解析一次 DESCRIPT.ION,大幅提升性能 - descMap := parseDescriptIon(fullPath) + // 缓存查找 + dirCacheMu.RLock() + entry, ok := dirCache[fullPath] + dirCacheMu.RUnlock() - // 按文件名(不区分大小写)排序 - sort.Slice(files, func(i, j int) bool { - nameI := files[i].Name() - nameJ := files[j].Name() + var folders, files []rowTemplate - // DESCRIPT.ION 始终放在最后 - if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") { - return false + if ok && entry.mtime.Equal(dirMtime) { + // 缓存命中 → ETag 检查 + if r.Header.Get("If-None-Match") == entry.etag { + w.WriteHeader(http.StatusNotModified) + return } - if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") { - return true + w.Header().Set("ETag", entry.etag) + folders = entry.folders + files = entry.files + } else { + // 缓存未命中 → 读取目录并构建 + entries, err := os.ReadDir(fullPath) + check(err) + + descMap := parseDescriptIon(fullPath) + + sort.Slice(entries, func(i, j int) bool { + nameI := entries[i].Name() + nameJ := entries[j].Name() + + if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") { + return false + } + if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") { + return true + } + + return nameI < nameJ + }) + + mimeCache := make(map[string]string, 16) + + folders = make([]rowTemplate, 0, len(entries)) + files = make([]rowTemplate, 0, len(entries)) + + for _, entry := range entries { + name := entry.Name() + + if *skipHidden && strings.HasPrefix(name, ".") { + continue + } + + if !*symlinks && entry.Type()&os.ModeSymlink != 0 { + continue + } + + var info fs.FileInfo + if !entry.IsDir() { + var statErr error + info, statErr = entry.Info() + if statErr != nil { + log.Println("error - cant stat a file", statErr) + continue + } + } + + href := url.PathEscape(name) + if entry.IsDir() && strings.HasPrefix(href, "/") { + href = strings.TrimPrefix(href, "/") + } + + desc := descMap[name] + + if entry.IsDir() { + folders = append(folders, rowTemplate{ + Name: html.EscapeString(name + "/"), + Href: template.URL(href), + Size: "4.0kB", + Ext: "folder", + Desc: html.EscapeString(desc), + Mime: "inode/directory", + }) + } else { + ext := strings.TrimPrefix(filepath.Ext(name), ".") + filemime, ok := mimeCache[ext] + if !ok { + filemime = mime.TypeByExtension("." + ext) + if filemime != "" { + if idx := strings.IndexByte(filemime, ';'); idx >= 0 { + filemime = strings.TrimSpace(filemime[:idx]) + } + } else { + if name == "DESCRIPT.ION" { + filemime = "text/plain" + } else { + filemime = "unknown/unknown" + } + } + mimeCache[ext] = filemime + } + + files = append(files, rowTemplate{ + Name: html.EscapeString(name), + Href: template.URL(href), + Size: humanize(info.Size()), + Ext: html.EscapeString(ext), + Desc: html.EscapeString(desc), + Mime: filemime, + }) + } } - return nameI < nameJ - }) -// 确保路径以斜杠结尾 + // 入缓存 + dirCacheMu.Lock() + dirCache[fullPath] = &dirCacheEntry{etag: etag, folders: folders, files: files, mtime: dirMtime} + dirCacheMu.Unlock() + w.Header().Set("ETag", etag) + } + + // --- 构建页面(请求相关部分)--- if !strings.HasSuffix(path, "/") { path += "/" } - // 构建当前路径 - pathtext := "/" + strings.TrimPrefix(path, *extraPath) // 删除extraPath前缀 + pathtext := "/" + strings.TrimPrefix(path, *extraPath) + p := pageTemplate{} now := time.Now().UTC() p.TimeStamp = now.Format(time.RFC3339) - // 如果不是根目录, 添加返回上级目录的链接 + + rowCap := len(folders) + len(files) + 1 + p.RowsFolders = make([]rowTemplate, 0, rowCap) + p.RowsFiles = make([]rowTemplate, 0, rowCap) + if path != *extraPath { p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"}) } + + p.RowsFolders = append(p.RowsFolders, folders...) + p.RowsFiles = append(p.RowsFiles, files...) + p.ExtraPath = template.HTML(html.EscapeString(*extraPath)) - p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径 + p.Path = template.HTML(html.EscapeString(pathtext)) if pathtext == "/" { - p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", "/", -1) + p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", "/") } else { - p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1) - } - // 遍历目录中的每个条目 - // 遍历目录中的每个条目 - for _, entry := range files { - name := entry.Name() - - // 跳过隐藏文件 - if *skipHidden && strings.HasPrefix(name, ".") { - continue - } - - // 跳过符号链接(如果不允许跟随) - if !*symlinks && entry.Type()&os.ModeSymlink != 0 { - continue - } - - // 获取文件信息(只对文件调用 Info,文件夹不需要) - var info fs.FileInfo - if !entry.IsDir() { - var statErr error - info, statErr = entry.Info() - if statErr != nil { - log.Println("error - cant stat a file", statErr) - continue - } - } - - href := url.PathEscape(name) - // 处理文件夹链接的斜杠问题 - if entry.IsDir() && strings.HasPrefix(href, "/") { - href = strings.Replace(href, "/", "", 1) - } - - desc := descMap[name] - - // 根据类型添加到不同的列表 - if entry.IsDir() { - row := rowTemplate{ - Name: html.EscapeString(name + "/"), - Href: template.URL(href), - Size: "4.0kB", - Ext: "folder", - Desc: html.EscapeString(desc), - Mime: "inode/directory", - } - p.RowsFolders = append(p.RowsFolders, row) - } else { - // 文件:使用 info.Size() - ext := strings.TrimPrefix(filepath.Ext(name), ".") - filemime := mime.TypeByExtension("." + ext) - filemime, _, _ = mime.ParseMediaType(filemime) - if filemime == "" { - if name == "DESCRIPT.ION" { - filemime = "text/plain" - } else { - filemime = "unknown/unknown" - } - } - - row := rowTemplate{ - Name: html.EscapeString(name), - Href: template.URL(href), - Size: humanize(info.Size()), - Ext: html.EscapeString(ext), - Desc: html.EscapeString(desc), - Mime: filemime, - } - p.RowsFiles = append(p.RowsFiles, row) - } + p.Title = strings.ReplaceAll(strings.ReplaceAll(*title, "%PATH%", pathtext), "%ITEM%", filepath.Base(fullPath)) } - // 检查客户端是否支持gzip压缩 - if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { - w.Header().Set("Content-Type", "text/html") + // 渲染到 buffer,按大小决定是否 gzip(<1KB 直接发,压缩了更慢) + var buf bytes.Buffer + tmpl.Execute(&buf, p) + body := buf.Bytes() + + w.Header().Set("Content-Type", "text/html") + + if len(body) > 1024 && strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { w.Header().Add("Content-Encoding", "gzip") - // 使用gzip压缩响应内容, 提高传输效率 - gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) - check(err) - defer gz.Close() - tmpl.Execute(gz, p) + gz := gzipWriterPool.Get().(*gzip.Writer) + gz.Reset(w) + gz.Write(body) + gz.Close() + gzipWriterPool.Put(gz) } else { - tmpl.Execute(w, p) + w.Write(body) } } @@ -309,8 +379,6 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str // w: HTTP响应写入器 // r: HTTP请求 func doContent(w http.ResponseWriter, r *http.Request) { - // query := r.URL.Query() - // 此处可以接收参数 // 如果URL不以配置的前缀开头, 重定向到正确的前缀 if !strings.HasPrefix(r.URL.Path, *extraPath) { http.Redirect(w, r, *extraPath, http.StatusFound) @@ -322,14 +390,20 @@ func doContent(w http.ResponseWriter, r *http.Request) { defer exitPath(w, "get content", path) // 获取安全的绝对路径 fullPath := enforcePath(path) - stat, errStat := os.Stat(fullPath) - check(errStat) - // 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理 - if stat.IsDir() { - replyList(w, r, fullPath, path) + // 只 Open + Stat 一次,避免 FileServer 内部二次 stat + f, err := os.Open(fullPath) + check(err) + fi, err := f.Stat() + check(err) + + if fi.IsDir() { + f.Close() + replyList(w, r, fullPath, path, fi) } else { - handler.ServeHTTP(w, r) + w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(fi.Name()))) + http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) + f.Close() } } @@ -462,8 +536,8 @@ func zipRPC(w http.ResponseWriter, r *http.Request) { // 打开文件并复制到ZIP中 file, err := os.Open(path) check(err) - defer file.Close() _, err = io.Copy(headerWriter, file) + file.Close() check(err) return nil }) @@ -573,7 +647,14 @@ func main() { check(err) // 创建HTTP服务器 - server := &http.Server{Addr: *host + ":" + *port, Handler: handler} + server := &http.Server{ + Addr: *host + ":" + *port, + Handler: nil, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 60 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } // 注册路由处理器 http.HandleFunc(*extraPath+"rpc", rpc) // RPC接口 (增删移与校验和) @@ -585,8 +666,6 @@ func main() { http.HandleFunc(*extraPath+"login", authLogin) // 登录接口 http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口 http.HandleFunc("/", doContent) // 主内容处理接口 - // 创建静态文件服务器, 用于直接提供文件下载 - handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath))) // 输出启动信息 fmt.Printf("Agile File Interface 版本 %s\n", ver) diff --git a/multiedit_demo.txt b/multiedit_demo.txt deleted file mode 100644 index 906cc67..0000000 --- a/multiedit_demo.txt +++ /dev/null @@ -1,6 +0,0 @@ -你好,世界! -这是一个演示文件。 -我们正在使用 multiedit 同时修改多处。 -第一行 -第二行 -第三行 diff --git a/ui/script.js b/ui/script.js index 177e346..e69de29 100644 --- a/ui/script.js +++ b/ui/script.js @@ -1,478 +0,0 @@ -afi = { - auth: {}, - consts: {}, - params: {}, - elements: {}, - utils: {}, - rpc: {}, - uploader: {}, - interface: {}, - directcall: {}, -} - -afi.auth = { - isLoggedIn: false, - - checkAuth: async function () { - try { - const res = await fetch(location.origin + afi.params.extraPath + '/auth', { - method: 'GET', - credentials: 'include' - }); - this.isLoggedIn = (res.status === 200); - return this.isLoggedIn; - } catch (e) { - this.isLoggedIn = false; - return false; - } - }, - logout: function () { - fetch(location.origin + afi.params.extraPath + '/auth', { - method: 'GET', - headers: { 'Authorization': 'Basic ' + btoa('invalid:invalid') }, - credentials: 'include' - }); - this.isLoggedIn = false; - location.reload(); - } -} - -afi.consts = { - leftPageWarn: "确认放弃正在进行的传输?", - ensureDrop: () => !confirm('确认移动此对象?') -} - -afi.params = { - extraPath: extraPath -} - -afi.elements = { - itemlinks: Array.from(document.querySelectorAll('a.item-links')), - table: document.getElementById("index-table"), - badge: document.getElementById("badge"), - uploader: document.getElementById("clickupload"), - path: document.getElementById("path"), - nav: document.getElementById("nav"), - title: document.head.querySelector("title"), - dropGrid: document.getElementById("drop-grid"), - barDisplay: document.getElementById("bar-display"), - barProc: document.getElementById("bar-proc"), - helpPanel: document.getElementById('help-panel'), - helpToggle: document.getElementById('help-toggle'), -} - -afi.pulsar = { - pulseLeft: 0, - pulseNotificator(text) { - afi.elements.badge.innerText = text - this.pulseEffect(afi.elements.badge) - }, - pulseSuccess() { - this.pulseNotificator("操作成功") - }, - pulseFailure(error) { - this.pulseNotificator("操作失败 " + error) - }, - pulseEffect: function (element) { - element.style.display = "block" - afi.pulsar.pulseLeft += 1 - element.classList.remove('pulse'); - void element.offsetWidth; - element.classList.add('pulse'); - setTimeout(function () { - afi.pulsar.pulseLeft -= 1; - if (afi.pulsar.pulseLeft > 0) { - console.log("[AFI] Notification stack", afi.pulsar.pulseLeft, "left, skip clear") - } else { - console.log("[AFI] Notification stack", afi.pulsar.pulseLeft, "left, do clear") - element.style.display = 'none'; - } - }, 5000); - }, -} - -afi.utils = { - isDupe: function (itemname) { - if (afi.elements.itemlinks.find(a => a.innerText.replace('/', '') === itemname)) { - alert('名称 ' + itemname + ' 已被已存在的项目占用'); - return true - } - else { - return false - } - }, - - isFolder: function (element) { - return element && element.href && element.innerText.endsWith('/'); - }, - - encodeURIHash: function (e) { - return encodeURI(e).replaceAll('#', '%23'); - }, - - refresh: function () { - afi.interface.browseTo(location.href, true); - }, - - prependPath: function (a) { - return a.startsWith('/') ? a : decodeURI(location.pathname) + a; - }, - cancelDefault(e) { - e.preventDefault() - e.stopPropagation() - }, - setHighlight: function (t) { - t.classList.add('highlight') - }, - getHighlight: function () { - return document.querySelector('.highlight') || {} - }, - resetHighlight: function () { - try { - this.getHighlight().classList.remove('highlight') - } catch (e) { - } - }, - isHelpMode: () => afi.elements.helpPanel.style.display === 'block', - helpOn: function () { - afi.elements.helpPanel.style.display = 'block' - afi.elements.table.style.display = 'none' - afi.elements.helpToggle.innerText = "关闭帮助" - afi.elements.helpToggle.onclick = afi.utils.helpOff - }, - helpOff: function () { - if (!afi.utils.isHelpMode()) return - afi.elements.helpPanel.style.display = 'none' - afi.elements.table.style.display = 'table' - afi.elements.helpToggle.innerText = "帮助菜单" - afi.elements.helpToggle.onclick = afi.utils.helpOn - }, - updateButtons: function () { - const isLogged = afi.auth.isLoggedIn; - document.querySelectorAll('#operations a').forEach(link => { - const text = link.innerText.trim(); - if (text === '登录') { - link.style.display = isLogged ? 'none' : 'block'; - } else if (['上传文件', '新建文件', '新建目录', '移动对象', '递归删除', '登出'].includes(text)) { - link.style.display = isLogged ? 'block' : 'none'; - if (text === '登出' && isLogged) { - link.onclick = function (e) { - e.preventDefault(); - afi.auth.logout(); - return false; - }; - } - } - }); - }, -} - -afi.rpc = { - rpc: function (call, args, cb) { - console.log('[AFI] RPC', call, args); - const xhr = new XMLHttpRequest(); - xhr.open('POST', location.origin + afi.params.extraPath + '/rpc'); - xhr.setRequestHeader('Content-Type', 'application/json;charset=UTF-8'); - xhr.withCredentials = true; - xhr.send(JSON.stringify({ call, args })); - xhr.onload = cb; - xhr.onerror = () => afi.pulsar.pulseFailure(); - }, - - mkdirCall: function (path, cb) { - this.rpc('mkdirp', [afi.utils.prependPath(path)], cb); - }, - - rmCall: function (path1, cb) { - this.rpc('rm', [afi.utils.prependPath(path1)], cb); - }, - - mvCall: function (path1, path2, cb) { - this.rpc('mv', [path1, path2], cb); - }, - - sumCall: function (path, type, cb) { - this.rpc('sum', [afi.utils.prependPath(path), type], cb); - }, - - touchCall: function (path, cb) { - this.rpc('touch', [afi.utils.prependPath(path)], cb); - } -} - -afi.uploader = { - totalUploads: 0, - totalUploaded: 0, - totalUploadsSize: 0, - totalUploadedSize: [], - - shouldRefresh: function () { - this.totalUploaded += 1 - console.log('[AFI] Have uploaded ' + this.totalUploaded + ' files') - if (this.totalUploads === this.totalUploaded) { - window.onbeforeunload = null - console.log('[AFI] Finished after uploaded ' + this.totalUploaded + ' files') - this.totalUploaded = 0 - this.totalUploads = 0 - this.totalUploadsSize = 0 - this.totalUploadedSize = [] - setTimeout(afi.utils.refresh, 200) - afi.elements.barDisplay.style.display = 'none' - } - }, - - updatePercent: function (event) { - this.totalUploadedSize[event.target.id] = event.loaded - const ttlDone = this.totalUploadedSize.reduce((s, x) => s + x) - const percent = Math.min(Math.floor(100 * ttlDone / this.totalUploadsSize), 100) + '%' - afi.elements.barProc.innerText = "传输: " + percent - }, - upload: function (id, what, path, cbDone, cbErr, cbUpdate) { - const xhr = new XMLHttpRequest(); - xhr.open('POST', location.origin + afi.params.extraPath + '/post'); - xhr.setRequestHeader('afi-path', path); - xhr.withCredentials = true; - xhr.upload.addEventListener('load', cbDone); - xhr.upload.addEventListener('progress', cbUpdate); - xhr.upload.addEventListener('error', cbErr); - xhr.upload.id = id; - xhr.send(what); - }, - - uploadFile: function (file, path) { - path = decodeURI(location.pathname).slice(0, -1) + path - window.onbeforeunload = afi.consts.leftPageWarn - afi.elements.barProc.style.display = afi.elements.barDisplay.style.display = 'block' - this.totalUploads += 1 - this.totalUploadsSize += file.size - if (typeof upBarName !== 'undefined') { - upBarName.innerText = this.totalUploads > 1 ? this.totalUploads + ' 个文件' : file.name - } - const formData = new FormData() - formData.append(file.name, file) - this.upload(this.totalUploads, formData, encodeURIComponent(path), this.shouldRefresh.bind(this), null, this.updatePercent.bind(this)) - } -} - -afi.interface = { - pushEntry: function (entry) { - if (!entry.webkitGetAsEntry && !entry.getAsEntry) { - return alert('不兼容此浏览器') - } else { - entry = entry.webkitGetAsEntry() || entry.getAsEntry() - } - this.parseDomItem.bind(this)(entry, true) - }, - parseDomFolder: function (f) { - f.createReader().readEntries(e => e.forEach(i => afi.interface.parseDomItem.bind(this)(i))) - }, - parseDomItem: function (domFile, shouldCheckDupes) { - if (shouldCheckDupes && afi.utils.isDupe(domFile.name)) { - return - } - if (domFile.isFile) { - domFile.file(f => afi.uploader.uploadFile(f, domFile.fullPath)) - } else { - const f = domFile.fullPath.startsWith('/') ? domFile.fullPath.slice(1) : domFile.fullPath - afi.rpc.mkdirCall(f, () => this.parseDomFolder.bind(this)(domFile)) - } - }, - - browseTo: async function (href, pulseEffectDone, skipHistory) { - try { - const r = await fetch(href, { credentials: 'include' }) - const t = await r.text() - const parsed = new DOMParser().parseFromString(t, 'text/html') - - afi.elements.table.innerHTML = parsed.getElementById('index-table').innerHTML - const nav = parsed.getElementById('nav').innerText - const path = parsed.getElementById('path').innerText - const title = parsed.head.querySelector('title').innerText - - if (afi.elements.path.innerText !== path) { - if (!skipHistory) { - const escaped = afi.utils.encodeURIHash(afi.params.extraPath + path) - history.pushState({}, '', escaped) - } - afi.elements.title.innerText = title - afi.elements.nav.innerText = nav - afi.elements.path.innerText = path - this.setBreadcrumbs() - } - init() - if (pulseEffectDone) afi.pulsar.pulseSuccess() - } catch (error) { - afi.pulsar.pulseFailure(error) - } - }, - - setBreadcrumbs: function () { - const parent = afi.elements.nav.parentNode; - afi.elements.nav.outerHTML = '' + afi.elements.path.innerText.split('/').join('/') + '' - afi.elements.nav = parent.querySelector('#nav'); - } -} - -afi.directcall = { - exec_mkdir: function () { - const folder = prompt('新建目录名称', '') - if (folder && !afi.utils.isDupe(folder)) { - afi.rpc.mkdirCall(folder, afi.utils.refresh) - } - }, - - exec_touch: function () { - const file = prompt('新建文件名称', '') - if (file && !afi.utils.isDupe(file)) { - afi.rpc.touchCall(file, afi.utils.refresh) - } - }, - - exec_rm: function () { - const file = prompt('删除对象名称', '') - afi.rpc.rmCall(file, afi.utils.refresh) - }, - - exec_mv: function () { - const orig = prompt('源地址', '') - const dest = prompt('目标地址', '') - if (orig && !afi.utils.isDupe(dest)) { - afi.rpc.mvCall(afi.utils.prependPath(orig), afi.utils.prependPath(dest), afi.utils.refresh) - } - }, - bread_click: function (e) { - const p = Array.from(document.getElementById('nav').childNodes).map(k => k.innerText) - const i = p.findIndex(s => s === e.target.innerText) - var dst = p.slice(0, i + 1).join('').slice(1) - if (!dst.startsWith('/')) { - dst = "/" + dst - } - const target = location.origin + afi.params.extraPath + afi.utils.encodeURIHash(dst) - afi.interface.browseTo(target, false) - } -} - -function init() { - afi.elements = { - itemlinks: Array.from(document.querySelectorAll('a.item-links')), - table: document.getElementById("index-table"), - badge: document.getElementById("badge"), - path: document.getElementById("path"), - uploader: document.getElementById("clickupload"), - nav: document.getElementById("nav"), - title: document.head.querySelector("title"), - dropGrid: document.getElementById("drop-grid"), - barDisplay: document.getElementById("bar-display"), - barProc: document.getElementById("bar-proc"), - helpPanel: document.getElementById('help-panel'), - helpToggle: document.getElementById('help-toggle'), - } - - afi.auth.checkAuth().then(() => { - afi.utils.updateButtons(); - }); - - afi.interface.setBreadcrumbs() - afi.elements.uploader.addEventListener('change', () => { - const files = Array.from(afi.elements.uploader.files); - - files.forEach(file => { - if (!afi.utils.isDupe(file.name)) { - afi.uploader.uploadFile(file, '/' + file.name); - } - }); - }, false); - afi.elements.dropGrid.ondragend = afi.elements.dropGrid.ondragexit = afi.elements.dropGrid.ondragleave = e => { - afi.utils.cancelDefault(e) - afi.elements.dropGrid.style.display = 'none' - } - let draggingSrc - document.ondragenter = e => { - afi.utils.cancelDefault(e) - afi.utils.resetHighlight() - if (!draggingSrc) { - afi.elements.dropGrid.style.display = 'flex' - e.dataTransfer.dropEffect = 'copy' - } else if (draggingSrc) { - const t = getClosestRow(e.target) - afi.utils.isFolder(t.firstChild) && setHighlight(t) - } - } - - afi.elements.itemlinks.forEach(link => { - const row = link.parentElement.parentElement.parentElement.querySelector('.item-icon'); - if (!row) return; - - let clickTimer = null; - let isDoubleClick = false; - - row.addEventListener('click', (e) => { - if (link.innerText === '../') return; - if (clickTimer) { - clearTimeout(clickTimer); - clickTimer = null; - } - - clickTimer = setTimeout(() => { - if (!isDoubleClick) { - e.stopPropagation(); - if (confirm('确认删除此项目?')) { - afi.rpc.rmCall(afi.utils.prependPath(link.getAttribute('href')), afi.utils.refresh); - } - } - isDoubleClick = false; - clickTimer = null; - }, 300); - }); - - row.addEventListener('dblclick', (e) => { - e.stopPropagation(); - - isDoubleClick = true; - - if (clickTimer) { - clearTimeout(clickTimer); - clickTimer = null; - } - - if (link.innerText === '../') return; - - const newName = prompt('新名称或目标地址', link.innerText); - if (newName && !afi.utils.isDupe(newName)) { - afi.rpc.mvCall( - afi.utils.prependPath(link.innerText), - afi.utils.prependPath(newName), - afi.utils.refresh - ); - } - - setTimeout(() => { - isDoubleClick = false; - }, 100); - }); - }); - document.ondragstart = e => { draggingSrc = e.target.innerHTML } - document.ondragend = e => resetHighlight() - document.ondragover = e => { afi.utils.cancelDefault(e); return false } - - document.ondrop = e => { - - afi.utils.cancelDefault(e) - afi.elements.dropGrid.style.display = 'none' - const t = afi.utils.getHighlight().firstChild - - if (draggingSrc && t) { - const dest = t.innerHTML + draggingSrc - afi.consts.ensureDrop() || afi.rpc.mvCall(afi.utils.prependPath(draggingSrc), afi.utils.prependPath(dest), afi.utils.refresh) - } else if (e.dataTransfer.items.length) { - Array.from(e.dataTransfer.items).forEach(afi.interface.pushEntry.bind(afi.interface)) - } - - afi.utils.resetHighlight() - draggingSrc = null - return false - } - console.log('[AFI] Initialized') -} - -window.onload = init \ No newline at end of file diff --git a/ui/style.css b/ui/style.css index 24627a6..e69de29 100644 --- a/ui/style.css +++ b/ui/style.css @@ -1,387 +0,0 @@ -@import url('https://fonts.loli.net/css2?family=Cascadia+Code:ital,wght@0,200..700;1,200..700&display=swap'); -@import url("https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@400;700&display=swap"); - -:root { - --background: #ffffff; - --background-darker: #eeeeee; - --background-verydarker: #dddddd; - --foreground: #454545; - --accent: #0f59a4; - --accent-lighten: #2793ff; - /*本来有color-mix的, 可惜支持太新*/ - --radius: 0; - --font-size: 1rem; - --line-height: 1.54em; -} - -html { - /* 预留滚动条空间,但不会让背景色断开,保持内容稳定 */ - scrollbar-gutter: stable; -} - -@media (max-width: 640px) { - #index-table th:nth-child(5) { - display: none; - } - - #index-table td:nth-child(6) { - display: none; - } - - .ellipsis { - display: -webkit-box; - -webkit-box-orient: vertical; - line-clamp: 2; - overflow: hidden; - text-overflow: ellipsis; - word-break: break-word; - } - - .desktoponly { - display: none; - } - - #index-table th:last-child, - #index-table td:last-child { - text-align: right; - padding: 0.25em 0 !important; - } - - #index-table th:nth-child(2), - #index-table td:nth-child(3) { - width: 52%; - } - - #index-table th:nth-child(3), - #index-table td:nth-child(4) { - display: none; - } -} - -*, -*::before, -*::after { - background-color: var(--background); - color: var(--foreground); - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - min-height: 100vh; - display: flex; - flex-direction: column; - font-family: "Cascadia Code", "Noto Sans SC", system-ui, sans-serif; - background: var(--background); -} - -header { - position: sticky; - top: 0; - display: flex; - justify-content: space-between; - align-items: center; - background: var(--background); - padding: 4px 32px 4px 20px; - border-bottom: 1px solid var(--background-verydarker); - z-index: 100; -} - -a { - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -.logo-img { - display: inline-block; - height: calc(8px + 1em); - width: auto; - padding: 0 1em 0 0; - vertical-align: middle; -} - -#nav { - flex: 1; - font-size: 14px; - color: var(--foreground); -} - -#operations { - display: flex; - gap: 8px; -} - -.operation { - padding: 4px 0px; - border-color: transparent; - color: var(--accent); - cursor: pointer; - font-size: 14px; -} - -.ellipsis { - display: block; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - max-width: 100%; -} - -#index-table { - margin: 20px 20px 0 20px; - width: auto; - border-collapse: collapse; - background: var(--background); - overflow: auto; - font-size: 12px; -} - -#index-table th, -#index-table td { - /*hack td高度计算的怪异标准(明确指定), 不然icon的div没高度显示不了icon*/ - height: 1px; - text-align: left; - /*Noto Fonts SC 和 Cascadia Code(以及各种utf-8 fallback字体) 的em不同, 所以用父元素统一基准, 不用子元素(可能没中文), 不然icon大小不齐*/ - /*然后导致rem不能有可变的多语言内容, 不然页面之间照样变*/ - /*css设计时就不能设置以指定字体为参照的em吗? 本来这个问题能用ie盒模型+"指定字体e"解决的 这下非要导致每行content间距可能不一样*/ - padding: 0.25rem 0.5rem; - /*em单位简直毫不兼容且毫无预测性*/ - /*连ex这种东西都设计出来了 就不能设计一个e(任意字符)吗? */ - /*最终在icon用px+硬编码数字 懒得折腾了*/ - /*真空球形鸡设计*/ -} - -#index-table thead th { - font-weight: 800; - text-transform: uppercase; - /*英文标题大写, 虽然我用中文*/ - letter-spacing: 0.5px; -} - -#index-table tbody th, -#index-table tbody td { - text-align: left; - border-bottom: 1px solid var(--background-darker); -} - -#index-table tr:hover { - background: var(--background-darker); -} - -#index-table th:nth-child(1) { - min-width: 2.5em; - padding: 0.25em 0; -} - -#index-table td:nth-child(1) { - width: 1.5em; - padding: 0.25em 0; -} - -#index-table td:nth-child(2) { - width: 0.5em; - padding: 0.25em 0em; - /*就一个'>'号*/ -} - -#index-table th:nth-child(2), -#index-table td:nth-child(3) { - max-width: 0; - /*允许压缩*/ - min-width: 120px; -} - -#index-table th:nth-child(3), -#index-table td:nth-child(4) { - width: auto - /*能占就占满*/ -} - -#index-table th:nth-child(4) { - text-align: center; -} - -#index-table td:nth-child(5) { - text-align: right; - width: 3em; -} - -#index-table th:nth-child(5) { - text-align: left; -} - -#index-table td:nth-child(6) { - width: 12em; -} - -#index-table th:last-child, -#index-table td:last-child { - text-align: right; - padding-right: 2em; - max-width: 0; - /*允许压缩*/ - min-width: 4em; -} - -.placeholder { - flex: 1; -} - -footer { - position: sticky; - bottom: 0; - display: flex; - justify-content: space-between; - align-items: center; - padding: 2px 12px 4px 12px; - background: var(--background); - border-top: 1px solid var(--background-verydarker); - font-size: 12px; - color: var(--foreground); - z-index: 100; -} - -@keyframes fade { - 0% { - opacity: 0; - } - - 20% { - opacity: 1; - } - - 80% { - opacity: 1; - } - - 100% { - opacity: 0; - } -} - -.pulse { - animation: fade 5s 1; -} - -.item-selected::before { - content: ">"; - justify-content: center; - align-items: center; - font-size: 12px; - color: var(--accent); - font-weight: bold; -} - -.highlight { - background-color: var(--accent-lighten); -} - - -#drop-grid { - align-items: center; - display: none; - justify-content: center; - position: fixed; - top: 0px; - bottom: 0px; - right: 0px; - left: 0px; - z-index: 999; - border: 5px dashed var(--accent); - margin: 0px; - border-radius: 5px; - text-align: center; - font-size: 4em; - color: var(--accent); - opacity: 1; - background-color: rgba(255, 255, 255, 0.6); - padding: 20px; -} - -#drop-grid::after { - content: "释放光标以传输文件"; - color: var(--foreground); - background-color: rgba(255, 255, 255, 0.6); - text-align: center; -} - -#notification { - display: flex; - flex-direction: row; - padding: 0 0; -} - -#notification * { - padding: 0 0; -} - -#statbar { - display: flex; - flex-direction: row; - padding: 0 0; -} - -#statbar * { - padding: 0 0; -} - -.nav:hover { - color: var(--accent); -} - -.item-icon { - height: 16px; - width: auto; - /*垃圾css*/ - cursor: pointer; - background-size: contain; - background-repeat: no-repeat; - background-position: center; -} - -.type-icon-folder { - background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNzc0NjkwMDA5MjU1IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjEwNTMwIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgd2lkdGg9IjIwMCIgaGVpZ2h0PSIyMDAiPjxwYXRoIGQ9Ik04ODAgMjk4LjRINTIxTDQwMy43IDE4Ni4yYy0xLjUtMS40LTMuNS0yLjItNS41LTIuMkgxNDRjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjU5MmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg3MzZjMTcuNyAwIDMyLTE0LjMgMzItMzJWMzMwLjRjMC0xNy43LTE0LjMtMzItMzItMzJ6TTg0MCA3NjhIMTg0VjI1NmgxODguNWwxMTkuNiAxMTQuNEg4NDBWNzY4eiIgcC1pZD0iMTA1MzEiPjwvcGF0aD48L3N2Zz4=") !important; -} - -.type-icon-file { - background-image: url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBzdGFuZGFsb25lPSJubyI/PjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+PHN2ZyB0PSIxNzc0Njg5OTcyMTU2IiBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHAtaWQ9IjUzNDAiIGRhdGEtc3BtLWFuY2hvci1pZD0iYTMxM3guc2VhcmNoX2luZGV4LjAuaTEuM2YwMTNhODFMUkJncXMiIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCI+PHBhdGggZD0iTTg1NC42IDI4OC42TDYzOS40IDczLjRjLTYtNi0xNC4xLTkuNC0yMi42LTkuNEgxOTJjLTE3LjcgMC0zMiAxNC4zLTMyIDMydjgzMmMwIDE3LjcgMTQuMyAzMiAzMiAzMmg2NDBjMTcuNyAwIDMyLTE0LjMgMzItMzJWMzExLjNjMC04LjUtMy40LTE2LjctOS40LTIyLjd6TTc5MC4yIDMyNkg2MDJWMTM3LjhMNzkwLjIgMzI2eiBtMS44IDU2MkgyMzJWMTM2aDMwMnYyMTZjMCAyMy4yIDE4LjggNDIgNDIgNDJoMjE2djQ5NHoiIHAtaWQ9IjUzNDEiIGRhdGEtc3BtLWFuY2hvci1pZD0iYTMxM3guc2VhcmNoX2luZGV4LjAuaTIuM2YwMTNhODFMUkJncXMiPjwvcGF0aD48L3N2Zz4="); -} - -#help-panel { - display: flex; - text-align: center; - width: 100%; -} - -#help-panel h3 { - padding: 0.2em 0 0 0; - margin: 0; -} - -#help-panel p { - padding: 0.3em 0 0.5em 0; - line-height: 1.5em; -} - -#help-panel table { - border-collapse: collapse; - align-items: center; - width: 40em; - max-height: 10em; - min-width: 30em; - max-width: 50%; - margin: auto; - border: 4px solid var(--foreground); -} - -#help-panel table td { - border: 2px solid var(--background-darker); -} - -#help-panel table * { - padding: 0.3em 1em; -} \ No newline at end of file