web UI:目录和文件既能点「浏览」逐级选,也能手工填

浏览器拿不到本地绝对路径,所以「交互式选择」做成后端列目录 + 弹窗点选:

- 新增 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、构建均干净。
This commit is contained in:
tamakyi
2026-09-25 16:30:01 +08:00
parent 0dfa224bac
commit 7a396a8852
5 changed files with 613 additions and 4 deletions
+164
View File
@@ -0,0 +1,164 @@
// 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)
}
}