From 0dfa224bac6837c27e6ee03e4ff4a416afa32afb Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 15:50:34 +0800 Subject: [PATCH] =?UTF-8?q?web=20UI=EF=BC=9A=E6=B2=A1=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E7=9A=84=E6=96=87=E6=9C=AC=E6=A1=86=E4=B8=8D=E5=86=8D=E8=A2=AB?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=88=B7=E6=96=B0=E5=88=B7=E5=9B=9E=E5=8E=9F?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面每 3 秒拉一次 /api/status 回填表单,而回填只跳过「正在聚焦」的元素, 所以文本框改完点一下别处(或按回车,输入框不在 form 里根本不会提交), 几秒后就被服务端的旧值盖回去,看起来像「改了又自己恢复」—— 改合成码率时的现象就是输入框里明明是 6500、过了 3 秒自己变回 1500。 服务端日志里一条 6500 的保存记录都没有,全是前端盖的。 - 表单字段加上「未提交」标记,定时刷新不再覆盖;提交成功才清掉 - 回车等于点保存设置(主播码率框回车即提交,新增 uid 回车即添加) - 有没提交的内容时顶栏提示「有未保存的修改」,保存按钮右上角标红点, 离开页面前再问一次 - 保存失败保留用户填的内容,不再静默回滚,提示里带上服务端给的原因 - 开关、下拉的立即保存用服务端值填还没提交的字段,避免一个框填错 (如成片目录不存在)把开关也一起打回去 - 主播列表里的码率框:重建列表时保留用户改过还没提交的值,填错立刻 回填服务端值 --- README.md | 4 +- webui.go | 161 +++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 120 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index df5bb4b..026a945 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,9 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`), | 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 设置 | 是否允许外网访问、管理口令的查看与更换 | -开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 +开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 + +页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。 顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。 diff --git a/webui.go b/webui.go index ef27b6d..efe3e9c 100644 --- a/webui.go +++ b/webui.go @@ -67,6 +67,11 @@ const webuiHTML = ` .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 = `
AcFun 直播录制弹幕 / 视频录制,直播结束后自动合并
+
@@ -225,7 +231,7 @@ const webuiHTML = `
-
文本框改完点右上角「保存设置」生效,开关和下拉框改完立即生效。
+
文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。

录制参数

@@ -262,7 +268,7 @@ const webuiHTML = `
-
文本框改完点右上角「保存设置」生效。
+
文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。

手动合并

自己挑视频和弹幕合成
@@ -341,7 +347,7 @@ const webuiHTML = `
- 用上面填写的昵称发一条测试推送,不用先保存;改完记得点右上角「保存设置」 + 用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」
@@ -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 || '
列表为空,在上面填 uid 添加
'; + 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; } }); // 回到地址栏或上次看的那个分区