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
+2
View File
@@ -158,6 +158,8 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。 页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。 顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
+164
View File
@@ -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)
}
}
+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("程序所在目录不该是空的")
}
}
+17
View File
@@ -109,3 +109,20 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。 成功后返回`{"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,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。 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`会带上——页面据此退到上一级,不会把用户卡在一个打不开的路径里(比如把文件路径当目录展开时)。
这个接口能看到整台机器的目录,但权限和其它管理接口一样,都是拿到口令即全部权限,没有放宽。
+176 -4
View File
@@ -155,6 +155,26 @@ const webuiHTML = `<!DOCTYPE html>
.mask h3{margin:0 0 6px;font-size:15px} .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} .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} #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} .only-m{display:none}
@media (max-width:700px){ @media (max-width:700px){
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */ /* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
@@ -227,7 +247,12 @@ const webuiHTML = `<!DOCTYPE html>
<div class="card"> <div class="card">
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div> <div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
<div class="grid"> <div class="grid">
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div> <div class="f"><label>文件保存目录(留空 = 程序所在目录)</label>
<div class="pathf">
<input type="text" id="directory" placeholder="例如 D:\record">
<button class="ghost" onclick="openPick('directory','dir')">浏览…</button>
</div>
</div>
<div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div> <div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
<div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div> <div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
</div> </div>
@@ -243,7 +268,12 @@ const webuiHTML = `<!DOCTYPE html>
<option value="mkv">mkv(可装多音轨)</option> <option value="mkv">mkv(可装多音轨)</option>
</select></div> </select></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div> <div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div> <div class="f"><label>ffmpeg 路径(留空自动查找)</label>
<div class="pathf">
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
<button class="ghost" onclick="openPick('ffmpegPath','file')">浏览…</button>
</div>
</div>
</div> </div>
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div> <div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div> <div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
@@ -265,7 +295,12 @@ const webuiHTML = `<!DOCTYPE html>
<div class="card"> <div class="card">
<div class="card-h"><h2>成片输出</h2></div> <div class="card-h"><h2>成片输出</h2></div>
<div class="grid"> <div class="grid">
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div> <div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label>
<div class="pathf">
<input type="text" id="mergeOutputDir" placeholder="例如 D:\merged">
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
</div>
</div>
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div> <div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
</div> </div>
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div> <div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
@@ -280,7 +315,10 @@ const webuiHTML = `<!DOCTYPE html>
</div> </div>
<div class="f"> <div class="f">
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label> <label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl"> <div class="pathf">
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
</div>
<datalist id="mrgDanmuList"></datalist> <datalist id="mrgDanmuList"></datalist>
</div> </div>
</div> </div>
@@ -400,6 +438,25 @@ const webuiHTML = `<!DOCTYPE html>
<div id="authMsg"></div> <div id="authMsg"></div>
</div> </div>
</div> </div>
<div class="mask" id="pick">
<div class="box pbox">
<h3 id="pickTitle">选择文件夹</h3>
<p id="pickDesc">逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。</p>
<div class="pathf" style="margin-bottom:8px">
<button class="ghost" id="pickUp" onclick="pickUp()">↑ 上一级</button>
<input type="text" id="pickPath" spellcheck="false" autocomplete="off" placeholder="也可以直接填或粘贴路径,回车跳转">
<button class="ghost" onclick="pickGo(document.getElementById('pickPath').value)">转到</button>
</div>
<div class="roots" id="pickRoots"></div>
<div class="plist" id="pickList"></div>
<div class="hint" id="pickMsg"></div>
<div class="bar" style="margin-top:12px">
<label class="sw" style="margin-right:auto"><input type="checkbox" id="pickHidden"><span class="sw-i"></span>显示隐藏项</label>
<button class="ghost" onclick="closePick()">取消</button>
<button id="pickOk" onclick="pickConfirm()">选用这个文件夹</button>
</div>
</div>
</div>
<script> <script>
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); }; var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); };
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写 var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
@@ -762,6 +819,7 @@ async function loadFiles(b){
if(!r.ok){ showMsg(false, await r.text()); return; } if(!r.ok){ showMsg(false, await r.text()); return; }
var d = await r.json(); var d = await r.json();
fileList = d.files || []; fileList = d.files || [];
recDir = d.recordDir || "";
fillMergePickers(); fillMergePickers();
document.getElementById("diskInfo").textContent = document.getElementById("diskInfo").textContent =
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) + "磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
@@ -798,6 +856,119 @@ async function delFile(i){
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败"))); showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
loadFiles(); loadFiles();
} }
// ========== 路径选择器 ==========
// 目录 / 文件框两种填法并存:自己敲或粘贴,或者点「浏览…」逐级点选
var pickField = ""; // 选中的路径填回哪个输入框
var pickKind = "dir"; // dir 选文件夹,file 选文件
var pickCur = ""; // 当前所在目录
var pickParent = ""; // 上一级
var pickFile = ""; // 选文件时选中的那个
var recDir = ""; // 录播目录,挑弹幕文件时从它开始
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
function openPick(id, kind){
var el = document.getElementById(id);
pickField = id;
pickKind = kind === "file" ? "file" : "dir";
pickFile = "";
document.getElementById("pickTitle").textContent = pickKind === "file" ? "选择文件" : "选择文件夹";
document.getElementById("pickOk").textContent = pickKind === "file" ? "选用这个文件" : "选用这个文件夹";
document.getElementById("pickHidden").checked = false;
document.getElementById("pick").classList.add("on");
var start = el ? el.value.trim() : "";
// 框里是文件名而不是路径时(弹幕名这种)不算路径:弹幕框从录播目录开始,其它从程序所在目录开始
if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = id === "mrgDanmu" ? recDir : "";
pickGo(start);
}
function closePick(){ document.getElementById("pick").classList.remove("on"); }
function pickUp(){ if(pickParent) pickGo(pickParent); }
async function pickGo(path){
lastNavAt = Date.now();
var msg = document.getElementById("pickMsg");
msg.textContent = "";
var base = "/api/browse?kind=" + pickKind + (document.getElementById("pickHidden").checked ? "&hidden=1" : "");
var d = null, fellBack = false;
// 框里填的是文件、或者目录根本不存在时,往上退到能打开的一层,别停在空列表上
for(var i = 0; i < 6; i++){
var url = base + (path ? "&path=" + encodeURIComponent(path) : "");
var r = null;
try{ r = await api(url); }catch(e){}
if(!r){ msg.textContent = "读取失败,连接不上服务器"; return; }
if(r.status === 401){ closePick(); showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok){ msg.textContent = "读取失败:" + await r.text(); return; }
d = await r.json();
if(!d.error || !d.parent) break;
path = d.parent;
fellBack = true;
}
var items = d.entries || [];
pickCur = d.path || "";
pickParent = d.parent || "";
// 换了目录之后之前选中的文件就不在当前视图里了,清掉
if(pickFile && !items.some(function(x){ return x.path === pickFile; })) pickFile = "";
document.getElementById("pickPath").value = pickCur;
document.getElementById("pickUp").disabled = !pickParent;
document.getElementById("pickRoots").innerHTML = (d.roots || []).map(function(x){
return '<button type="button" data-path="' + esc(x.path) + '">' + esc(x.name) + '</button>';
}).join("");
var list = document.getElementById("pickList");
if(!items.length){
list.innerHTML = '<div class="off">' + (pickKind === "file" ? "这里没有可选项" : "这里没有子文件夹") + '</div>';
}else{
list.innerHTML = items.map(function(x){
var on = (!x.dir && x.path === pickFile) ? " on" : "";
return '<div class="pi' + (x.dir ? " pi-dir" : "") + on + '" data-path="' + esc(x.path) + '" data-dir="' + (x.dir ? "1" : "0") + '">'
+ '<span class="pi-n" title="' + esc(x.path) + '">' + esc(x.name) + '</span>'
+ '<span class="pi-m">' + (x.dir ? "文件夹" : fmtSize(x.size)) + '</span></div>';
}).join("");
}
document.getElementById("pickOk").disabled = pickKind === "file" && !pickFile;
if(d.error){
msg.textContent = d.error;
}else if(fellBack){
msg.textContent = "原来填的位置打不开,已经跳到能打开的上级目录";
}else if(d.truncated){
msg.textContent = "条目太多,只列出了前 2000 项";
}
}
function pickConfirm(){
var v = pickKind === "file" ? pickFile : pickCur;
if(!v) return;
var el = document.getElementById(pickField);
if(el){ el.value = v; markDirty(pickField); }
closePick();
}
document.getElementById("pickList").addEventListener("click", function(e){
var it = e.target.closest(".pi");
if(!it) return;
if(Date.now() - lastNavAt < 350) return;
// 双击时浏览器会先发两次 click,同一条目短时间内重复点击只算一次
if(it.dataset.path === lastPickKey && Date.now() - lastPickAt < 350) return;
lastPickKey = it.dataset.path;
lastPickAt = Date.now();
if(it.dataset.dir === "1"){ pickGo(it.dataset.path); return; }
pickFile = it.dataset.path;
var all = this.querySelectorAll(".pi");
for(var i = 0; i < all.length; i++) all[i].classList.toggle("on", all[i] === it);
document.getElementById("pickOk").disabled = false;
});
// 文件双击直接选用
document.getElementById("pickList").addEventListener("dblclick", function(e){
var it = e.target.closest(".pi");
if(it && it.dataset.dir === "0") pickConfirm();
});
document.getElementById("pickRoots").addEventListener("click", function(e){
var b = e.target.closest("button");
if(b) pickGo(b.dataset.path);
});
document.getElementById("pickHidden").addEventListener("change", function(){ pickGo(pickCur); });
document.getElementById("pickPath").addEventListener("keydown", function(e){
if(e.key === "Enter"){ e.preventDefault(); pickGo(this.value.trim()); }
});
document.addEventListener("keydown", function(e){
if(e.key === "Escape" && document.getElementById("pick").classList.contains("on")) closePick();
});
// ========== 手动合并 ========== // ========== 手动合并 ==========
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新 var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
function mrgPayload(){ function mrgPayload(){
@@ -1572,6 +1743,7 @@ func startWebUI(ctx context.Context) {
mux.HandleFunc("GET /api/log", logHandler) mux.HandleFunc("GET /api/log", logHandler)
mux.HandleFunc("POST /api/notify/test", notifyTestHandler) mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
mux.HandleFunc("GET /api/files", filesHandler) mux.HandleFunc("GET /api/files", filesHandler)
mux.HandleFunc("GET /api/browse", browseHandler)
mux.HandleFunc("GET /api/file", fileDownloadHandler) mux.HandleFunc("GET /api/file", fileDownloadHandler)
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler) mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler) mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)