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

255 lines
6.4 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.
// 路径选择器相关
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("程序所在目录不该是空的")
}
}