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:
@@ -158,6 +158,8 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
|
||||
|
||||
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
||||
|
||||
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
|
||||
|
||||
页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。
|
||||
|
||||
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
|
||||
|
||||
@@ -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
@@ -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("程序所在目录不该是空的")
|
||||
}
|
||||
}
|
||||
@@ -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`会带上——页面据此退到上一级,不会把用户卡在一个打不开的路径里(比如把文件路径当目录展开时)。
|
||||
|
||||
这个接口能看到整台机器的目录,但权限和其它管理接口一样,都是拿到口令即全部权限,没有放宽。
|
||||
|
||||
@@ -155,6 +155,26 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
.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 = `<!DOCTYPE html>
|
||||
<div class="card">
|
||||
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
|
||||
<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="mergeSubdir" placeholder="merged"></div>
|
||||
</div>
|
||||
@@ -243,7 +268,12 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
<option value="mkv">mkv(可装多音轨)</option>
|
||||
</select></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 class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</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-h"><h2>成片输出</h2></div>
|
||||
<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>
|
||||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
||||
@@ -280,7 +315,10 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
</div>
|
||||
<div class="f">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,6 +438,25 @@ const webuiHTML = `<!DOCTYPE html>
|
||||
<div id="authMsg"></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>
|
||||
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); };
|
||||
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
|
||||
@@ -762,6 +819,7 @@ async function loadFiles(b){
|
||||
if(!r.ok){ showMsg(false, await r.text()); return; }
|
||||
var d = await r.json();
|
||||
fileList = d.files || [];
|
||||
recDir = d.recordDir || "";
|
||||
fillMergePickers();
|
||||
document.getElementById("diskInfo").textContent =
|
||||
"磁盘剩余 " + 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() : "连接失败")));
|
||||
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 一起刷新
|
||||
function mrgPayload(){
|
||||
@@ -1572,6 +1743,7 @@ func startWebUI(ctx context.Context) {
|
||||
mux.HandleFunc("GET /api/log", logHandler)
|
||||
mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
|
||||
mux.HandleFunc("GET /api/files", filesHandler)
|
||||
mux.HandleFunc("GET /api/browse", browseHandler)
|
||||
mux.HandleFunc("GET /api/file", fileDownloadHandler)
|
||||
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
|
||||
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
|
||||
|
||||
Reference in New Issue
Block a user