web UI 按用途分成六个分区,整体改版并跟随系统深浅色

需求是「界面比较杂乱,做一下分类,UI 也现代化一些」。原来所有设置挤在一张
「设置」卡片里,从保存目录一路排到管理口令,加上通知推送的两个渠道,一屏
拉不到底,找东西全靠翻。

1) 分成六个分区,顶部一条导航切换
   - 监控:添加主播 + 主播列表 + 运行日志(默认打开的就是这里)
   - 录制:保存目录、录播/弹幕和成片子文件夹、录播格式、默认码率上限、ffmpeg 路径
   - 合并:自动合并的三个开关、成片输出目录和合成码率、手动合并
   - 通知:推送哪些事件、QQ 机器人渠道、MeoW 渠道
   - 文件:磁盘容量 + 文件列表
   - 设置:外网访问、管理口令
   - 分区只靠 class 切显示,所有元素始终在 DOM 里,所以原来按 id 取值的
     那一大堆逻辑一行都不用改;「监控」是默认页,因为看录制状态是最高频的操作

2) 地址栏带上 #分区名
   - 用 replaceState 写,不往历史里塞记录,但刷新、收藏、把链接发给别人
     都能落到同一个分区;也顺便记住了上次看的是哪一页(localStorage 兜底)

3) 大改样式(都在同一个模板里,没有引入任何外部依赖)
   - 颜色、圆角、阴影收进 CSS 变量,卡片改成柔和阴影 + 大圆角,输入框加了
     hover 和 focus 光晕,按钮分主次两级
   - 勾选框改成开关样式(隐藏原生 input + 兄弟节点画轨道,所以 el.checked
     和 :focus 判断照旧能用);主播列表的「弹幕/录播」改成小圆片
   - 把原来那个行内的小 #msg 文案换成右下角提示条:成功 2.6 秒、失败 6 秒
     自动消失,最多同时留 4 条,报错信息不会再一闪而过
   - 顶栏常驻状态摘要(录制中 N / 直播中 N / 空闲),「保存设置」也挪到顶栏,
     一直可见——文本框可能在别的分区里,按钮跟着分区走会找不到
   - 跟随系统深浅色(prefers-color-scheme),配套改了 color-scheme 元信息;
     日志面板在两种模式下都是深色终端风格
   - 手机上标题居中、右侧按钮收成两个字(「保存」),分区导航横向滚动;
     顶栏用 1fr auto 1fr 的网格,两侧等宽才是真的居中
   - 文件列表里的文件名原来被 max-width:200px 截成「2026-09-24 20-00-00 Xsp…」,
     文件名本来就是用来认文件的,改成不截断、允许换行

4) 顺手修的两个真问题
   - 口令里有中文时,页面彻底用不了:HTTP 请求头只接受 ISO-8859-1,浏览器
     遇到非 ASCII 的 X-Auth-Token 会直接抛 TypeError
     (Failed to read the 'headers' property…),请求根本发不出去,页面只显示
     「无法连接到服务器」,用户既进不去也没法在页面上把口令改回来。
     现在页面统一把口令 encodeURIComponent 之后再放进头里,服务端
     authTokenCandidates() 把原样和解码后的都作为候选逐个定长比较
     (不提前返回,避免响应时间泄露是哪一种对上了),所以中文口令能用,
     curl / 脚本按文档原样发中文口令也照样能过
   - 删「正在写入」的文件返回 500 而不是 409,见上一个提交

5) 文档
   - README 的「使用方法」补分区表、保存规则(开关立即生效 / 文本框点保存)、
     顶栏状态和深浅色说明;各处「设置区」改成对应的分区名
   - doc/webapi.md 补口令编码那段,说明脚本侧按原样填即可

