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
+254
View File
@@ -0,0 +1,254 @@
// 路径选择器相关
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"testing"
)
// 造一棵目录树:两个名字大小写不同的文件夹、两个文件、一个隐藏文件夹
func setupBrowseTree(t *testing.T) string {
t.Helper()
dir := t.TempDir()
for _, sub := range []string{"beta", "Alpha", ".hidden"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0755); err != nil {
t.Fatal(err)
}
}
for _, f := range []string{"b.txt", "a.mp4"} {
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
}
return dir
}
func browseNames(entries []browseEntry) []string {
out := make([]string, 0, len(entries))
for _, e := range entries {
out = append(out, e.Name)
}
return out
}
func browseHas(entries []browseEntry, name string) bool {
for _, e := range entries {
if e.Name == name {
return true
}
}
return false
}
func doBrowseQuery(t *testing.T, v url.Values) browseResp {
t.Helper()
q := ""
if v != nil {
q = "?" + v.Encode()
}
rec := httptest.NewRecorder()
browseHandler(rec, httptest.NewRequest(http.MethodGet, "/api/browse"+q, nil))
if rec.Code != http.StatusOK {
t.Fatalf("状态码是 %d,想要 200", rec.Code)
}
var resp browseResp
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("解析响应失败:%v(%s)", err, rec.Body.String())
}
return resp
}
func doBrowse(t *testing.T, path, kind string, hidden bool) browseResp {
t.Helper()
v := url.Values{}
if path != "" {
v.Set("path", path)
}
if kind != "" {
v.Set("kind", kind)
}
if hidden {
v.Set("hidden", "1")
}
return doBrowseQuery(t, v)
}
func TestBrowseListDirsOnly(t *testing.T) {
dir := setupBrowseTree(t)
entries, parent, truncated, err := browseList(dir, false, false)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
if truncated {
t.Fatal("条目没超上限,不该标成截断")
}
if parent != filepath.Dir(dir) {
t.Fatalf("上级目录不对:%q", parent)
}
want := []string{"Alpha", "beta"}
if len(entries) != len(want) {
t.Fatalf("只该列出文件夹:%q", browseNames(entries))
}
for i, e := range entries {
if e.Name != want[i] {
t.Fatalf("第 %d 项是 %q,想要 %q(文件夹排前面、按名字不区分大小写排)", i, e.Name, want[i])
}
if !e.Dir || e.Path != filepath.Join(dir, e.Name) {
t.Fatalf("条目内容不对:%+v", e)
}
}
}
func TestBrowseListWithFiles(t *testing.T) {
dir := setupBrowseTree(t)
entries, _, _, err := browseList(dir, true, false)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
want := []string{"Alpha", "beta", "a.mp4", "b.txt"}
if len(entries) != len(want) {
t.Fatalf("条目不对:%q", browseNames(entries))
}
for i, e := range entries {
if e.Name != want[i] {
t.Fatalf("第 %d 项是 %q,想要 %q", i, e.Name, want[i])
}
}
// 选文件时文件夹也要列出来,不然没法点进去
if !entries[0].Dir {
t.Fatal("选文件时也该列出文件夹")
}
if entries[2].Dir || entries[2].Size != 1 {
t.Fatalf("文件条目不对:%+v", entries[2])
}
}
func TestBrowseListHidden(t *testing.T) {
dir := setupBrowseTree(t)
entries, _, _, err := browseList(dir, false, false)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
if browseHas(entries, ".hidden") {
t.Fatal("默认不该列出隐藏文件夹")
}
entries, _, _, err = browseList(dir, false, true)
if err != nil {
t.Fatalf("列目录失败:%v", err)
}
if !browseHas(entries, ".hidden") {
t.Fatalf("开了显示隐藏项就该列出来:%q", browseNames(entries))
}
}
func TestBrowseParentAtRoot(t *testing.T) {
root := "/"
if runtime.GOOS == "windows" {
root = `C:\`
}
if got := browseParent(root); got != "" {
t.Fatalf("根位置不该有上级:%q", got)
}
if got := browseParent(filepath.Join(root, "a")); got != root {
t.Fatalf("子目录的上级应该是根:%q", got)
}
}
func TestBrowseRootsNotEmpty(t *testing.T) {
if len(browseRoots()) == 0 {
t.Fatal("根列表不该是空的,否则用户没法从盘符开始选")
}
}
func TestBrowseHandlerDir(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, dir, "dir", false)
if resp.Error != "" {
t.Fatalf("不该报错:%s", resp.Error)
}
if resp.Path != dir {
t.Fatalf("回显的路径不对:%q", resp.Path)
}
if resp.Parent != filepath.Dir(dir) {
t.Fatalf("上级目录不对:%q", resp.Parent)
}
if len(resp.Roots) == 0 {
t.Fatal("根列表不该为空")
}
if got := browseNames(resp.Entries); len(got) != 2 || got[0] != "Alpha" || got[1] != "beta" {
t.Fatalf("条目不对:%q", got)
}
}
func TestBrowseHandlerFileKind(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, dir, "file", false)
if resp.Error != "" {
t.Fatalf("不该报错:%s", resp.Error)
}
if !browseHas(resp.Entries, "a.mp4") || !browseHas(resp.Entries, "b.txt") {
t.Fatalf("选文件时该列出文件:%q", browseNames(resp.Entries))
}
for _, e := range resp.Entries {
if e.Name == "a.mp4" {
if e.Dir {
t.Fatal("文件不该被标成文件夹")
}
if e.Path != filepath.Join(dir, "a.mp4") {
t.Fatalf("文件路径不对:%q", e.Path)
}
}
}
}
func TestBrowseHandlerMissingPath(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, filepath.Join(dir, "没有这个目录"), "dir", false)
if resp.Error == "" {
t.Fatal("不存在的目录要给出错误提示,而不是静默返回空列表")
}
// 打不开的时候仍要能往上走,不然用户就卡在一个不存在的路径里了
if resp.Parent != dir {
t.Fatalf("错误时也该带上上级目录:%q", resp.Parent)
}
if len(resp.Entries) != 0 {
t.Fatalf("打不开的目录不该有内容:%q", browseNames(resp.Entries))
}
}
func TestBrowseHandlerPathIsFile(t *testing.T) {
dir := setupBrowseTree(t)
resp := doBrowse(t, filepath.Join(dir, "a.mp4"), "dir", false)
if resp.Error == "" {
t.Fatal("把文件当目录展开应该报错")
}
}
func TestBrowseHandlerEmptyPathUsesWorkDir(t *testing.T) {
resp := doBrowse(t, "", "dir", false)
wd, err := os.Getwd()
if err != nil {
t.Skip("取不到工作目录")
}
if normalizePath(resp.Path) != normalizePath(wd) {
t.Fatalf("没给路径时该从程序所在目录开始:%q", resp.Path)
}
// 程序目录里至少有自己的源码,不该是空的
if len(resp.Entries) == 0 {
t.Fatal("程序所在目录不该是空的")
}
}