perf: 增加内存缓存提升性能
This commit is contained in:
17
README.md
17
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
|
||||
|
||||
329
afi.go
329
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)
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
你好,世界!
|
||||
这是一个演示文件。
|
||||
我们正在使用 multiedit 同时修改多处。
|
||||
第一行
|
||||
第二行
|
||||
第三行
|
||||
478
ui/script.js
478
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 = '<span id="nav" onclick="return afi.directcall.bread_click(event)"><span class="nav">' + afi.elements.path.innerText.split('/').join('/</span><span class="nav">') + '</span></span>'
|
||||
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
|
||||
387
ui/style.css
387
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;
|
||||
}
|
||||
Reference in New Issue
Block a user