diff --git a/DESCRIPT.ION b/DESCRIPT.ION new file mode 100644 index 0000000..4061b35 --- /dev/null +++ b/DESCRIPT.ION @@ -0,0 +1,9 @@ +ui 用户界面前端 +afi 生产版本释出二进制 +afi-dev 开发版本释出二进制 +afi.go 主模块文件 +afi_embed.go 嵌入资源与模板配置 +afi_embed_dev.go 热重载资源配置 +go.mod Golang 模块定义 +LICENSE MIT 协议文件 +README.md 自述文件 diff --git a/README.md b/README.md index 9ad7fe7..f137ad6 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,35 @@ -# AFI - gossa 的个人维护分支 - +# AFI - gossa 的现代化改进与维护分支 本项目 fork 自 [Gossa, 由 Pierre Dubouilh 及 Gossa Authors 开发维护](https://github.com/pldubouilh/gossa), 并重新初始化 git. -一个敏捷且超轻量的文件网页服务器, 无依赖且代码量极小, 易于审查. +一个轻量的文件网页服务器, 无依赖且代码量极小, 易于审查. -默认提供简洁的UI, 包含以下功能: +可与配置好的 rclone 与 fuse 实例搭配, 但不附带配置界面. - * 文件/目录浏览与处理 - * 拖放上传 - * 轻量级网页 UI, 毫秒级响应 - * 文本编辑器 - * 键盘导航 - * 轻量级且无依赖的代码库 - * 快速的 Go 语言静态服务器 - * 只读模式 - * 支持 PWA - * 多平台支持 +默认提供简洁现代的用户界面, 并包含以下功能: + +* 文件/目录浏览与处理 +* 拖放上传 +* 轻量级网页用户界面, 毫秒级响应 +* 键盘导航 +* 轻量级, 且易于维护的代码库 +* 快速的 Go 语言静态服务器 +* 基于 DESCRIPT.ION 的文件注释支持 +* 可选的只读模式 +* 可选的 symlink 追踪 +* 支持 PWA +* 多平台支持 ## 使用方法 + ```sh % ./afi --help ``` ## 编译 + ```bash -go vet && go fmt -go build -o afi # 单文件, 包括 web 资源 -go build -tags dev -o afi-dev # 使用运行目录(优先)或程序目录下的 web 资源 -``` \ No newline at end of file +# go vet && go fmt # 可选 +go build -o afi # 单文件, 自包括 web 资源, 便于部署 +go build -tags dev -o afi-dev # 使用运行目录(优先)或程序目录下的 web 资源 (ui 目录) +``` diff --git a/afi.go b/afi.go index 683cfab..b9b8dda 100644 --- a/afi.go +++ b/afi.go @@ -2,6 +2,7 @@ package main import ( "archive/zip" + "bufio" "compress/gzip" "crypto/md5" "crypto/sha1" @@ -19,6 +20,7 @@ import ( "io" "io/fs" "log" + "mime" "net/http" "net/url" "os" @@ -26,35 +28,41 @@ import ( "sort" "strconv" "strings" + "time" ) // rowTemplate 定义文件列表中的每一行数据结构 -// 用于在HTML模板中渲染文件或文件夹的显示信息 +// 用于在HTML模板中渲染项目的显示信息 type rowTemplate struct { Name string // 文件/文件夹名称 Href template.URL // 链接地址 - Size string // 文件大小(仅文件) - Ext string // 文件扩展名(仅文件) + Size string // 项目大小 + Ext string // 项目扩展名 (文件夹是folder) + Desc string // 项目描述 + Mime string // 项目mime } // pageTemplate 定义整个页面渲染所需的数据结构 // 包含页面标题、路径前缀、读写模式标志以及文件和文件夹列表 type pageTemplate struct { - Title template.HTML // 页面标题,显示当前路径 + Title string // 页面标题 + Path template.HTML // 页面路径 ExtraPath template.HTML // URL前缀路径 Ro bool // 是否只读模式(不允许上传、重命名、移动等操作) RowsFiles []rowTemplate // 文件列表 RowsFolders []rowTemplate // 文件夹列表 + TimeStamp string //时间戳 } // 命令行参数定义 var host = flag.String("h", "127.0.0.1", "监听的主机地址") // 监听的主机地址 var port = flag.String("p", "8001", "监听的端口") // 监听的端口 -var extraPath = flag.String("prefix", "/", "afi 的 URL 访问路径前缀, 例如 /afi/ (斜杠很重要)") // URL前缀路径 +var extraPath = flag.String("prefix", "/", "afi 的 URL 访问路径前缀, 例如 /afi/ (斜杠很重要)") // URL前缀路径 var symlinks = flag.Bool("symlinks", false, "跟随符号链接 \033[4m警告\033[0m: 符号链接可能会跳出已定义的\"根目录\" (默认值: false)") // 是否跟随符号链接 var verb = flag.Bool("verb", false, "详细输出") // 是否输出详细日志 var skipHidden = flag.Bool("k", true, "\n跳过隐藏文件") // 是否跳过隐藏文件(以.开头) var ro = flag.Bool("ro", false, "只读模式(无法修改文件系统)") // 是否只读模式 +var title = flag.String("title", "%PATH%", "页面标题, 用%PATH%指代完整路径, %ITEM%指代末端文件/目录名, 不会泄漏根目录目录名") // rpcCall 定义RPC调用的JSON数据结构 // 用于处理前端发送的远程调用请求(创建目录、移动、删除、计算校验和) @@ -64,10 +72,10 @@ type rpcCall struct { } var rootPath = "" // 共享目录的根路径(绝对路径) -var handler http.Handler // HTTP处理器,用于处理静态文件服务 +var handler http.Handler // HTTP处理器, 用于处理静态文件服务 -// check 检查错误,如果错误不为nil则panic -// 用于简化错误处理,配合defer exitPath使用 +// check 检查错误, 如果错误不为nil则panic +// 用于简化错误处理 func check(e error) { if e != nil { panic(e) @@ -75,7 +83,7 @@ func check(e error) { } // exitPath 延迟执行的错误恢复和日志记录函数 -// 用于捕获panic并返回500错误,同时记录日志 +// 用于捕获panic并返回500错误, 同时记录日志 // w: HTTP响应写入器 // s: 日志信息参数 func exitPath(w http.ResponseWriter, s ...interface{}) { @@ -100,15 +108,76 @@ func humanize(bytes int64) string { for { if b < 1024 { // 根据当前数量级选择合适的单位 - return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "k", "M", "G", "T", "P", "E", "Z", "Y"}[u] + return strconv.FormatFloat(b, 'f', 1, 64) + [9]string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"}[u] } b = b / 1024 u++ } } +// lookupDesc 返回指定文件在 DESCRIPT.ION 中的注释内容 +func lookupDesc(fullPath string) string { + // 1. 获取绝对路径并提取目录和文件名 + absPath, err := filepath.Abs(fullPath) + if err != nil { + return "" + } + dir := filepath.Dir(absPath) + fileName := filepath.Base(absPath) + + if fileName == "DESCRIPT.ION" { + return "备注元数据" + } + + // 2. 尝试打开该目录下的 DESCRIPT.ION (不区分大小写) + // 在 Windows 下直接打开即可,在 Linux 下可能需要遍历目录匹配文件名 + descFilePath := filepath.Join(dir, "DESCRIPT.ION") + file, err := os.Open(descFilePath) + if err != nil { + return "" + } + defer file.Close() + + // 3. 逐行扫描文件内容 + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + var targetName, description string + + // DESCRIPT.ION 格式处理: + // 如果文件名包含空格,通常会被双引号包裹,例如: "My Photo.jpg" This is a photo + if strings.HasPrefix(line, "\"") { + endQuoteIndex := strings.Index(line[1:], "\"") + if endQuoteIndex != -1 { + targetName = line[1 : endQuoteIndex+1] + description = strings.TrimSpace(line[endQuoteIndex+2:]) + } + } else { + // 普通格式:文件名 描述 (空格分隔) + parts := strings.SplitN(line, " ", 2) + targetName = parts[0] + if len(parts) > 1 { + description = strings.TrimSpace(parts[1]) + } + } + + // 4. 匹配文件名(不区分大小写,符合该标准的一贯做法) + if strings.EqualFold(targetName, fileName) { + // 部分实现会在描述末尾包含控制字符 \x04,需要清理 + description = strings.Split(description, "\x04")[0] + return strings.TrimSpace(description) + } + } + + return "" +} + // replyList 生成目录列表页面并返回 -// 读取指定目录下的文件和文件夹,构建页面模板数据并渲染 +// 读取指定目录下的文件和文件夹, 构建页面模板数据并渲染 // w: HTTP响应写入器 // r: HTTP请求 // fullPath: 目录的绝对路径 @@ -117,25 +186,47 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str // 读取目录内容 files, err := os.ReadDir(fullPath) check(err) - // 按文件名(不区分大小写)排序 - sort.Slice(files, func(i, j int) bool { return strings.ToLower(files[i].Name()) < strings.ToLower(files[j].Name()) }) + // 按文件名(不区分大小写)排序 + sort.Slice(files, func(i, j int) bool { + nameI := files[i].Name() + nameJ := files[j].Name() + + // 1. 特殊逻辑:处理 DESCRIPT.ION + // 如果 i 是目标文件,且 j 不是,则 i 应该在后 (返回 false) + if strings.EqualFold(nameI, "DESCRIPT.ION") && !strings.EqualFold(nameJ, "DESCRIPT.ION") { + return false + } + // 如果 j 是目标文件,且 i 不是,则 i 应该在前 (返回 true) + if strings.EqualFold(nameJ, "DESCRIPT.ION") && !strings.EqualFold(nameI, "DESCRIPT.ION") { + return true + } + + // 2. 普通逻辑:不区分大小写的字母排序 + return strings.ToLower(nameI) < strings.ToLower(nameJ) + }) // 确保路径以斜杠结尾 if !strings.HasSuffix(path, "/") { path += "/" } - // 构建页面标题,显示当前路径 - title := "/" + strings.TrimPrefix(path, *extraPath) + // 构建当前路径 + pathtext := "/" + strings.TrimPrefix(path, *extraPath) // 删除extraPath前缀 p := pageTemplate{} - // 如果不是根目录,添加返回上级目录的链接 + now := time.Now().UTC() + p.TimeStamp = now.Format(time.RFC3339) + // 如果不是根目录, 添加返回上级目录的链接 if path != *extraPath { - p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "../", "", "folder"}) + p.RowsFolders = append(p.RowsFolders, rowTemplate{"../", "..", "4.0kB", "folder", "返回上级目录", "inode/directory"}) } p.ExtraPath = template.HTML(html.EscapeString(*extraPath)) p.Ro = *ro - p.Title = template.HTML(html.EscapeString(title)) - + p.Path = template.HTML(html.EscapeString(pathtext)) // 安全化路径 + if pathtext == "/" { + p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", "/", -1) + } else { + p.Title = strings.Replace(strings.Replace(*title, "%PATH%", pathtext, -1), "%ITEM%", filepath.Base(fullPath), -1) + } // 遍历目录中的每个条目 for _, el := range files { info, errInfo := el.Info() @@ -154,7 +245,7 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str continue } - // URL编码文件名,避免特殊字符问题 + // URL编码文件名, 避免特殊字符问题 href := url.PathEscape(el.Name()) name := el.Name() @@ -163,15 +254,25 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str href = strings.Replace(href, "/", "", 1) } + var desc string = lookupDesc(fullPath + "/" + el.Name()) + // 根据类型添加到不同的列表 if el.IsDir() { - row := rowTemplate{name + "/", template.URL(href), "4.0k", "folder"} + row := rowTemplate{name + "/", template.URL(href), "4.0kB", "folder", desc, "inode/directory"} p.RowsFolders = append(p.RowsFolders, row) } else { // 提取文件扩展名 - sl := strings.Split(name, ".") - ext := strings.ToLower(sl[len(sl)-1]) - row := rowTemplate{name, template.URL(href), humanize(el.Size()), ext} + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") + filemime := mime.TypeByExtension("." + ext) + filemime, _, _ = mime.ParseMediaType(filemime) // 避免mime瞎猜文档编码 + if filemime == "" { + if name == "DESCRIPT.ION" { + filemime = "text/plain" + } else { + filemime = "unknown/unknown" + } + } + row := rowTemplate{name, template.URL(href), humanize(el.Size()), ext, desc, filemime} p.RowsFiles = append(p.RowsFiles, row) } } @@ -180,7 +281,7 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { w.Header().Set("Content-Type", "text/html") w.Header().Add("Content-Encoding", "gzip") - // 使用gzip压缩响应内容,提高传输效率 + // 使用gzip压缩响应内容, 提高传输效率 gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) check(err) defer gz.Close() @@ -191,11 +292,14 @@ func replyList(w http.ResponseWriter, r *http.Request, fullPath string, path str } // doContent 处理所有内容请求的核心函数 -// 根据请求路径判断是目录还是文件,目录则列出文件列表,文件则提供下载 +// 根据请求路径判断是目录还是文件, 目录则列出文件列表, 文件则提供下载 // w: HTTP响应写入器 // r: HTTP请求 func doContent(w http.ResponseWriter, r *http.Request) { - // 如果URL不以配置的前缀开头,重定向到正确的前缀 + query := r.URL.Query() + name := query.Get("header") + name := query.Get("footer") + // 如果URL不以配置的前缀开头, 重定向到正确的前缀 if !strings.HasPrefix(r.URL.Path, *extraPath) { http.Redirect(w, r, *extraPath, http.StatusFound) return @@ -209,7 +313,7 @@ func doContent(w http.ResponseWriter, r *http.Request) { stat, errStat := os.Stat(fullPath) check(errStat) - // 如果是目录,显示文件列表;如果是文件,交给静态文件服务器处理 + // 如果是目录, 显示文件列表;如果是文件, 交给静态文件服务器处理 if stat.IsDir() { replyList(w, r, fullPath, path) } else { @@ -261,7 +365,7 @@ func zipRPC(w http.ResponseWriter, r *http.Request) { zipWriter := zip.NewWriter(w) defer zipWriter.Close() - // 遍历目录树,将文件添加到ZIP中 + // 遍历目录树, 将文件添加到ZIP中 err = filepath.Walk(zipFullPath, func(path string, f fs.FileInfo, err error) error { check(err) // 跳过目录(目录本身不加入ZIP) @@ -317,6 +421,9 @@ func rpc(w http.ResponseWriter, r *http.Request) { // 根据调用的方法执行相应操作 switch rpc.Call { + case "touch": + // 创建目录(包括所有父目录) + _, err = os.OpenFile(enforcePath(rpc.Args[0]), os.O_RDONLY|os.O_CREATE, 0777) case "mkdirp": // 创建目录(包括所有父目录) err = os.MkdirAll(enforcePath(rpc.Args[0]), os.ModePerm) @@ -355,7 +462,7 @@ func rpc(w http.ResponseWriter, r *http.Request) { } // enforcePath 确保请求的路径是安全的 -// 防止路径遍历攻击,确保访问路径在共享目录内 +// 防止路径遍历攻击, 确保访问路径在共享目录内 // p: 请求的路径 // 返回: 安全的绝对路径 func enforcePath(p string) string { @@ -367,8 +474,8 @@ func enforcePath(p string) string { // 安全检查: // 1. 获取绝对路径是否出错 // 2. 路径是否以rootPath开头(防止路径遍历) - // 3. 如果跳过隐藏文件,路径中是否包含隐藏目录 - // 4. 如果不允许符号链接,符号链接是否指向rootPath外 + // 3. 如果跳过隐藏文件, 路径中是否包含隐藏目录 + // 4. 如果不允许符号链接, 符号链接是否指向rootPath外 if err != nil || !strings.HasPrefix(fp, rootPath) || *skipHidden && strings.Contains(p, "/.") || !*symlinks && len(sl) > 0 && !strings.HasPrefix(sl, rootPath) { panic(errors.New("invalid path")) } @@ -401,7 +508,7 @@ func main() { } http.HandleFunc(*extraPath+"zip", zipRPC) // ZIP打包下载接口 http.HandleFunc("/", doContent) // 主内容处理接口 - // 创建静态文件服务器,用于直接提供文件下载 + // 创建静态文件服务器, 用于直接提供文件下载 handler = http.StripPrefix(*extraPath, http.FileServer(http.Dir(rootPath))) // 输出启动信息 diff --git a/ui/DESCRIPT.ION b/ui/DESCRIPT.ION new file mode 100644 index 0000000..e8a3cd1 --- /dev/null +++ b/ui/DESCRIPT.ION @@ -0,0 +1,4 @@ +favicon.svg 图标文件 +script.js 前端脚本 +style.css 样式表 +ui.html 前端模板 diff --git a/ui/favicon.svg b/ui/favicon.svg new file mode 100644 index 0000000..f4e87dc --- /dev/null +++ b/ui/favicon.svg @@ -0,0 +1 @@ + diff --git a/ui/script.js b/ui/script.js new file mode 100644 index 0000000..e4dd0db --- /dev/null +++ b/ui/script.js @@ -0,0 +1,469 @@ +afi = { + consts: {}, + params: {}, + elements: {}, + utils: {}, + rpc: {}, + uploader: {}, + interface: {}, + directcall: {}, +} + +afi.consts = { + leftPageWarn: "确认放弃正在进行的传输?", + ensureDrop: () => !confirm('确认移动此对象?') +} + +afi.params = { + readonly: readonly, + 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 + } +} + +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.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' + //afi.elements.barProc.innerText = "传输: 就绪" + } + }, + + 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.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) { + if (afi.params.readonly) return + 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 + //this.totalUploadedSize[this.totalUploads] = 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.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) + } +} + +afi.checksum = { + getSum(type) { + upBarPc.style.display = 'block' + upBarPc.innerText = '远程求解校验和...' + upBarPc.style.width = '100%' + sumsOff() + sumCall(getASelected().innerText, type, loaded => { + navigator.clipboard.writeText(loaded.target.responseText) // 复制到剪贴板 + upBarPc.style.display = 'none' + flicker(okBadge) + }) + }, + + isSumsMode: () => sums.style.display === 'block', + sumsToggle: () => isSumsMode() ? sumsOff() : sumsOn(), + + sumsOn() { + if (isFolder(getASelected())) { + alert('无法对目录求解校验和') + return + } + sums.style.display = 'block' + table.style.display = 'none' + }, + sumsOff() { + if (!isSumsMode()) return + sums.style.display = 'none' + table.style.display = 'table' + return true + } +} + +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.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 => { + if (afi.params.readonly) { return } + 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 (afi.params.readonly || link.innerText === '../') return; + if (clickTimer) { + clearTimeout(clickTimer); + clickTimer = null; + } + + clickTimer = setTimeout(() => { + if (!isDoubleClick) { + e.stopPropagation(); + if (confirm('确认删除此项目?')) { + afi.rpc.rmCall(afi.utils.prependPath(link.href), afi.utils.refresh); + } + } + isDoubleClick = false; + clickTimer = null; + }, 300); + }); + + row.addEventListener('dblclick', (e) => { + e.stopPropagation(); + + isDoubleClick = true; + + if (clickTimer) { + clearTimeout(clickTimer); + clickTimer = null; + } + + if (afi.params.readonly) return; + 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 => { + if (afi.params.readonly) return + + 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 + } + if (!afi.params.readonly) { + afi.elements.barProc.innerText = "传输: 就绪" + } + console.log('[AFI] Initialized') +} + +window.onload = init \ No newline at end of file diff --git a/ui/style.css b/ui/style.css new file mode 100644 index 0000000..61ae081 --- /dev/null +++ b/ui/style.css @@ -0,0 +1,348 @@ +@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; +} + +*, +*::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 diff --git a/ui/ui.html b/ui/ui.html new file mode 100644 index 0000000..822de5a --- /dev/null +++ b/ui/ui.html @@ -0,0 +1,204 @@ + + + +
+ +| 图标 | +名称 | ++ | 大小 | +类型 | +备注 | +|
|---|---|---|---|---|---|---|
| + + | ++ + | ++ + | ++ |
+ {{.Size}}
+ |
+
+ {{.Mime}}
+ |
+
+ {{.Desc}}
+ |
+
| + + | ++ + | ++ + | ++ |
+ {{.Size}}
+ |
+
+ {{.Mime}}
+ |
+
+ {{.Desc}}
+ |
+