Files
biliup/app/ui/streamer-card.module.scss
Noah 486676db29 feat(ui): 直播预览阶段三——录制中房间实时码率折线图 (#1714)
* feat(api): GET /v1/live-rates 录制中房间的每秒写盘速率瘦端点

/v1/streamers 每次查全表、拼齐全部字段,10 s 一次的节奏不适合调快;码率曲线需要 1 s 粒度,
新加一个只读内存的瘦端点:扫一遍 worker,对每个 Working 的房间读一次阶段一 RateMeter 的窗口均值,
返回 [{id, bytes_per_sec, ts}]。与 /v1/streamers 的 live_bytes_per_sec 同源同值;不新增采样任务、
服务端不存历史、Cache-Control: no-store;注册在 router() 内,--auth 时同一道登录校验。

Part of #1708

* feat(ui): 实时码率数据层与 uPlot 折线组件

- app/lib/use-live-rates.ts:页面内唯一的一份采样源。有订阅者时每秒拉一次 /v1/live-rates,
  全部卸载 / 标签页隐藏即停;历史放模块级环形缓冲(每房间最近 3 分钟),组件卸载不清,
  切走再切回曲线还在;帧里没有的房间补 null 成断口。useSyncExternalStore 订阅,无新帧不重渲染。
- app/ui/RateChart.tsx:uPlot 折线,full(带时间轴 / Y 轴,单位按窗口最大值自动选 KB/s / MB/s,
  Y 从 0 起)与 sparkline(无坐标轴,hover 读数)两种形态;实例在 effect 里建 / 销毁,渲染期不碰 ref,
  StrictMode 双次 effect 不留多余 canvas;配色每次绘制时读 Semi CSS 变量,深浅色切换不重建。
  无采样 / 未录制只显示「—」占位,不画空图。
- app/ui/LiveRateChart.tsx:next/dynamic 懒加载入口 + 当前 / 峰值 / 均值统计条;
  uPlot 与其 CSS 打进独立 chunk(约 23 KB gzip),首屏不含。
- 新依赖 uplot ^1.6.32(本 PR 唯一新依赖)。

Part of #1708

* feat(ui): 三处接入码率折线——弹层可折叠折线、卡片与监视器 sparkline

- 卡片弹层底部新增「写盘速率 · 最近 3 分钟」可折叠区(默认展开,折叠态记 localStorage
  biliup.preview.rateChart):标题行带当前 / 峰值 / 均值,展开时才轮询与加载 uPlot。
- 控制台 / 直播管理页录制中卡片:名称行下一条 60 s sparkline(28 px,hover 显示时刻与数值)。
- 历史记录页「实时监视」:工具栏新增「码率图」开关(默认关,记 biliup.monitor.rateChart),
  开启后每路小窗下方一条 60 s sparkline;所有小窗共用同一次每秒请求。

Part of #1708

* style(api): live_rates 测试按 rustfmt 换行

* fix(ui): sparkline 的 hover 读数浮在折线上方,不再被容器裁掉

* docs(readme): 功能列表补充直播预览(封面 / 播放器 / 弹幕 / 码率曲线)

#1708 三个阶段至此完整,在 README 的功能列表里加一条说明。

Closes #1708

* feat(api): GET /v1/ws/live-rates 用 WebSocket 每秒推送码率帧

review 1(ForgQi):复用现有的 WebSocket 机制推送而不是每秒轮询。WebSocket 不占浏览器对同一主机
HTTP/1.1 的六个并发连接(监视器 4 路视频 + 1 条弹幕 SSE 已用掉五个,这正是当初不用 SSE 的原因),
与日志页 /v1/ws/logs 同款:Origin 与 Host 同源校验、进程内连接数上限(16)、Ping 回 Pong、Close 即止。
连上立刻发一帧,之后每秒一帧,形状与 GET /v1/live-rates 相同;HTTP 版保留给 WebSocket 连不上时回退与 curl 核对。
两个路由都在 router() 内,--auth 下同一道登录校验。单测用手写的最小 WebSocket 客户端走完握手 + 两帧,不引新依赖。

* feat(ui): 码率数据改走 WebSocket;卡片 sparkline 加开关并叠在封面上

review 1(ForgQi):
- use-live-rates.ts 首选一条 WebSocket /v1/ws/live-rates(服务端每秒推一帧,所有图表共用),
  一帧都没收到就断开(反代没放行 upgrade、401)时回退到每秒 GET /v1/live-rates 轮询;中途断开退避重连,
  标签页隐藏时断开、可见时重连;快照新增 transport: 'ws' | 'poll'。
- 控制台「进行中」区块头与直播管理页工具栏新增「码率图」开关(默认关,记 localStorage biliup.cards.rateChart,
  两页共用);开启后录制中卡片的 sparkline 叠在封面底部(深色渐变垫底、读数条在图区内;点击仍冒泡到封面打开预览),
  封面没有 / 加载失败时退到名称行下方。

* test(api): 码率 WebSocket 连接上限测试改用独立信号量

CI 上与同一二进制里并发跑的 WebSocket 推送测试争用进程级信号量(后者占着一个许可),
上限测试拿不满 16 个而失败;改为像 ws.rs 的日志连接测试那样传入独立的 Semaphore。
2026-09-23 03:44:43 +00:00

348 lines
6.9 KiB
SCSS

/* 全局统一直播间卡片 —— 主页 / 直播管理共用 */
.card {
display: flex;
flex-direction: column;
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
background: var(--semi-color-bg-1);
padding: 14px 16px 15px;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.15s ease;
}
.card:hover {
border-color: var(--semi-color-primary);
box-shadow: 0 6px 16px rgba(var(--semi-grey-9), 0.08);
transform: translateY(-1px);
}
/* 直播中:轻绿底 */
.card.rec {
background: rgba(var(--semi-green-4), 0.05);
}
.card.rec:hover {
background: rgba(var(--semi-green-4), 0.09);
}
.card.paused {
opacity: 0.78;
}
/* ===== 头部行 ===== */
.topRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 9px;
}
.left {
display: inline-flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.chk {
width: 15px;
height: 15px;
accent-color: var(--semi-color-primary);
cursor: pointer;
flex-shrink: 0;
margin: 0;
}
/* 状态徽章(淡色版,与页面状态语言一致) */
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
padding: 2px 9px;
border-radius: 999px;
white-space: nowrap;
}
.bdot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.badgeLive {
background: rgba(var(--semi-green-4), 0.13);
color: rgb(var(--semi-green-5));
}
.badgeLive .bdot {
background: rgb(var(--semi-green-5));
animation: pulseLive 1.5s infinite;
}
@keyframes pulseLive {
0% {
box-shadow: 0 0 0 0 rgba(var(--semi-green-4), 0.45);
}
70% {
box-shadow: 0 0 0 6px rgba(var(--semi-green-4), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--semi-green-4), 0);
}
}
.badgePause {
background: rgba(var(--semi-orange-4), 0.14);
color: rgb(var(--semi-orange-5));
}
.badgePause .bdot {
background: rgb(var(--semi-orange-5));
}
.badgeIdle {
background: var(--semi-color-fill-1);
color: var(--semi-color-text-1);
}
.badgeIdle .bdot {
background: var(--semi-color-text-2);
}
/* 平台 chip:品牌色点缀(颜色由组件内联注入) */
.platChip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.platDot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
/* ===== 录制中:直播间封面(16:9) ===== */
.cover {
position: relative;
margin: 0 0 10px;
aspect-ratio: 16 / 9;
border-radius: 8px;
overflow: hidden;
background: var(--semi-color-fill-1);
}
.cover img {
object-fit: cover;
opacity: 0;
transition: opacity 0.25s ease;
}
.cover[data-state='ok'] img {
opacity: 1;
}
/* 可预览时封面可点:中央播放标记,悬停加深 */
.coverClickable {
cursor: pointer;
}
.coverClickable:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
.coverPlay {
position: absolute;
left: 50%;
top: 50%;
width: 44px;
height: 44px;
margin: -22px 0 0 -22px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
opacity: 0.85;
transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease;
}
.coverPlay svg {
margin-left: 2px;
}
.coverClickable:hover .coverPlay {
opacity: 1;
transform: scale(1.06);
background: rgba(0, 0, 0, 0.6);
}
/* ===== 名称 + 时长 ===== */
.nameRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 3px;
}
.nameWrap {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
/* 录制中:主播头像小圆图 */
.avatar {
width: 22px;
height: 22px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
background: var(--semi-color-fill-1);
}
.name {
font-size: 15px;
font-weight: 650;
color: var(--semi-color-text-0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
/* 直播中:右侧时长 + 写盘速率 */
.liveStats {
display: inline-flex;
align-items: baseline;
gap: 8px;
flex-shrink: 0;
}
/* 直播中:实时时长(绿色) */
.liveInfo {
font-size: 12.5px;
font-weight: 600;
color: rgb(var(--semi-green-5));
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* 直播中:写盘速率(最近 10 秒平均);无采样时为「—」 */
.rate {
font-size: 12px;
font-weight: 600;
color: var(--semi-color-text-2);
white-space: nowrap;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
padding: 1px 7px;
border-radius: 999px;
background: var(--semi-color-fill-0);
min-width: 2.6em;
text-align: center;
}
/* 直播中:预览按钮(图标) */
.previewBtn {
height: 22px;
min-width: 22px;
padding: 0 6px !important;
align-self: center;
}
/* 直播中:最近 60 秒写盘速率 sparkline(无坐标轴,hover 显示数值);没有封面可叠时放名称行下 */
.spark {
margin: 2px 0 6px;
}
/* 叠在封面底部的 sparkline:深色渐变垫底让绿线在任何封面上都可读;点击仍冒泡到封面打开预览 */
.coverOverlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 10px 6px 2px;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.35) 60%, rgba(0, 0, 0, 0));
pointer-events: none;
}
.coverOverlay > * {
pointer-events: auto;
}
/* 未开播:最近录制时间 */
.recInfo {
font-size: 12px;
color: var(--semi-color-text-2);
white-space: nowrap;
flex-shrink: 0;
}
/* ===== 标题 / URL ===== */
.title {
font-size: 12.5px;
line-height: 1.3;
color: var(--semi-color-text-1);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 3px;
/* 无标题时占位,保证所有卡片等高(标题基线对齐) */
min-height: 16px;
}
.url {
display: block;
font-size: 11.5px;
color: var(--semi-color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
margin-bottom: 10px;
}
.url:hover {
color: var(--semi-color-primary);
text-decoration: underline;
}
/* ===== 底部 ===== */
.meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--semi-color-text-2);
/* 推到底部:同一行所有卡片的上传状态对齐在同一水平线 */
margin-top: auto;
/* 无上传状态时占位,保证所有卡片等高 */
min-height: 20px;
}
/* 底部操作栏(仅直播管理页使用) */
.actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
padding-top: 11px;
border-top: 1px solid var(--semi-color-border);
}
.actions :global(.semi-button) {
padding: 5px;
}
/* 减少动态偏好 */
@media (prefers-reduced-motion: reduce) {
.badgeLive .bdot {
animation: none;
}
}