Files
NoahandAluneu f06efbb3b9 feat(ui): 前端整体重设计与统一(接手 #1653) (#1704)
* feat(ui): 前端整体重设计与统一(接手 #1653)

来自 Aluneu 的 PR #1653(head bdcfbea),在 master 4e156ac 上重新落地。
相对原 PR 的差异:
- 剔除 logo / favicon / logo.svg 品牌资产变更,侧栏与登录页仍用 public/logo.png
- 解决与 master 的三处冲突:upload-manager add/edit 的 studioEntity 补回 #1699 的 tid_v2;
  global.tsx 下载插件说明补上 mesio(9d02856)
- README.md 相关提交在原 PR 合并上游时已归零,此处不含

主要内容:
- PageHeader / SectionTitle / data-card 统一页头与卡片容器,status.tsx 作为状态视觉的唯一来源
- 侧栏自绘重写:5 组 9 项、服务状态指示、折叠偏好持久化、移动端抽屉
- 首页从外链 iframe 改为本地数据的控制台(KPI / 进行中 / 未开播 / 24h 事件流)
- 直播管理:搜索、平台筛选、网格/列表双视图、批量暂停/删除;接口失败不再被当作空列表
- 新增 /changelog 页,运行时拉取官方 CHANGELOG,带 localStorage 离线缓存
- 全局自定义背景:仅本地图片,canvas 重绘为 JPEG data URL,2MB 上限
- 修复:根 layout 与 (app)/layout 双重 <html>/<body>、主题水合闪烁、扫码登录无保护取值崩溃、
  弹窗内 width:1000 横向溢出、移动端抽屉被遮罩拦截
- 后处理 {cmd,value} ↔ HookStep 转换抽成 lib/postprocessor.ts
- 12 个平台插件文案精简

* fix(ui): 空间配置平台列表改为遍历插件注册表

dashboard 里硬编码的 PLATFORM_LIST 让新增平台插件要改第三个地方,漏了就在 UI 里静默消失。
把顺序与文案登记到 app/ui/plugins/index.ts 的 PlatformPanels,dashboard 只负责渲染。

* fix(ui): 控制台断连判定改为全部接口失败才触发,并放宽轮询频率

- /v1/status 单独失败时原先把整个控制台替换成「无法连接后端」,主播卡片全部消失;
  现在只有四个接口全部失败才判定断连,单个接口失败只显示对应提示条
- 主播状态 4s → 10s;streamer-info(全表)、videos(扫目录)、status(序列化整份配置)
  30s → 60s;侧栏与直播管理页共用同一组常量
- 事件流的平台名复用 status.tsx 的 platformName,删掉 use-dashboard 里的重复实现
- 首页「文档」链接对齐 README 里的 biliup.github.io 地址

* fix(ui): 直播状态文案与配色在卡片和列表视图保持一致

- 列表视图的「未开播」原是红底白字,与卡片的灰色徽章不一致;改为同一套三色
- 补回 Pending →「检测中」(原页面就有,重设计后被吞成「未开播」)
- OutOfSchedule 不再归到绿色录制组,绿色只留给 Working
- 删除无人引用的 statusMeta / platformTag

* fix(ui): 编辑投稿模板页在分区列表加载失败时给出原因而不是空白

/bili/archive/pre 需要可用的 B 站 cookie,没有账号时后端返回 500;原先 isError 为真但
error 为 undefined,页面渲染成一个空 div。现在保留页头并显示服务端返回的 message。

* fix(ui): 更新日志页不再把过时的最后一条标成「最新」

仓库里的 CHANGELOG.md 停在 1.2.1,而运行版本已是 1.2.5。页面现在读取 /v1/status 的版本号:
运行版本比日志最新一条还新时去掉「最新」标签并在顶部如实说明;日志里有当前版本时标「当前运行」。

* chore(ui): 清理重设计遗留的重复代码与死代码

- 直播管理页网格 / 列表两份完全相同的操作按钮合并为一处
- 去掉源码里面向评审工具的注释(「相对 PR 版本的增强」「保留 PR 逻辑」等)
- useGlobalBackground 删除 URL 模式残留的 validateBg / isRemoteUrl,遮罩透明度钳制与滑块范围对齐(0.05–0.8)
- 删除无人引用的 (app)/page.module.css 与 BiliupStatus 类型
- 移除 react-use(useWindowSize 已由 useIsMobile 替代,代码里已无引用)

* fix(ui): 全局设置里 sync_save_dir 输入框随下载插件下拉框即时显示

原先用 useFormApi().getValue() 做条件渲染,useFormApi 不订阅表单值变化,
选了 sync-downloader 之后输入框要先保存再刷新才出现。改用 useFormState 订阅 values。

* chore(ui): 删除原 PR 已移除的两个无人引用样式文件

app/page.module.css 与 app/styles/Home.module.css 是脚手架遗留,#1653 已删除;
接手时从合并树导入文件没有带上删除,这里补齐。

* fix(ui): 按 review 意见重审设置页文案,优先清晰无歧义

回应 #1704 的 review:设置页文案不再追求字数少,改为直观、清晰、无歧义。
逐条对照 master 原文与 Rust 实现重写,凡是精简后丢失默认值、单位、取值格式、
生效条件、依赖关系或风险提示的条目一律补回,用换行分点、加粗标出风险项。

- 12 个平台面板 + 全局「下载插件」说明:恢复多行结构,补回默认值 / 单位 / 格式 /
  生效条件(如 douyin_true_origin 需画质为原画且协议为 flv、twitch_disable_ads /
  twitch_cookie 仅 streamlink / ffmpeg 生效、youtube_cookie 填的是文件路径、
  日期格式 YYYYMMDD、大小格式 100M/5G、编码多选用 | 分隔)
- 与实现对齐并修正原文:bilibili 弹幕不再限定非 stream-gears、且随录像分段;
  bili_cookie 与 bili_cookie_file 同时填写时以文本为准;youtube_prefer_vcodec /
  acodec 是编码而非封装格式;streamlink 现可直接下载 FLV 直链;
  youtube_enable_download_playback「只想录直播的可以开启」笔误改为「关闭」;
  bili_qn 占位符改为 25000(原画),与实现默认值一致
- 空间配置页头描述改为准确描述三个 Tab 并提示需点「保存」
- 链接颜色改用 var(--semi-color-link)(原 rgb(var(...)) 为无效 CSS)

不涉及任何逻辑改动。

---------

Co-authored-by: Aluneu <19215190873@163.com>
2026-09-21 13:46:15 +00:00

116 lines
4.9 KiB
TypeScript

import React from 'react'
import { Tag } from '@douyinfe/semi-ui'
/**
* 状态视觉体系(全局唯一事实来源)。
* 后端 status / upload_status 均为 WorkerStatus 枚举的 Debug 字符串,
* 常见取值:Working / Pending / Idle / Pause / OutOfSchedule / TitleExcluded / ""(空)。
*/
export const LIVE_STATUS = 'Working'
export const PAUSE_STATUS = 'Pause'
export const PENDING_STATUS = 'Pending'
/** 录制策略排期外(recording_policy.rs::Rejection::OutOfTimeRange) */
export const OUT_OF_SCHEDULE = 'OutOfSchedule'
/** 标题命中排除关键词(recording_policy.rs::Rejection::ExcludedKeyword) */
export const TITLE_EXCLUDED = 'TitleExcluded'
/** 统一后端版本号展示,避免版本字段自带 v 时渲染成 vv1.2.2。 */
export function formatVersion(version?: string): string | undefined {
return typeof version === 'string' ? version.replace(/^v/i, '') : undefined
}
export function isLiveStatus(status?: string): boolean {
return status === LIVE_STATUS
}
export function isPaused(status?: string): boolean {
return status === PAUSE_STATUS
}
/** 直播状态 → 徽章视觉配置 */
export interface StatusVisual {
label: string
/** 样式类:badgeLive(绿,录制中)/ badgePause(橙,人为或规则暂停)/ badgeIdle(灰,其余) */
cls: 'badgeLive' | 'badgePause' | 'badgeIdle'
}
/**
* 与 master 上原 streamers 页的 switch 保持同一组文案:
* Working 直播中 / Pending 检测中 / Idle 空闲(未开播) / OutOfSchedule 非录播时间 /
* TitleExcluded 标题已排除 / Pause 暂停中。绿色只给真正在录的 Working。
*/
export function streamerStatusMeta(status?: string): StatusVisual {
if (isLiveStatus(status)) return { label: '直播中', cls: 'badgeLive' }
if (status === PENDING_STATUS) return { label: '检测中', cls: 'badgeIdle' }
if (status === OUT_OF_SCHEDULE) return { label: '非录播时间', cls: 'badgeIdle' }
if (status === TITLE_EXCLUDED) return { label: '标题已排除', cls: 'badgePause' }
if (isPaused(status)) return { label: '已暂停', cls: 'badgePause' }
return { label: '未开播', cls: 'badgeIdle' }
}
/**
* 上传状态 → 独立的 Tag(与直播状态是两个维度,保留)。
* 重要:后端 upload_status 是 WorkerStatus 枚举的 Debug 字符串,
* 真实取值只有 Working / Pending / Idle / Pause / ""(空),没有 Failed / Uploaded。
* 上传失败不会体现在这个字段里。
*/
export function uploadStatusTag(uploadStatus?: string): React.ReactNode {
// 后端 upload_status 是 WorkerStatus 枚举:Working / Pending / Idle / Pause / ""(空)。
// Idle / 空 表示没有上传活动,不是"上传完成";不配置上传时就是这个状态。
// 因此只有真正有上传任务时才显示 Tag,避免没配上传却显示"待上传"/"上传完成"。
const map: Record<string, { text: string; bg: string; color: string }> = {
Working: { text: '上传中', bg: 'rgba(var(--semi-blue-4), 1)', color: '#fff' },
Pending: { text: '待上传', bg: 'rgba(var(--semi-grey-3), 1)', color: 'var(--semi-color-text-0)' },
Pause: { text: '上传暂停', bg: 'rgba(var(--semi-yellow-4), 1)', color: 'var(--semi-color-text-0)' },
}
const cfg = map[uploadStatus ?? '']
if (!cfg) return null
return (
<Tag
size="small"
style={{ backgroundColor: cfg.bg, color: cfg.color, border: 'none', fontWeight: 500 }}
>
{cfg.text}
</Tag>
)
}
/** 直播状态 → Tag(直播管理页列表视图用),颜色与卡片徽章同一套 */
export function streamerStatusTag(status?: string): React.ReactNode {
const m = streamerStatusMeta(status)
const palette: Record<StatusVisual['cls'], { bg: string; color: string }> = {
badgeLive: { bg: 'rgba(var(--semi-green-4), 0.13)', color: 'rgb(var(--semi-green-5))' },
badgePause: { bg: 'rgba(var(--semi-orange-4), 0.14)', color: 'rgb(var(--semi-orange-5))' },
badgeIdle: { bg: 'var(--semi-color-fill-1)', color: 'var(--semi-color-text-1)' },
}
const c = palette[m.cls]
return (
<Tag
size="small"
style={{ backgroundColor: c.bg, color: c.color, border: 'none', fontWeight: 600 }}
>
{m.label}
</Tag>
)
}
/** 直播地址 → 平台中文名(统一识别逻辑,避免各页面各写一份) */
export function platformName(url?: string): string {
if (!url) return '直播源'
const lower = url.toLowerCase()
if (lower.includes('bilibili.com') || lower.includes('b23.tv')) return '哔哩哔哩'
if (lower.includes('huya.com')) return '虎牙直播'
if (lower.includes('douyu.com')) return '斗鱼直播'
if (lower.includes('youtube.com') || lower.includes('youtu.be')) return 'YouTube'
if (lower.includes('twitch.tv')) return 'Twitch'
if (lower.includes('cc.163.com')) return 'CC直播'
if (lower.includes('kuaishou.com')) return '快手直播'
if (lower.includes('douyin.com')) return '抖音直播'
try {
return new URL(url).hostname || '直播源'
} catch {
return '直播源'
}
}