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
+157
View File
@@ -0,0 +1,157 @@
// web UI 相关
package main
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
// 测试期间换一个管理口令
func withAuthToken(t *testing.T, token string) {
t.Helper()
streamers.Lock()
old := config.AuthToken
config.AuthToken = token
streamers.Unlock()
t.Cleanup(func() {
streamers.Lock()
config.AuthToken = old
streamers.Unlock()
})
}
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
func pageHeader(r *http.Request, token string) *http.Request {
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
return r
}
func TestCheckAuthTokenPlainASCII(t *testing.T) {
withAuthToken(t, "a1b2c3d4e5f6")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
if !checkAuthToken(r) {
t.Fatal("ASCII 口令应该通过")
}
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
withAuthToken(t, "ab%20cd")
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
}
if !checkAuthToken(r) {
t.Fatal("含 % 的口令应该通过")
}
}
func TestCheckAuthTokenNonASCII(t *testing.T) {
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
withAuthToken(t, "我的口令abc123")
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
t.Fatal("测试前提不成立:头里不该是原文")
}
if !checkAuthToken(r) {
t.Fatal("编码后的中文口令应该通过")
}
// 手写 curl 按文档原样发中文口令,也要能通过
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "我的口令abc123")
if !checkAuthToken(r) {
t.Fatal("原样的中文口令也应该通过")
}
}
func TestCheckAuthTokenAllSources(t *testing.T) {
withAuthToken(t, "口令abc123")
// Authorization: Bearer
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
if !checkAuthToken(r) {
t.Fatal("Bearer 写法应该通过")
}
// ?token=(net/http 已经把查询串解码好了)
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数写法应该通过")
}
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
withAuthToken(t, "口令%20abc")
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
if !checkAuthToken(r) {
t.Fatal("查询参数里的混合口令应该通过")
}
}
func TestCheckAuthTokenRejects(t *testing.T) {
withAuthToken(t, "正确的口令")
cases := map[string]*http.Request{
"口令错了": func() *http.Request {
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
}(),
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
"空口令头": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
return r
}(),
"编码了但不是口令": func() *http.Request {
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
return r
}(),
}
for name, r := range cases {
if checkAuthToken(r) {
t.Fatalf("%s:不该通过", name)
}
}
// 口令没配时一律拒绝,不能因为「都是空」就放行
withAuthToken(t, "")
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("X-Auth-Token", "")
if checkAuthToken(r) {
t.Fatal("口令为空时不该通过")
}
}
func TestAuthTokenCandidatesOrder(t *testing.T) {
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
r.Header.Set("Authorization", "Bearer aaa")
r.Header.Set("X-Auth-Token", "bbb")
got := authTokenCandidates(r)
want := []string{"aaa", "bbb", "qqq"}
if len(got) != len(want) {
t.Fatalf("候选数量不对:%q", got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
}
}
// Authorization 不是 Bearer 前缀时不算数
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
r.Header.Set("Authorization", "Basic zzz")
r.Header.Set("X-Auth-Token", "bbb")
got = authTokenCandidates(r)
if len(got) != 1 || got[0] != "bbb" {
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
}
}