web UI:没提交的文本框不再被状态刷新刷回原值

页面每 3 秒拉一次 /api/status 回填表单,而回填只跳过「正在聚焦」的元素,
所以文本框改完点一下别处(或按回车,输入框不在 form 里根本不会提交),
几秒后就被服务端的旧值盖回去,看起来像「改了又自己恢复」——
改合成码率时的现象就是输入框里明明是 6500、过了 3 秒自己变回 1500。
服务端日志里一条 6500 的保存记录都没有,全是前端盖的。

- 表单字段加上「未提交」标记,定时刷新不再覆盖;提交成功才清掉
- 回车等于点保存设置(主播码率框回车即提交,新增 uid 回车即添加)
- 有没提交的内容时顶栏提示「有未保存的修改」,保存按钮右上角标红点,
  离开页面前再问一次
- 保存失败保留用户填的内容,不再静默回滚,提示里带上服务端给的原因
- 开关、下拉的立即保存用服务端值填还没提交的字段,避免一个框填错
  (如成片目录不存在)把开关也一起打回去
- 主播列表里的码率框:重建列表时保留用户改过还没提交的值,填错立刻
  回填服务端值
This commit is contained in:
tamakyi
2026-09-25 15:50:34 +08:00
parent 470b7668ac
commit 0dfa224bac
2 changed files with 120 additions and 45 deletions
+3 -1
View File
@@ -156,7 +156,9 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
| 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 文件 | 磁盘剩余容量、录播和成片的文件列表 |
| 设置 | 是否允许外网访问、管理口令的查看与更换 | | 设置 | 是否允许外网访问、管理口令的查看与更换 |
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。 顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
+117 -44
View File
@@ -67,6 +67,11 @@ const webuiHTML = `<!DOCTYPE html>
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none} .dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none}
.dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)} .dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
.dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)} .dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)}
.dot.warn{background:var(--bad);box-shadow:0 0 0 3px var(--accent-soft)}
/* 有没提交的修改时把保存按钮标出来,手机上光看输入框看不出来 */
#saveBtn{position:relative}
#saveBtn.dirty{box-shadow:0 0 0 3px var(--accent-soft)}
#saveBtn.dirty::after{content:"";position:absolute;top:-4px;right:-4px;width:9px;height:9px;border-radius:50%;background:var(--bad);border:2px solid var(--card)}
/* 分区导航 */ /* 分区导航 */
.tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none} .tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
.tabs::-webkit-scrollbar{display:none} .tabs::-webkit-scrollbar{display:none}
@@ -181,6 +186,7 @@ const webuiHTML = `<!DOCTYPE html>
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div> <div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
</div> </div>
<div class="top-act"> <div class="top-act">
<span class="stat only-d" id="dirtyTip" style="display:none"><i class="dot warn"></i>有未保存的修改</span>
<span class="stat" id="topStat"></span> <span class="stat" id="topStat"></span>
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button> <button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
</div> </div>
@@ -225,7 +231,7 @@ const webuiHTML = `<!DOCTYPE html>
<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>
<div class="hint">文本框改完点右上角「保存设置」生效,开关和下拉框改完立即生效。</div> <div class="hint">文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。</div>
</div> </div>
<div class="card"> <div class="card">
<div class="card-h"><h2>录制参数</h2></div> <div class="card-h"><h2>录制参数</h2></div>
@@ -262,7 +268,7 @@ const webuiHTML = `<!DOCTYPE html>
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div> <div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></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">文本框改完点右上角「保存设置」生效。</div> <div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
</div> </div>
<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>
@@ -341,7 +347,7 @@ const webuiHTML = `<!DOCTYPE html>
<div class="hint bad" id="meowBlocked" style="display:none"></div> <div class="hint bad" id="meowBlocked" style="display:none"></div>
<div class="bar"> <div class="bar">
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button> <button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span> <span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」</span>
</div> </div>
</div> </div>
</section> </section>
@@ -402,8 +408,52 @@ var logOffset = "0"; // 已经读取到的日志偏移量
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地 var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
var authed = false; // 是否已通过口令校验,未通过时停止轮询 var authed = false; // 是否已通过口令校验,未通过时停止轮询
var tokenLoaded = false; var tokenLoaded = false;
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; } // 设置表单里的字段,元素 id 与 /api/config 的字段名一致
function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; } var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
var numberFields = {mergeBitrate:1, defaultBitrate:1, meowHtmlHeight:1};
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
var dirty = {};
var lastSt = null; // 最近一次 /api/status 的结果,自动保存时用它填没提交的字段
function dirtyCount(){ return Object.keys(dirty).length; }
function markDirty(id){ if(id && !dirty[id]){ dirty[id] = true; syncDirtyUI(); } }
function dropDirty(id){ if(dirty[id]){ delete dirty[id]; syncDirtyUI(); } }
function syncDirtyUI(){
var n = dirtyCount();
var tip = document.getElementById("dirtyTip");
if(tip) tip.style.display = n ? "" : "none";
var b = document.getElementById("saveBtn");
if(b) b.classList.toggle("dirty", n > 0);
}
// 提交成功后才把对应的未保存标记清掉:值还是提交时那个才清,避免把提交期间的新改动也当成已保存
function clearSaved(body){
settingFields.forEach(function(id){
if(dirty[id] && String(fieldValue(id)).trim() === String(body[id]).trim()) delete dirty[id];
});
syncDirtyUI();
}
function fieldValue(id){
var el = document.getElementById(id);
if(!el) return "";
return el.type === "checkbox" ? !!el.checked : el.value;
}
// 按当前表单拼 /api/config 的请求体;serverForDirty 为真时,没提交的字段用服务端的值顶上
// (开关、下拉是改完立即保存的,不该被别的框里填了一半的内容连累到整批失败)
function settingsBody(serverForDirty){
var body = {};
settingFields.forEach(function(id){
var v = fieldValue(id);
if(typeof v === "string"){
v = v.trim();
if(numberFields[id]) v = parseInt(v, 10) || 0;
}
if(serverForDirty && dirty[id] && lastSt && lastSt[id] !== undefined) v = lastSt[id];
body[id] = v;
});
return body;
}
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && !el.matches(":focus")) el.checked = !!v; }
function setVal(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && document.activeElement !== el) el.value = v; }
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; } function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
// 管理接口都要带上口令 // 管理接口都要带上口令
function api(url, opt){ function api(url, opt){
@@ -510,6 +560,7 @@ async function refresh(){
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok) return; if(!r.ok) return;
var st = await r.json(); var st = await r.json();
lastSt = st;
hideLogin(); hideLogin();
// 口令只在登录后拉取一次,填进设置区的只读框 // 口令只在登录后拉取一次,填进设置区的只读框
if(!tokenLoaded){ if(!tokenLoaded){
@@ -583,7 +634,17 @@ async function refresh(){
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉 // 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
if(sig !== stSig && !list.contains(document.activeElement)){ if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig; stSig = sig;
// 重建前把用户改过、还没提交的码率记下来,建完再抄回去(填错的不用留)
var keepBr = {};
Array.prototype.forEach.call(list.querySelectorAll("input.br"), function(el){
var v = parseInt(el.value.trim(), 10);
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepBr[el.dataset.uid] = el.value;
});
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>'; list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
Object.keys(keepBr).forEach(function(uid){
var el = list.querySelector('input.br[data-uid="' + uid + '"]');
if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; }
});
} }
var lr = await api("/api/log?since=" + logOffset); var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
@@ -615,12 +676,12 @@ function showMsg(ok, text){
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220); setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
}, life); }, life);
} }
async function post(url, body){ async function post(url, body, errPrefix){
saving = true; saving = true;
try{ try{
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; } if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
if(!r.ok) showMsg(false, await r.text()); if(!r.ok) showMsg(false, (errPrefix || "") + await r.text());
return r.ok; return r.ok;
}finally{ }finally{
saving = false; saving = false;
@@ -629,50 +690,52 @@ async function post(url, body){
loadFiles(); loadFiles();
} }
} }
async function saveSettings(b){ async function saveSettings(b, autoSave){
if(b) b.disabled = true; if(b) b.disabled = true;
var ok = await post("/api/config", { var body = settingsBody(!!autoSave);
directory: document.getElementById("directory").value.trim(), var ok = await post("/api/config", body, autoSave ? "保存失败,这次改动没生效:" : "");
autoMerge: document.getElementById("autoMerge").checked, if(ok){
mergeDelVideo: document.getElementById("mergeDelVideo").checked, clearSaved(body);
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked, showMsg(true, "已保存");
mergeOutputDir: document.getElementById("mergeOutputDir").value.trim(), }
mergeBitrate: parseInt(document.getElementById("mergeBitrate").value, 10) || 0,
defaultBitrate: parseInt(document.getElementById("defaultBitrate").value, 10) || 0,
recordSubdir: document.getElementById("recordSubdir").value.trim(),
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
recordFormat: document.getElementById("recordFormat").value,
notifyEnabled: document.getElementById("notifyEnabled").checked,
notifyUrl: document.getElementById("notifyUrl").value.trim(),
notifyToken: document.getElementById("notifyToken").value.trim(),
notifyGroup: document.getElementById("notifyGroup").value.trim(),
notifyLiveOn: document.getElementById("notifyLiveOn").checked,
notifyLiveOff: document.getElementById("notifyLiveOff").checked,
notifyRecordStart: document.getElementById("notifyRecordStart").checked,
notifyMergeDone: document.getElementById("notifyMergeDone").checked,
meowEnabled: document.getElementById("meowEnabled").checked,
meowNickname: document.getElementById("meowNickname").value.trim(),
meowMsgType: document.getElementById("meowMsgType").value,
meowHtmlHeight: parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0,
meowImgUrl: document.getElementById("meowImgUrl").value.trim(),
webExternal: document.getElementById("webExternal").checked
});
if(ok) showMsg(true, "已保存");
if(b) b.disabled = false; if(b) b.disabled = false;
} }
// 勾选类设置修改后立即保存,避免被定时刷新还原 // 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){ ["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ saveSettings(null); }); document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
}); });
// 下拉框改完也立即保存 // 下拉框改完也立即保存
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); }); ["recordFormat","meowMsgType"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
});
// HTML 高度只对 html 类型有意义,选 text 时收起来 // HTML 高度只对 html 类型有意义,选 text 时收起来
function toggleMeowHeight(){ function toggleMeowHeight(){
var isHTML = document.getElementById("meowMsgType").value === "html"; var isHTML = document.getElementById("meowMsgType").value === "html";
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none"; document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
} }
document.getElementById("meowMsgType").addEventListener("change", function(){ toggleMeowHeight(); saveSettings(null); }); document.getElementById("meowMsgType").addEventListener("change", toggleMeowHeight);
// 文本类字段一改就记成「未保存」,定时刷新不再盖掉它,直到保存成功
document.addEventListener("input", function(e){
var el = e.target;
if(!el) return;
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; return; }
if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
});
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
document.addEventListener("keydown", function(e){
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
var el = e.target;
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return;
if(el.classList.contains("br")){ el.blur(); return; } // 码率失焦即提交
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); return; }
if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); }
});
// 还有没保存的改动时提醒一句,别直接关掉页面
window.addEventListener("beforeunload", function(e){
if(dirtyCount() > 0){ e.preventDefault(); e.returnValue = ""; return ""; }
});
var fileList = []; var fileList = [];
function fmtSize(n){ function fmtSize(n){
if(!n) return "0 B"; if(!n) return "0 B";
@@ -879,24 +942,34 @@ async function delUid(uid){
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return; if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid}); post("/api/streamer/remove", {uid: uid});
} }
document.addEventListener("change", function(e){ // 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建
function restoreBr(el){
var uid = parseInt(el.dataset.uid, 10);
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
delete el.dataset.dirty;
el.value = cur ? (cur.bitrate || 0) : 0;
}
document.addEventListener("change", async function(e){
var t = e.target; var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){ if(t.matches('input[type=checkbox][data-uid]')){
var uid = parseInt(t.dataset.uid, 10); var uid = parseInt(t.dataset.uid, 10);
var row = t.closest(".row"); var row = t.closest(".row");
var danmu = row.querySelector('input[data-k=danmu]').checked; var danmu = row.querySelector('input[data-k=danmu]').checked;
var record = row.querySelector('input[data-k=record]').checked; var record = row.querySelector('input[data-k=record]').checked;
var bitrate = parseInt(row.querySelector('input.br').value, 10) || 0; var brEl = row.querySelector('input.br');
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}); var bitrate = parseInt(brEl.value, 10) || 0;
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate})) delete brEl.dataset.dirty;
} }
if(t.matches('input.br')){ if(t.matches('input.br')){
var uid2 = parseInt(t.dataset.uid, 10); var uid2 = parseInt(t.dataset.uid, 10);
var row2 = t.closest(".row"); var row2 = t.closest(".row");
var v = parseInt(t.value.trim(), 10); var v = parseInt(t.value.trim(), 10);
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; } if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); restoreBr(t); return; }
var danmu2 = row2.querySelector('input[data-k=danmu]').checked; var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
var record2 = row2.querySelector('input[data-k=record]').checked; var record2 = row2.querySelector('input[data-k=record]').checked;
post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v}); // 提交成功才能把「未提交」标记去掉,列表重建时才不会把它当成用户还在填
if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty;
} }
}); });
// 回到地址栏或上次看的那个分区 // 回到地址栏或上次看的那个分区