Files
tamakyi 7a396a8852 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、构建均干净。
2026-09-25 16:30:01 +08:00

165 lines
4.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
}
}