已验证:新增 webui_test.go 5 个用例(ASCII 口令、含 % 的口令、中文口令的
编码与原样两种写法、Bearer 与 ?token= 三个来源、以及错口令/空口令/口令未配
一律拒绝、候选顺序与非 Bearer 的 Authorization 不算数),全量 39 个用例通过。
真机用 Edge 无头浏览器跑了两轮共 40 项断言,覆盖:自动登录、六个分区逐一点开
(只显示该分区、页签高亮、hash 跟着变)、六个分区 + 深色 + 三个移动端截图、
开关点了立刻落盘并能点回来、顶栏保存写到配置文件、在别的分区保存不会把隐藏
表单的值清空、刷新后仍在原分区、MeoW 红字提示的显示与隐藏、合并中按钮禁用
并改文案、口令弹窗的失败与重试,桌面/深色/移动端三处控制台零报错。
中文口令另做了端到端验证:配置里放中文口令能直接进、换口令后接口继续可用、
重开页面用新口令能进、旧口令被拒;把客户端编码去掉后同一个测试立刻失败,
确认这个修复是必需的而不是想当然。Windows/Linux 双平台构建、go vet、
gofmt 全干净。51880 的上游原版全程未动。
This commit is contained in:
tamakyi
2026-09-25 14:19:44 +08:00
parent be8ec5f953
commit a2f27974b2
4 changed files with 596 additions and 206 deletions
+415 -200
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"os"
"sort"
"strconv"
@@ -28,221 +29,368 @@ const webuiHTML = `<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>AcFun 直播录制</title>
<style>
:root{--line:#e8eaed;--soft:#f0f1f3;--sub:#8a8f98;--red:#fd4c5d}
:root{
--bg:#f4f5f7;--card:#fff;--line:#e6e8ec;--soft:#eff1f4;--soft2:#f7f8fa;
--text:#1f2329;--sub:#8a9099;--accent:#fd4c5d;--accent-soft:#ffecee;
--ok:#1d7a46;--ok-soft:#e7f5ed;--bad:#d63046;
--topbar:rgba(255,255,255,.86);
--code-bg:#1c1f26;--code-fg:#c9ced8;
--radius:12px;--radius-s:8px;
--shadow:0 1px 2px rgba(16,20,28,.04),0 6px 16px -10px rgba(16,20,28,.18);
}
@media (prefers-color-scheme:dark){
:root{
--bg:#15171c;--card:#1e2127;--line:#2b2f37;--soft:#262a32;--soft2:#22262d;
--text:#e7e9ed;--sub:#959ca7;--accent:#fd4c5d;--accent-soft:#3a2126;
--ok:#59c98a;--ok-soft:#1e3328;--bad:#ff7183;
--topbar:rgba(30,33,39,.86);
--code-bg:#101216;--code-fg:#c2c7d0;
--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 20px -12px rgba(0,0,0,.6);
}
}
*{box-sizing:border-box}
body{margin:0;background:#f6f7f8;color:#222;font:14px/1.5 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
.wrap{max-width:1000px;margin:0 auto;padding:14px 12px 28px}
h1{font-size:16px;font-weight:600;margin:0 0 2px}
.head-sub{font-size:12px;color:var(--sub);margin-bottom:12px}
.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:10px}
.card h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px 14px}
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px}
input[type=text],select,textarea{width:100%;border:1px solid #dcdfe4;border-radius:4px;padding:7px 9px;font:inherit;background:#fff;color:#222}
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--red)}
body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
a{color:var(--accent)}
/* 顶栏 */
.top{position:sticky;top:0;z-index:5;background:var(--topbar);backdrop-filter:saturate(1.5) blur(12px);-webkit-backdrop-filter:saturate(1.5) blur(12px);border-bottom:1px solid var(--line)}
.top-in{max-width:1060px;margin:0 auto;padding:10px 14px 8px;display:flex;align-items:center;gap:12px}
.brand{display:flex;align-items:center;gap:10px;min-width:0;flex:1}
.logo{width:30px;height:30px;border-radius:9px;background:var(--accent);color:#fff;font-weight:700;font-size:15px;display:flex;align-items:center;justify-content:center;flex:none;box-shadow:0 2px 8px -2px rgba(253,76,93,.6)}
.bt{min-width:0}
.bt strong{display:block;font-size:14px;font-weight:600;line-height:1.3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.head-sub{display:block;font-size:11.5px;color:var(--sub);line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.top-act{display:flex;align-items:center;gap:8px;flex:none}
.stat{font-size:12px;color:var(--sub);display:inline-flex;align-items:center;gap:5px;white-space:nowrap}
.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)}
/* 分区导航 */
.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}
.tab{flex:0 0 auto;border:none;background:transparent;color:var(--sub);font:inherit;font-size:13px;padding:6px 14px;border-radius:999px;cursor:pointer;transition:background .15s,color .15s}
.tab:hover{background:var(--soft);color:var(--text)}
.tab.on{background:var(--card);color:var(--text);font-weight:600;box-shadow:var(--shadow)}
.tab .n{font-size:11px;opacity:.7;margin-left:4px;font-variant-numeric:tabular-nums}
/* 内容 */
.wrap{max-width:1060px;margin:0 auto;padding:14px 14px 40px}
.pane{display:none}
.pane.on{display:block;animation:fade .18s ease-out}
@keyframes fade{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
.card{background:var(--card);border:1px solid var(--line);border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:var(--shadow)}
.card-h{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin-bottom:12px}
.card-h h2{font-size:14px;font-weight:600;margin:0}
.card-h .sub{font-size:12px;color:var(--sub)}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px 16px}
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:5px}
input[type=text],select,textarea{width:100%;border:1px solid var(--line);border-radius:var(--radius-s);padding:8px 10px;font:inherit;background:var(--card);color:var(--text);transition:border-color .15s,box-shadow .15s}
input[type=text]:hover,select:hover,textarea:hover{border-color:var(--sub)}
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
textarea{resize:vertical;line-height:1.5;font-size:13px}
select[multiple]{padding:4px}
#mrgInfo{white-space:pre-wrap;margin-top:8px}
select[multiple]{padding:5px}
input[readonly]{background:var(--soft2);color:var(--sub)}
#mrgInfo{white-space:pre-wrap;margin-top:10px;font-size:12.5px;line-height:1.7;color:var(--text);background:var(--soft2);border-radius:var(--radius-s);padding:10px 12px}
#mrgInfo:empty{display:none}
input.br{width:88px;text-align:center}
.chk{display:inline-flex;align-items:center;gap:5px;margin:0 14px 0 0;font-size:13px;color:#333;cursor:pointer}
button{border:none;border-radius:4px;padding:8px 16px;font:inherit;cursor:pointer;background:var(--red);color:#fff}
button.ghost,a.ghost{background:#f1f2f4;color:#555;padding:6px 10px;font-size:12px;text-decoration:none;display:inline-block}
button:active,a.ghost:active{opacity:.8}
button:disabled{opacity:.5}
.hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6}
.hint.bad{color:#d63046}
.hr{height:1px;background:var(--soft);margin:12px -14px}
.ch{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#333;margin:12px 0 8px}
.ch::after{content:"";flex:1;height:1px;background:var(--soft)}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 0;border-top:1px solid var(--soft)}
.row:first-child{border-top:none;padding-top:2px}
input.br{width:92px;text-align:center;padding:6px 8px}
/* 开关 */
.sws{display:flex;flex-wrap:wrap;gap:10px 22px}
.sw{display:inline-flex;align-items:center;gap:9px;cursor:pointer;font-size:13px;position:relative}
.sw input{position:absolute;width:1px;height:1px;opacity:0;margin:0;pointer-events:none}
.sw-i{width:36px;height:21px;border-radius:999px;background:#d3d7de;flex:none;position:relative;transition:background .16s}
.sw-i::after{content:"";position:absolute;top:2px;left:2px;width:17px;height:17px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.24);transition:transform .16s}
.sw input:checked+.sw-i{background:var(--accent)}
.sw input:checked+.sw-i::after{transform:translateX(15px)}
.sw input:focus-visible+.sw-i{box-shadow:0 0 0 3px var(--accent-soft)}
/* 小圆片勾选(主播列表) */
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;border-radius:999px;background:var(--soft);font-size:12px;color:var(--sub);cursor:pointer;user-select:none;border:1px solid transparent;transition:.15s}
.pill input{appearance:none;-webkit-appearance:none;width:14px;height:14px;margin:0;border:1.5px solid #c3c9d2;border-radius:4px;background:var(--card);position:relative;flex:none;cursor:pointer}
.pill input:checked{background:var(--accent);border-color:var(--accent)}
.pill input:checked::after{content:"";position:absolute;left:3px;top:.5px;width:4px;height:8px;border:2px solid #fff;border-top:0;border-left:0;transform:rotate(45deg)}
.pill:has(input:checked){background:var(--accent-soft);color:var(--accent);border-color:rgba(253,76,93,.25)}
/* 按钮 */
button{border:none;border-radius:var(--radius-s);padding:8px 16px;font:inherit;font-size:13px;cursor:pointer;background:var(--accent);color:#fff;transition:filter .15s,background .15s;white-space:nowrap}
button:hover{filter:brightness(1.06)}
button:active{filter:brightness(.94)}
button:disabled{opacity:.5;cursor:default;filter:none}
button.ghost,a.ghost{background:var(--soft);color:var(--text);padding:7px 12px;font-size:12.5px;text-decoration:none;display:inline-block}
button.ghost:hover,a.ghost:hover{background:var(--soft2);filter:none}
.hint{font-size:12px;color:var(--sub);margin-top:8px;line-height:1.65}
.hint.bad{color:var(--bad)}
.bar{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:12px}
.bar .hint{margin:0}
.rowline{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.rowline input{max-width:240px}
/* 主播列表 / 文件列表 */
.row{display:flex;flex-wrap:wrap;align-items:center;gap:8px 12px;padding:10px 10px;border-radius:var(--radius-s);transition:background .12s}
.row+.row{border-top:1px solid var(--line)}
.row:hover{background:var(--soft2)}
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
.name{font-weight:600;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.name{font-weight:600;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.title{flex:1 1 200px;min-width:0;font-size:12px;color:var(--sub);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ops{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:12px;margin-right:4px}
.on{background:#ffe8e8;color:#d63046}
.rec{background:#e6f4ec;color:#1d7a46}
.off{font-size:12px;color:var(--sub)}
pre{background:#1e1e1e;color:#d6d6d6;border-radius:6px;padding:10px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
#msg{font-size:12px;margin-left:8px}
input[readonly]{background:#f6f7f8;color:#555}
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(20,22,26,.55);display:none;align-items:center;justify-content:center;z-index:9;padding:16px}
/* 文件名是拿来认文件的关键信息,在文件列表里放开宽度不要截 */
#files .name{max-width:none;flex:1 1 240px;word-break:break-all;white-space:normal}
.badge{display:inline-block;padding:2px 9px;border-radius:999px;font-size:11.5px;margin-right:4px;background:var(--soft);color:var(--sub)}
.badge.on{background:var(--accent-soft);color:var(--accent)}
.badge.rec{background:var(--ok-soft);color:var(--ok)}
.off{font-size:12.5px;color:var(--sub);padding:8px 0}
pre{background:var(--code-bg);color:var(--code-fg);border-radius:var(--radius-s);padding:12px;font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,"Courier New",monospace;max-height:340px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
/* 提示条 */
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none;width:calc(100% - 24px);max-width:460px}
.toast{background:var(--card);color:var(--text);border:1px solid var(--line);border-left:3px solid var(--ok);border-radius:var(--radius-s);padding:10px 14px;font-size:12.5px;line-height:1.6;box-shadow:0 8px 28px -10px rgba(16,20,28,.35);animation:tin .18s ease-out;max-width:100%;word-break:break-word}
.toast.bad{border-left-color:var(--bad)}
.toast.out{opacity:0;transform:translateY(6px);transition:.2s}
@keyframes tin{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
/* 口令弹窗 */
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(16,18,22,.5);backdrop-filter:blur(3px);display:none;align-items:center;justify-content:center;z-index:30;padding:16px}
.mask.on{display:flex}
.mask .box{background:#fff;border-radius:8px;padding:18px;width:100%;max-width:380px}
.mask .box{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px;width:100%;max-width:390px;box-shadow:0 20px 50px -20px rgba(0,0,0,.5)}
.mask h3{margin:0 0 6px;font-size:15px}
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.6}
#authMsg{font-size:12px;color:#d63046;min-height:17px;margin-top:8px}
@media (max-width:600px){
.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}
.only-m{display:none}
@media (max-width:700px){
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
.top-in{padding:9px 12px 7px;gap:8px;display:grid;grid-template-columns:1fr auto 1fr}
.brand{grid-column:2;justify-content:center}
.top-act{grid-column:3;justify-content:flex-end}
.head-sub{display:none}
.stat{display:none}
.only-d{display:none}
.only-m{display:inline}
.wrap{padding:12px 12px 36px}
.card{padding:14px;border-radius:10px}
.grid{grid-template-columns:1fr}
.row{gap:6px 10px}
.name{max-width:none}
.title{flex:1 1 100%;order:8}
.ops{width:100%;margin-left:0;order:9}
.ops button,.ops a{flex:1 1 0;padding:8px 4px;text-align:center}
button.ghost,.mask button{padding:8px 10px;font-size:13px}
button.ghost,.mask button{padding:8px 12px;font-size:13px}
#toast{bottom:14px}
}
</style>
</head>
<body>
<header class="top">
<div class="top-in">
<div class="brand">
<span class="logo">A</span>
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
</div>
<div class="top-act">
<span class="stat" id="topStat"></span>
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
</div>
</div>
<nav class="tabs" id="tabs">
<button class="tab on" data-tab="mon">监控</button>
<button class="tab" data-tab="rec">录制</button>
<button class="tab" data-tab="mrg">合并</button>
<button class="tab" data-tab="ntf">通知</button>
<button class="tab" data-tab="file">文件</button>
<button class="tab" data-tab="sys">设置</button>
</nav>
</header>
<div class="wrap">
<h1>AcFun 直播录制</h1>
<div class="head-sub">弹幕 / 视频录制,直播结束后自动合并</div>
<div class="card">
<h2>设置</h2>
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div>
<div class="hr"></div>
<div>
<label class="chk"><input type="checkbox" id="autoMerge">直播结束后自动合并</label>
<label class="chk"><input type="checkbox" id="mergeDelVideo">合并后删除源视频</label>
<label class="chk"><input type="checkbox" id="mergeDelDanmu">合并后删除弹幕</label>
</div>
<div class="hint">三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并</div>
<div class="grid" style="margin-top:10px">
<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="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></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>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div>
<div class="f"><label>录播文件格式</label><select id="recordFormat">
<option value="mp4">mp4(推荐,最通用)</option>
<option value="ts">ts(强杀后还能播已录部分)</option>
<option value="flv">flv(兼容性一般)</option>
<option value="mkv">mkv(可装多音轨)</option>
</select></div>
</div>
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录</div>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
<div class="hr"></div>
<div class="ch">通知推送</div>
<div>
<label class="chk"><input type="checkbox" id="notifyLiveOn">开播</label>
<label class="chk"><input type="checkbox" id="notifyLiveOff">下播</label>
<label class="chk"><input type="checkbox" id="notifyRecordStart">开始录制</label>
<label class="chk"><input type="checkbox" id="notifyMergeDone">合并完成</label>
</div>
<div class="hint">这一行勾的是「推哪些事件」,下面两个渠道共用;推送失败只记警告,不影响录制</div>
<div class="ch">渠道一 · QQ 机器人(webhook)</div>
<div><label class="chk"><input type="checkbox" id="notifyEnabled">启用这个渠道</label></div>
<div class="grid" style="margin-top:8px">
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人自己的默认群"></div>
<!-- 监控 -->
<section class="pane on" data-pane="mon">
<div class="card">
<div class="card-h"><h2>添加主播</h2><span class="sub">填 uid 后默认同时开启弹幕和录播</span></div>
<div class="rowline">
<input type="text" id="newuid" inputmode="numeric" placeholder="主播 uid,例如 23682490">
<button class="ghost" onclick="addUid()">添加</button>
</div>
</div>
<div class="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
<span class="hint" style="margin:0">用上面填写的地址发一条测试推送,不用先保存</span>
<div class="card">
<div class="card-h"><h2>主播列表</h2><span class="sub">勾选 = 开启对应录制,改完立即生效</span></div>
<div class="hint" style="margin:0 0 10px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
<div id="st"></div>
</div>
<div class="card">
<div class="card-h"><h2>运行日志</h2><span class="sub">手动合并的进度也在这里</span></div>
<pre id="log"></pre>
</div>
</section>
<div class="ch">渠道二 · MeoW</div>
<div><label class="chk"><input type="checkbox" id="meowEnabled">启用这个渠道</label></div>
<div class="grid" style="margin-top:8px">
<div class="f"><label>MeoW 昵称(接收者)</label><input type="text" id="meowNickname" placeholder="你在 MeoW 里的昵称"></div>
<div class="f"><label>消息类型</label><select id="meowMsgType">
<option value="text">text(纯文本)</option>
<option value="html">html(在 App 里渲染)</option>
</select></div>
<div class="f" id="meowHeightRow"><label>HTML 显示高度(像素)</label><input type="text" id="meowHtmlHeight" inputmode="numeric" placeholder="200"></div>
<div class="f"><label>通知图标 URL(可选)</label><input type="text" id="meowImgUrl" placeholder="建议 216×216 的 PNG,留空用 MeoW 默认图标"></div>
<!-- 录制 -->
<section class="pane" data-pane="rec">
<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><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>
<div class="hint">推给 MeoW 里这个昵称,手机上直接收通知,点开可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)</div>
<div class="hint bad" id="meowBlocked" style="display:none"></div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
<span class="hint" style="margin:0">用上面填写的昵称发一条测试推送,不用先保存</span>
<div class="card">
<div class="card-h"><h2>录制参数</h2></div>
<div class="grid">
<div class="f"><label>录播文件格式</label><select id="recordFormat">
<option value="mp4">mp4(推荐,最通用)</option>
<option value="ts">ts(强杀后还能播已录部分)</option>
<option value="flv">flv(兼容性一般)</option>
<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>
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
</div>
<div style="margin-top:10px">
<label class="chk"><input type="checkbox" id="webExternal">允许外网访问(关闭时只有本机能打开本页)</label>
</section>
<!-- 合并 -->
<section class="pane" data-pane="mrg">
<div class="card">
<div class="card-h"><h2>自动合并</h2><span class="sub">直播正常结束时的自动处理</span></div>
<div class="sws">
<label class="sw"><input type="checkbox" id="autoMerge"><span class="sw-i"></span>直播结束后自动合并</label>
<label class="sw"><input type="checkbox" id="mergeDelVideo"><span class="sw-i"></span>合并后删除源视频</label>
<label class="sw"><input type="checkbox" id="mergeDelDanmu"><span class="sw-i"></span>合并后删除弹幕</label>
</div>
<div class="hint">三项互不影响,可以同时开启;需该主播同时开启弹幕和录播且都录到直播结束。手动停止或重新开始不会触发自动合并,那种情况用下面的手动合并。</div>
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
</div>
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连</div>
<div class="hr"></div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px">
<button onclick="saveSettings(this)">保存设置</button><span id="msg"></span>
<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>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
</div>
<div class="hint">文本框改完点右上角「保存设置」生效。</div>
</div>
<div class="hint">勾选类设置修改后立即保存,文本框内容点「保存设置」生效</div>
<div class="hr"></div>
<div class="f"><label>管理口令(打开本页和管理录制都需要,建议复制保存到别处)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" id="authTokenView" readonly style="flex:1 1 200px">
<button class="ghost" onclick="copyToken()">复制</button>
<div class="card">
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div>
<div class="hint" style="margin:0 0 12px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见「监控」页的运行日志。</div>
<div class="grid">
<div class="f">
<label>视频文件(一行一个,也可填绝对路径)</label>
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
</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">
<datalist id="mrgDanmuList"></datalist>
</div>
</div>
<div class="grid" style="margin-top:12px">
<div class="f">
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
</div>
<div class="f">
<label>偏移(秒,留空 = 自动推算)</label>
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
<label style="margin-top:10px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
</div>
</div>
<div class="bar">
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
<button id="mrgGo" onclick="startMerge()">开始合并</button>
<span class="hint" id="mrgState"></span>
</div>
<div class="hint" id="mrgInfo"></div>
</div>
</section>
<!-- 通知 -->
<section class="pane" data-pane="ntf">
<div class="card">
<div class="card-h"><h2>推送哪些事件</h2><span class="sub">下面两个渠道共用这份勾选</span></div>
<div class="sws">
<label class="sw"><input type="checkbox" id="notifyLiveOn"><span class="sw-i"></span>开播</label>
<label class="sw"><input type="checkbox" id="notifyLiveOff"><span class="sw-i"></span>下播</label>
<label class="sw"><input type="checkbox" id="notifyRecordStart"><span class="sw-i"></span>开始录制</label>
<label class="sw"><input type="checkbox" id="notifyMergeDone"><span class="sw-i"></span>合并完成</label>
</div>
<div class="hint">推送失败只记警告,不影响录制。</div>
</div>
<div class="card">
<div class="card-h"><h2>渠道一 · QQ 机器人</h2><span class="sub">自建机器人,走 HTTP POST JSON</span></div>
<label class="sw"><input type="checkbox" id="notifyEnabled"><span class="sw-i"></span>启用这个渠道</label>
<div class="grid" style="margin-top:12px">
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人的默认群"></div>
</div>
<div class="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群。</div>
<div class="bar">
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
<span class="hint">用上面填写的地址发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span>
</div>
</div>
<div class="f" style="margin-top:8px"><label>更换管理口令(至少 8 个字符)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px">
<button class="ghost" onclick="changeToken()">更换</button>
<div class="card">
<div class="card-h"><h2>渠道二 · MeoW</h2><span class="sub">手机上直接收通知</span></div>
<label class="sw"><input type="checkbox" id="meowEnabled"><span class="sw-i"></span>启用这个渠道</label>
<div class="grid" style="margin-top:12px">
<div class="f"><label>MeoW 昵称(接收者)</label><input type="text" id="meowNickname" placeholder="你在 MeoW 里的昵称"></div>
<div class="f"><label>消息类型</label><select id="meowMsgType">
<option value="text">text(纯文本)</option>
<option value="html">html(在 App 里渲染)</option>
</select></div>
<div class="f" id="meowHeightRow"><label>HTML 显示高度(像素)</label><input type="text" id="meowHtmlHeight" inputmode="numeric" placeholder="200"></div>
<div class="f"><label>通知图标 URL(可选)</label><input type="text" id="meowImgUrl" placeholder="建议 216×216 的 PNG,留空用 MeoW 默认图标"></div>
</div>
<div class="hint">推给 MeoW 里这个昵称,点开通知可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)。</div>
<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>
</div>
</div>
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段</div>
<div class="hr"></div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<input type="text" id="newuid" inputmode="numeric" placeholder="新增主播 uid,例如 23682490" style="max-width:230px">
<button class="ghost" onclick="addUid()">添加(弹幕+录播)</button>
</div>
</div>
<div class="card">
<h2>主播列表(勾选 = 开启对应录制,修改立即生效)</h2>
<div class="hint" style="margin:0 0 8px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
<div id="st"></div>
</div>
<div class="card">
<h2>文件管理</h2>
<div class="hint" id="diskInfo" style="margin:0 0 8px">点击下面的按钮读取文件和磁盘信息</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
<span class="hint" style="margin:0" id="filesInfo"></span>
</div>
<div id="files" style="margin-top:6px"></div>
</div>
<div class="card">
<h2>手动合并</h2>
<div class="hint" style="margin:0 0 8px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。</div>
<div class="grid">
<div class="f">
<label>视频文件(一行一个,也可填绝对路径)</label>
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
</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">
<datalist id="mrgDanmuList"></datalist>
</section>
<!-- 文件 -->
<section class="pane" data-pane="file">
<div class="card">
<div class="card-h"><h2>文件管理</h2><span class="sub" id="diskInfo">正在读取磁盘信息…</span></div>
<div class="bar" style="margin-top:0">
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
<span class="hint" id="filesInfo"></span>
</div>
<div id="files" style="margin-top:8px"></div>
</div>
<div class="grid" style="margin-top:10px">
<div class="f">
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
</div>
<div class="f">
<label>偏移(秒,留空 = 自动推算)</label>
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
<label style="margin-top:8px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
</div>
</section>
<!-- 设置 -->
<section class="pane" data-pane="sys">
<div class="card">
<div class="card-h"><h2>访问方式</h2></div>
<label class="sw"><input type="checkbox" id="webExternal"><span class="sw-i"></span>允许外网访问(关闭时只有本机能打开本页)</label>
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连。</div>
</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
<button id="mrgGo" onclick="startMerge()">开始合并</button>
<span class="hint" style="margin:0" id="mrgState"></span>
<div class="card">
<div class="card-h"><h2>管理口令</h2><span class="sub">打开本页和管理录制都需要</span></div>
<div class="f"><label>当前口令(建议复制保存到别处)</label>
<div class="rowline">
<input type="text" id="authTokenView" readonly style="flex:1 1 200px;max-width:none">
<button class="ghost" onclick="copyToken()">复制</button>
</div>
</div>
<div class="f" style="margin-top:12px"><label>更换管理口令(至少 8 个字符)</label>
<div class="rowline">
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px;max-width:none">
<button class="ghost" onclick="changeToken()">更换</button>
</div>
</div>
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段。</div>
</div>
<div class="hint" id="mrgInfo"></div>
</div>
<div class="card">
<h2>运行日志</h2>
<pre id="log"></pre>
</div>
</section>
</div>
<div id="toast"></div>
<div class="mask" id="auth">
<div class="box">
<h3>请输入管理口令</h3>
<p>管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。</p>
<input type="text" id="tokenInput" autocomplete="off" placeholder="管理口令">
<div style="margin-top:10px"><button onclick="login()">进入</button></div>
<div style="margin-top:12px"><button onclick="login()">进入</button></div>
<div id="authMsg"></div>
</div>
</div>
@@ -256,16 +404,44 @@ 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; }
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
// 管理接口都要带上口令
function api(url, opt){
opt = opt || {};
var h = {};
var src = opt.headers || {};
for(var k in src){ h[k] = src[k]; }
h["X-Auth-Token"] = token;
// 请求头只接受 ISO-8859-1,口令里有中文的话浏览器会直接报错、请求发不出去,
// 所以统一先百分号编码,服务端两种写法都认
h["X-Auth-Token"] = encodeURIComponent(token);
opt.headers = h;
return fetch(url, opt);
}
// ========== 分区导航 ==========
var tabNames = {mon:"监控", rec:"录制", mrg:"合并", ntf:"通知", file:"文件", sys:"设置"};
var curTab = "";
function showTab(name){
if(!tabNames[name]) name = "mon";
if(curTab === name) return;
curTab = name;
var tabs = document.querySelectorAll(".tab");
for(var i = 0; i < tabs.length; i++){ tabs[i].classList.toggle("on", tabs[i].dataset.tab === name); }
var panes = document.querySelectorAll(".pane");
for(var j = 0; j < panes.length; j++){ panes[j].classList.toggle("on", panes[j].dataset.pane === name); }
try{ localStorage.setItem("acfunlive_tab", name); }catch(e){}
// 地址栏留个 #分区,刷新和收藏都能回到同一个分区(replaceState 不往历史里塞记录)
if(location.hash.slice(1) !== name){
try{ history.replaceState(null, "", "#" + name); }catch(e){ location.hash = name; }
}
// 切过去的时候顺手刷新那个分区里的实时数据
if(name === "file" && authed) loadFiles();
if(name === "mrg" && authed) fillMergePickers();
}
document.getElementById("tabs").addEventListener("click", function(e){
var t = e.target.closest(".tab");
if(t) showTab(t.dataset.tab);
});
window.addEventListener("hashchange", function(){ showTab(location.hash.slice(1)); });
function showLogin(text){
authed = false;
document.getElementById("auth").classList.add("on");
@@ -368,13 +544,22 @@ async function refresh(){
toggleMeowHeight();
var mb = document.getElementById("meowBlocked");
if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; }
else mb.style.display = "none";
else show("meowBlocked", false);
setChk("webExternal", st.webExternal);
setChk("autoMerge", st.autoMerge);
setChk("mergeDelVideo", st.mergeDelVideo);
setChk("mergeDelDanmu", st.mergeDelDanmu);
syncMergeState(st);
var rows = (st.streamers||[]).map(function(s){
// 顶栏的状态摘要
var sl = st.streamers||[];
var rec = 0, live = 0;
sl.forEach(function(s){ if(s.recordActive || s.danmuActive) rec++; else if(s.live) live++; });
var ts = document.getElementById("topStat");
if(rec) ts.innerHTML = '<i class="dot rec"></i>录制中 ' + rec;
else if(live) ts.innerHTML = '<i class="dot live"></i>直播中 ' + live;
else if(sl.length) ts.innerHTML = '<i class="dot"></i>空闲';
else ts.innerHTML = "";
var rows = sl.map(function(s){
var badges = "";
if(s.recordActive) badges += '<span class="badge on">录播中</span>';
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
@@ -383,8 +568,8 @@ async function refresh(){
return '<div class="row">'
+ '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
+ '<label class="pill"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
+ '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0) + '" placeholder="0=最高" title="下载码率上限(Kbps),0 = 最高档">'
+ '<span class="state">' + badges + '</span>'
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
@@ -398,7 +583,7 @@ async function refresh(){
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig;
list.innerHTML = rows || '<div class="off">列表为空,请在上方添加 uid</div>';
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
}
var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
@@ -415,10 +600,20 @@ async function refresh(){
}
}catch(e){}
}
// 右下角弹一条提示,成功 2.6 秒、失败 6 秒
function showMsg(ok, text){
var m = document.getElementById("msg");
m.textContent = text; m.style.color = ok ? "#1d7a46" : "#d63046";
setTimeout(function(){ m.textContent = ""; }, 3000);
var w = document.getElementById("toast");
if(!w){ return; }
var d = document.createElement("div");
d.className = "toast" + (ok ? "" : " bad");
d.textContent = text;
w.appendChild(d);
while(w.children.length > 4){ w.removeChild(w.firstChild); }
var life = ok ? 2600 : 6000;
setTimeout(function(){
d.classList.add("out");
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
}, life);
}
async function post(url, body){
saving = true;
@@ -590,7 +785,7 @@ function setMergeState(){
go.disabled = mrgBusy;
go.textContent = mrgBusy ? "合并中…" : "开始合并";
document.getElementById("mrgCheck").disabled = mrgBusy;
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见下方运行日志" : "";
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见「监控」页的运行日志" : "";
}
async function checkMerge(){
var body = mrgPayload();
@@ -619,7 +814,7 @@ async function startMerge(){
var body = mrgPayload();
if(!body) return;
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
if(!confirm("开始合并?合成期间可以在下方运行日志里看进度。")) return;
if(!confirm("开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return;
var b = document.getElementById("mrgGo");
b.disabled = true;
var r = null;
@@ -644,7 +839,7 @@ function syncMergeState(st){
if(busy) return;
loadFiles();
if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr);
else showMsg(true, "手动合并结束,结果见运行日志");
else showMsg(true, "手动合并结束,结果见「监控」页的运行日志");
}
async function testNotify(b, channel){
if(b) b.disabled = true;
@@ -695,15 +890,17 @@ document.addEventListener("change", function(e){
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
}
if(t.matches('input.br')){
var uid = parseInt(t.dataset.uid, 10);
var row = t.closest(".row");
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; }
var danmu = row.querySelector('input[data-k=danmu]').checked;
var record = row.querySelector('input[data-k=record]').checked;
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: v});
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});
}
});
// 回到地址栏或上次看的那个分区
showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon");
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
authed = true;
refresh();
@@ -1351,17 +1548,32 @@ func webUIAddr() string {
return "127.0.0.1:" + itoa(config.WebPort)
}
// 从请求里取管理口令:优先 Authorization: Bearer,其次 X-Auth-Token,最后 ?token=
func authTokenFromRequest(r *http.Request) string {
// 从请求里取管理口令的所有可能写法,按优先级排:Authorization: Bearer、X-Auth-Token、?token=。
// 浏览器不允许把非 ISO-8859-1 的字符放进请求头(中文口令会直接抛 TypeError,请求根本发不出去),
// 所以页面会先把口令百分号编码再放进头里;这里把原样和解码后的都当候选,
// 这样中文口令能用,手写 curl / 脚本用原样口令也照样能用。
func authTokenCandidates(r *http.Request) []string {
var raw []string
if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" {
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
return strings.TrimSpace(h[7:])
raw = append(raw, strings.TrimSpace(h[7:]))
}
}
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
return t
raw = append(raw, t)
}
return strings.TrimSpace(r.URL.Query().Get("token"))
if t := strings.TrimSpace(r.URL.Query().Get("token")); t != "" {
raw = append(raw, t)
}
out := make([]string, 0, len(raw)*2)
for _, v := range raw {
out = append(out, v)
if d, err := url.QueryUnescape(v); err == nil && d != v {
out = append(out, d)
}
}
return out
}
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
@@ -1370,11 +1582,14 @@ func checkAuthToken(r *http.Request) bool {
if want == "" {
return false
}
got := authTokenFromRequest(r)
if got == "" {
return false
// 逐个候选都比完再出结果,不提前返回,免得响应时间泄露是哪一种写法对上了
ok := false
for _, got := range authTokenCandidates(r) {
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 {
ok = true
}
}
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
return ok
}
// 口令错误次数的记录,给反复尝试的请求加一点延迟