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
+176 -4
View File
@@ -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 {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[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)