From 7a396a8852137adc8cc699921b33483b67a594a5 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 16:30:01 +0800 Subject: [PATCH] =?UTF-8?q?web=20UI=EF=BC=9A=E7=9B=AE=E5=BD=95=E5=92=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=97=A2=E8=83=BD=E7=82=B9=E3=80=8C=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E3=80=8D=E9=80=90=E7=BA=A7=E9=80=89=EF=BC=8C=E4=B9=9F?= =?UTF-8?q?=E8=83=BD=E6=89=8B=E5=B7=A5=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 浏览器拿不到本地绝对路径,所以「交互式选择」做成后端列目录 + 弹窗点选: - 新增 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、构建均干净。 --- README.md | 2 + browse.go | 164 +++++++++++++++++++++++++++++++ browse_test.go | 254 +++++++++++++++++++++++++++++++++++++++++++++++++ doc/webapi.md | 17 ++++ webui.go | 180 ++++++++++++++++++++++++++++++++++- 5 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 browse.go create mode 100644 browse_test.go diff --git a/README.md b/README.md index 026a945..cf381c1 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,8 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`), 开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 +目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。 + 页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。 顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。 diff --git a/browse.go b/browse.go new file mode 100644 index 0000000..d1f3645 --- /dev/null +++ b/browse.go @@ -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) + } +} diff --git a/browse_test.go b/browse_test.go new file mode 100644 index 0000000..f12aecd --- /dev/null +++ b/browse_test.go @@ -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("程序所在目录不该是空的") + } +} diff --git a/doc/webapi.md b/doc/webapi.md index 6706def..7724e76 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -109,3 +109,20 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。 MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。 + +`GET /api/browse` 给页面上的`浏览`按钮列目录用。参数:`path`(要展开的目录,不填就用程序当前的工作目录)、`kind=file`(连文件一起列,挑文件时用)、`hidden=1`(连以点开头的隐藏项一起列)。 + +``` +{ + "path": "D:\\record", // 当前展开的目录,已转成绝对路径 + "parent": "D:\\", // 上级目录,已经到根时是空串 + "roots": [{"name": "C:", "path": "C:\\", "dir": true, "size": 0}], + "entries": [{"name": "2026-09-25.mp4", "path": "D:\\record\\2026-09-25.mp4", "dir": false, "size": 123}], + "truncated": false, // 条目太多时只返回前 2000 项 + "error": "" // 打不开时的中文原因 +} +``` + +文件夹排在文件前面,同类按名字排(不区分大小写)。挑文件时文件夹照样会列出来,不然进不去子目录。`error`非空时仍是 HTTP 200,`entries`是空的,但`parent`会带上——页面据此退到上一级,不会把用户卡在一个打不开的路径里(比如把文件路径当目录展开时)。 + +这个接口能看到整台机器的目录,但权限和其它管理接口一样,都是拿到口令即全部权限,没有放宽。 diff --git a/webui.go b/webui.go index efe3e9c..c719953 100644 --- a/webui.go +++ b/webui.go @@ -155,6 +155,26 @@ const webuiHTML = ` .mask h3{margin:0 0 6px;font-size:15px} .mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65} #authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px} + /* 路径输入框:手工填和点「浏览」并存 */ + .pathf{display:flex;gap:8px;align-items:center} + .pathf input{flex:1 1 auto;min-width:0;max-width:none} + .pathf button{flex:none} + /* 路径选择弹窗 */ + #pick .box{max-width:620px} + #pickPath{font-size:12.5px} + .roots{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px} + .roots:empty{display:none} + .roots button{background:var(--soft);color:var(--text);font-size:12px;padding:4px 10px;border-radius:999px} + .roots button:hover{background:var(--soft2)} + .plist{max-height:44vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-s);background:var(--soft2)} + .plist .off{padding:10px} + .pi{display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:13px;cursor:pointer} + .pi+.pi{border-top:1px solid var(--line)} + .pi:hover{background:var(--card)} + .pi.on{background:var(--accent-soft);color:var(--accent)} + .pi-n{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} + .pi-dir .pi-n{font-weight:600} + .pi-m{flex:none;font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums} .only-m{display:none} @media (max-width:700px){ /* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */ @@ -227,7 +247,12 @@ const webuiHTML = `

保存位置

录播、弹幕和成片都放在这里
-
+
+
+ + +
+
@@ -243,7 +268,12 @@ const webuiHTML = `
-
+
+
+ + +
+
录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。
录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。
@@ -265,7 +295,12 @@ const webuiHTML = `

成片输出

-
+
+
+ + +
+
文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。
@@ -280,7 +315,10 @@ const webuiHTML = `
- +
+ + +
@@ -400,6 +438,25 @@ const webuiHTML = `
+
+
+

选择文件夹

+

逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。

+
+ + + +
+
+
+
+
+ + + +
+
+