web UI:没提交的文本框不再被状态刷新刷回原值
页面每 3 秒拉一次 /api/status 回填表单,而回填只跳过「正在聚焦」的元素, 所以文本框改完点一下别处(或按回车,输入框不在 form 里根本不会提交), 几秒后就被服务端的旧值盖回去,看起来像「改了又自己恢复」—— 改合成码率时的现象就是输入框里明明是 6500、过了 3 秒自己变回 1500。 服务端日志里一条 6500 的保存记录都没有,全是前端盖的。 - 表单字段加上「未提交」标记,定时刷新不再覆盖;提交成功才清掉 - 回车等于点保存设置(主播码率框回车即提交,新增 uid 回车即添加) - 有没提交的内容时顶栏提示「有未保存的修改」,保存按钮右上角标红点, 离开页面前再问一次 - 保存失败保留用户填的内容,不再静默回滚,提示里带上服务端给的原因 - 开关、下拉的立即保存用服务端值填还没提交的字段,避免一个框填错 (如成片目录不存在)把开关也一起打回去 - 主播列表里的码率框:重建列表时保留用户改过还没提交的值,填错立刻 回填服务端值
This commit is contained in:
@@ -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.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.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::-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>
|
||||
<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>
|
||||
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
|
||||
</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="mergeSubdir" placeholder="merged"></div>
|
||||
</div>
|
||||
<div class="hint">文本框改完点右上角「保存设置」生效,开关和下拉框改完立即生效。</div>
|
||||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
||||
</div>
|
||||
<div class="hint">文本框改完点右上角「保存设置」生效。</div>
|
||||
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<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="bar">
|
||||
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
|
||||
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span>
|
||||
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -402,8 +408,52 @@ var logOffset = "0"; // 已经读取到的日志偏移量
|
||||
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
|
||||
var authed = false; // 是否已通过口令校验,未通过时停止轮询
|
||||
var tokenLoaded = false;
|
||||
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; }
|
||||
function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; }
|
||||
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致
|
||||
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 api(url, opt){
|
||||
@@ -510,6 +560,7 @@ async function refresh(){
|
||||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||||
if(!r.ok) return;
|
||||
var st = await r.json();
|
||||
lastSt = st;
|
||||
hideLogin();
|
||||
// 口令只在登录后拉取一次,填进设置区的只读框
|
||||
if(!tokenLoaded){
|
||||
@@ -583,7 +634,17 @@ async function refresh(){
|
||||
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
||||
if(sig !== stSig && !list.contains(document.activeElement)){
|
||||
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>';
|
||||
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);
|
||||
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||||
@@ -615,12 +676,12 @@ function showMsg(ok, text){
|
||||
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
|
||||
}, life);
|
||||
}
|
||||
async function post(url, body){
|
||||
async function post(url, body, errPrefix){
|
||||
saving = true;
|
||||
try{
|
||||
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.ok) showMsg(false, await r.text());
|
||||
if(!r.ok) showMsg(false, (errPrefix || "") + await r.text());
|
||||
return r.ok;
|
||||
}finally{
|
||||
saving = false;
|
||||
@@ -629,50 +690,52 @@ async function post(url, body){
|
||||
loadFiles();
|
||||
}
|
||||
}
|
||||
async function saveSettings(b){
|
||||
async function saveSettings(b, autoSave){
|
||||
if(b) b.disabled = true;
|
||||
var ok = await post("/api/config", {
|
||||
directory: document.getElementById("directory").value.trim(),
|
||||
autoMerge: document.getElementById("autoMerge").checked,
|
||||
mergeDelVideo: document.getElementById("mergeDelVideo").checked,
|
||||
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked,
|
||||
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, "已保存");
|
||||
var body = settingsBody(!!autoSave);
|
||||
var ok = await post("/api/config", body, autoSave ? "保存失败,这次改动没生效:" : "");
|
||||
if(ok){
|
||||
clearSaved(body);
|
||||
showMsg(true, "已保存");
|
||||
}
|
||||
if(b) b.disabled = false;
|
||||
}
|
||||
// 勾选类设置修改后立即保存,避免被定时刷新还原
|
||||
["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 时收起来
|
||||
function toggleMeowHeight(){
|
||||
var isHTML = document.getElementById("meowMsgType").value === "html";
|
||||
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 = [];
|
||||
function fmtSize(n){
|
||||
if(!n) return "0 B";
|
||||
@@ -879,24 +942,34 @@ async function delUid(uid){
|
||||
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
||||
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;
|
||||
if(t.matches('input[type=checkbox][data-uid]')){
|
||||
var uid = parseInt(t.dataset.uid, 10);
|
||||
var row = t.closest(".row");
|
||||
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
||||
var record = row.querySelector('input[data-k=record]').checked;
|
||||
var bitrate = parseInt(row.querySelector('input.br').value, 10) || 0;
|
||||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
|
||||
var brEl = row.querySelector('input.br');
|
||||
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')){
|
||||
var uid2 = parseInt(t.dataset.uid, 10);
|
||||
var row2 = t.closest(".row");
|
||||
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 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;
|
||||
}
|
||||
});
|
||||
// 回到地址栏或上次看的那个分区
|
||||
|
||||
Reference in New Issue
Block a user