浏览器拿不到本地绝对路径,所以「交互式选择」做成后端列目录 + 弹窗点选: - 新增 GET /api/browse:path 为空时用程序所在目录,kind=file 连文件一起列, hidden=1 带上点开头的隐藏项;文件夹优先排序,上限 2000 项。打不开时仍返回 200 并带上 error 和 parent —— 页面据此自动向上退,不会把用户卡在一个打不开 的路径里(框里填的是文件路径时,就是这样落到它所在的目录的)。权限没放宽, 和其它管理接口一样走口令。 - 文件保存目录、成片移动到、ffmpeg 路径、手动合并的弹幕文件这四个框加了 「浏览…」按钮,输入框本身照旧可以直接填或粘贴;弹窗里的地址栏也能粘贴路径 回车跳转,点选和手填随时切换。默认不列隐藏项,需要时勾「显示隐藏项」。 - 文件夹单击进入(手机上也能用),文件单击选中、双击直接选用;连点同一项、 以及刚跳转过后的 350ms 内忽略点击,双击文件夹不会连进两层。 - 选完只标记「未保存」并填进输入框,不自动保存,和文本框的规则保持一致。 - 补上 README 里的用法和 doc/webapi.md 里的接口说明。 新增 browse_test.go(12 个用例);仓库外的 verify_pick.py 30 项断言全过, go test ./...、gofmt、go vet、构建均干净。
165 lines
4.9 KiB
Go
165 lines
4.9 KiB
Go
// web UI 的路径选择器:给设置里的目录 / 文件框提供「浏览」
|
||
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io/fs"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"sort"
|
||
"strings"
|
||
)
|
||
|
||
// 选择器里的一个条目
|
||
type browseEntry struct {
|
||
Name string `json:"name"`
|
||
Path string `json:"path"` // 完整路径,直接回填到设置里
|
||
Dir bool `json:"dir"`
|
||
Size int64 `json:"size"`
|
||
}
|
||
|
||
// 展开目录的结果
|
||
type browseResp struct {
|
||
Path string `json:"path"` // 当前展开的目录,空表示只给根列表
|
||
Parent string `json:"parent"` // 上级目录,已经到根时为空
|
||
Roots []browseEntry `json:"roots"` // 盘符 / 常用挂载点
|
||
Entries []browseEntry `json:"entries"` // 子文件夹(选文件时还含文件)
|
||
Truncated bool `json:"truncated"` // 条目太多只返回了一部分
|
||
Error string `json:"error"` // 展开失败的原因
|
||
}
|
||
|
||
// 单个目录最多返回多少项,避免几万个文件的目录把页面卡住
|
||
const browseMaxEntries = 2000
|
||
|
||
// 以点开头的当成隐藏项(Linux 下的 .config,Windows 下各类工具留下的 .cache 之类)
|
||
func browseHidden(name string) bool {
|
||
return strings.HasPrefix(name, ".")
|
||
}
|
||
|
||
// 上级目录,已经是根(盘符或 /)时返回空
|
||
func browseParent(abs string) string {
|
||
parent := filepath.Dir(abs)
|
||
if parent == abs {
|
||
return ""
|
||
}
|
||
return parent
|
||
}
|
||
|
||
// 列出 path 下的内容,wantFiles 为真时连文件一起列(选文件用)
|
||
// truncated 表示条目太多被截断了
|
||
func browseList(path string, wantFiles, showHidden bool) (entries []browseEntry, parent string, truncated bool, err error) {
|
||
abs, err := filepath.Abs(path)
|
||
if err != nil {
|
||
return nil, "", false, err
|
||
}
|
||
parent = browseParent(abs)
|
||
items, err := os.ReadDir(abs)
|
||
if err != nil {
|
||
return nil, parent, false, err
|
||
}
|
||
out := make([]browseEntry, 0, len(items))
|
||
for _, item := range items {
|
||
name := item.Name()
|
||
if !showHidden && browseHidden(name) {
|
||
continue
|
||
}
|
||
full := filepath.Join(abs, name)
|
||
isDir := item.IsDir()
|
||
// ReadDir 用的是 lstat,指向文件夹的符号链接会被当成文件,这里补一次判断
|
||
if !isDir && item.Type()&fs.ModeSymlink != 0 {
|
||
if info, err := os.Stat(full); err == nil {
|
||
isDir = info.IsDir()
|
||
}
|
||
}
|
||
if isDir {
|
||
out = append(out, browseEntry{Name: name, Path: full, Dir: true})
|
||
continue
|
||
}
|
||
if !wantFiles {
|
||
continue
|
||
}
|
||
info, err := item.Info()
|
||
if err != nil {
|
||
continue
|
||
}
|
||
out = append(out, browseEntry{Name: name, Path: full, Size: info.Size()})
|
||
}
|
||
// 文件夹排前面,同类型按名字排(Windows 上的大小写不敏感)
|
||
sort.SliceStable(out, func(i, j int) bool {
|
||
if out[i].Dir != out[j].Dir {
|
||
return out[i].Dir
|
||
}
|
||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||
})
|
||
if len(out) > browseMaxEntries {
|
||
out, truncated = out[:browseMaxEntries], true
|
||
}
|
||
if out == nil {
|
||
out = []browseEntry{}
|
||
}
|
||
return out, parent, truncated, nil
|
||
}
|
||
|
||
// 根位置:Windows 下列盘符,其它平台给几个常用的挂载点
|
||
func browseRoots() []browseEntry {
|
||
out := make([]browseEntry, 0, 10)
|
||
if runtime.GOOS == "windows" {
|
||
for c := 'A'; c <= 'Z'; c++ {
|
||
drive := string(c) + ":\\"
|
||
if info, err := os.Stat(drive); err == nil && info.IsDir() {
|
||
out = append(out, browseEntry{Name: string(c) + ":", Path: drive, Dir: true})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
for _, dir := range []string{"/", "/home", "/mnt", "/media", "/srv", "/data", "/opt", "/var/lib", "/tmp"} {
|
||
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||
out = append(out, browseEntry{Name: dir, Path: dir, Dir: true})
|
||
}
|
||
}
|
||
if home, err := os.UserHomeDir(); err == nil {
|
||
if info, err := os.Stat(home); err == nil && info.IsDir() {
|
||
out = append(out, browseEntry{Name: "主目录 " + home, Path: home, Dir: true})
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 展开目录,供页面上的「浏览」按钮用
|
||
// kind=file 时连文件一起列,hidden=1 时显示隐藏项
|
||
func browseHandler(w http.ResponseWriter, r *http.Request) {
|
||
resp := browseResp{
|
||
Roots: browseRoots(),
|
||
Entries: []browseEntry{},
|
||
}
|
||
|
||
target := strings.TrimSpace(r.URL.Query().Get("path"))
|
||
if target == "" {
|
||
// 没给路径时从程序所在目录开始,多半就是用户想选的地方
|
||
if wd, err := os.Getwd(); err == nil {
|
||
target = wd
|
||
}
|
||
}
|
||
if target != "" {
|
||
abs, err := filepath.Abs(target)
|
||
if err != nil {
|
||
resp.Path = target
|
||
resp.Error = "路径不合法:" + err.Error()
|
||
} else {
|
||
resp.Path = abs
|
||
entries, parent, truncated, err := browseList(abs, r.URL.Query().Get("kind") == "file", r.URL.Query().Get("hidden") == "1")
|
||
resp.Entries, resp.Parent, resp.Truncated = entries, parent, truncated
|
||
if err != nil {
|
||
resp.Error = "打开失败:" + err.Error()
|
||
}
|
||
}
|
||
}
|
||
|
||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||
lPrintErrf("输出目录列表失败:%v", err)
|
||
}
|
||
}
|