需求是「界面比较杂乱,做一下分类,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 的上游原版全程未动。
158 lines
5.0 KiB
Go
158 lines
5.0 KiB
Go
// 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)
|
||
}
|
||
}
|