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:
+254
@@ -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("程序所在目录不该是空的")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user