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>
This commit is contained in:
NoahandAluneu authored and GitHub committed 2026-09-21 13:46:15 +00:00
1 parent 4e156ac683
commit f06efbb3b9
65 files changed
+4385 -2030

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ biliup/**/public
!package-lock.json
qrcode.png
*.log
*.tsbuildinfo
/*.ts
/biliup/stream-gears/
data/
+43
View File
@@ -0,0 +1,43 @@
/* 全局自定义背景:当 body 带 app-has-bg 时生效 */
html,
body {
overflow-x: hidden;
}
body.app-has-bg::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: -2;
background-image: var(--app-bg-image);
background-repeat: no-repeat;
background-size: cover;
background-position: center;
pointer-events: none;
}
/* 同色半透明遮罩,保证所有页面文字可读(明暗主题都跟随,透明度可调) */
body.app-has-bg::after {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: var(--semi-color-bg-0);
opacity: var(--app-bg-mask-opacity, 0.35);
z-index: -1;
pointer-events: none;
}
/* 让 Semi 顶层容器透出 body 背景(兼容旧布局) */
body.app-has-bg .semi-layout,
body.app-has-bg .semi-sider,
body.app-has-bg .semi-nav,
body.app-has-bg .semi-layout-content,
body.app-has-bg .semi-layout-header {
background: transparent !important;
}
+104
View File
@@ -0,0 +1,104 @@
.content {
padding: 24px;
min-height: calc(100vh - 69px);
}
.feed,
.center {
position: relative;
z-index: 1;
}
.center {
display: flex;
align-items: center;
justify-content: center;
min-height: 320px;
}
/* 版本卡片流:居中、限制最大阅读宽度 */
.feed {
max-width: 820px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.card {
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
background: var(--semi-color-bg-1);
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px 24px 0;
}
.versionBlock {
display: flex;
align-items: center;
gap: 10px;
}
.versionTitle {
margin: 0 !important;
color: var(--semi-color-text-0);
font-family: var(--semi-font-regular);
}
.ul {
padding-left: 18px;
margin: 8px 0 14px;
}
.li {
margin: 4px 0;
font-size: 13px;
line-height: 1.7;
color: var(--semi-color-text-1);
}
.subhead {
font-size: 13px;
font-weight: 600;
margin: 14px 0 4px;
color: var(--semi-color-text-0);
}
.subheadMuted {
font-size: 12px;
font-weight: 600;
margin: 16px 0 4px;
color: var(--semi-color-text-2);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.subheadMuted + .ul .li {
color: var(--semi-color-text-2);
}
.para {
font-size: 13px;
margin: 6px 0;
color: var(--semi-color-text-1);
}
.link {
color: rgb(var(--semi-blue-4));
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
/* 第一个(最新)版本卡片轻微强调 */
.feed > .card:first-child {
border-color: var(--semi-color-primary);
}
+311
View File
@@ -0,0 +1,311 @@
'use client'
import React from 'react'
import { Layout, Card, Button, Spin, Empty, Tag, Typography, Tooltip } from '@douyinfe/semi-ui'
import useSWR from 'swr'
import { IconBook, IconExport } from '@douyinfe/semi-icons'
import PageHeader from '../components/PageHeader'
import { fetcher } from '@/app/lib/api-streamer'
import { formatVersion } from '@/app/lib/status'
import { SLOW_REFRESH_MS } from '@/app/lib/use-dashboard'
import styles from './changelog.module.scss'
const { Header, Content } = Layout
const { Title, Text } = Typography
// 数据源:biliup 官方 CHANGELOG(随版本自动更新,零后端改动)
const CHANGELOG_URL =
'https://raw.githubusercontent.com/biliup/biliup/master/docs/content/docs/guide/CHANGELOG.md'
const MAX_VERSIONS = 25
const CHANGELOG_CACHE_KEY = 'biliup_changelog_cache'
/**
* 拉取官方 CHANGELOG:成功则写入 localStorage 作为离线缓存;
* 失败(离线 / GitHub 被墙 / 接口异常)时回退到上一次缓存,
* 避免页面直接白屏成「无法加载」。仅在既失败又无缓存时才真正报错。
*/
async function fetchChangelog(url: string): Promise<{ text: string; stale: boolean }> {
try {
const r = await fetch(url)
if (!r.ok) throw new Error(`http ${r.status}`)
const text = await r.text()
try {
localStorage.setItem(CHANGELOG_CACHE_KEY, text)
} catch {
/* 隐私模式等场景忽略写入失败 */
}
return { text, stale: false }
} catch {
let cached: string | null = null
try {
cached = localStorage.getItem(CHANGELOG_CACHE_KEY)
} catch {
cached = null
}
if (cached) return { text: cached, stale: true }
throw new Error('changelog fetch failed and no cache')
}
}
export interface VersionEntry {
version: string
body: string
compareUrl?: string
}
/**
* 把 CHANGELOG markdown 解析为版本数组。
* - 跳过文档站 frontmatter(+++ ... +++)
* - 仅保留 `## 版本号` 段,过滤 `## 标签含义` 等噪声段
* - 提取 `**Full Changelog**: [text](url)` 外链,并从正文移除
* - 取最近 MAX_VERSIONS 个版本
*/
function parseChangelog(md: string): VersionEntry[] {
const noFm = md.replace(/^\+\+\+[\s\S]*?\+\+\+\s*/, '')
const lines = noFm.split(/\r?\n/)
const out: VersionEntry[] = []
let cur: { version: string; lines: string[]; compareUrl?: string } | null = null
const fullChangelogRe = /^\s*\*\*Full Changelog\*\*\s*:\s*\[([^\]]+)\]\(([^)]+)\)\s*$/
for (const line of lines) {
const m = line.match(/^##\s+([\d.]+(-[\d.]+)?)\s*$/)
if (m) {
if (cur) out.push({ version: cur.version, body: cur.lines.join('\n').trim(), compareUrl: cur.compareUrl })
cur = { version: m[1], lines: [] }
} else if (/^##\s+/.test(line)) {
// 非版本标题(如「标签含义」),结束当前收集
cur = null
} else if (cur) {
const fm = line.match(fullChangelogRe)
if (fm && /^https?:\/\//i.test(fm[2])) {
cur.compareUrl = fm[2]
continue
}
cur.lines.push(line)
}
}
if (cur) out.push({ version: cur.version, body: cur.lines.join('\n').trim(), compareUrl: cur.compareUrl })
return out.slice(0, MAX_VERSIONS)
}
// 行内渲染:将 [text](url) 渲染为外链(仅放行 http/https,防注入),**bold** 加粗
function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
const out: React.ReactNode[] = []
const linkRe = /\[([^\]]+)\]\(([^)\s]+)\)/g
let last = 0
let m: RegExpExecArray | null
let i = 0
const pushText = (t: string, k: string) => {
const parts = t.split(/\*\*([^*]+)\*\*/)
parts.forEach((p, idx) => {
if (p === '') return
out.push(
idx % 2 === 1 ? (
<b key={`${k}-b${idx}`}>{p}</b>
) : (
<span key={`${k}-t${idx}`}>{p}</span>
)
)
})
}
while ((m = linkRe.exec(text)) !== null) {
if (m.index > last) pushText(text.slice(last, m.index), `${keyPrefix}-${i}`)
const url = m[2]
const safe = /^https?:\/\//i.test(url)
out.push(
safe ? (
<a
key={`${keyPrefix}-l${i}`}
href={url}
target="_blank"
rel="noreferrer"
className={styles.link}
>
{m[1]}
</a>
) : (
<span key={`${keyPrefix}-l${i}`}>{m[1]}</span>
)
)
last = m.index + m[0].length
i++
}
if (last < text.length) pushText(text.slice(last), `${keyPrefix}-${i}`)
return out
}
// 把一个版本的正文渲染成:小标题 / 列表 / 普通段落
function renderBody(body: string, versionKey: string): React.ReactNode {
const lines = body.split(/\r?\n/)
const blocks: React.ReactNode[] = []
let listBuf: string[] = []
const flush = (k: string) => {
if (listBuf.length === 0) return
const items = listBuf
listBuf = []
blocks.push(
<ul key={`${k}-ul`} className={styles.ul}>
{items.map((it, idx) => (
<li key={idx} className={styles.li}>
{renderInline(it.replace(/^[-*]\s+/, ''), `${k}-${idx}`)}
</li>
))}
</ul>
)
}
lines.forEach((line, idx) => {
const h = line.match(/^###\s+(.+)$/)
if (h) {
flush(`${versionKey}-${idx}`)
const isContributors = /New Contributors/i.test(h[1])
blocks.push(
<div key={`${versionKey}-h${idx}`} className={isContributors ? styles.subheadMuted : styles.subhead}>
{h[1]}
</div>
)
} else if (/^[-*]\s+/.test(line)) {
listBuf.push(line)
} else if (line.trim() === '') {
flush(`${versionKey}-${idx}`)
} else {
flush(`${versionKey}-${idx}`)
blocks.push(
<div key={`${versionKey}-p${idx}`} className={styles.para}>
{renderInline(line, `${versionKey}-${idx}`)}
</div>
)
}
})
flush(`${versionKey}-end`)
return <>{blocks}</>
}
/** 比较 "1.2.5" 与 "1.2.1" 这类点分版本号,返回 a - b 的符号 */
function compareVersion(a: string, b: string): number {
const pa = a.split(/[.-]/).map(Number)
const pb = b.split(/[.-]/).map(Number)
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
if (d !== 0) return d
}
return 0
}
export default function Changelog() {
const { data, error, isLoading } = useSWR(CHANGELOG_URL, fetchChangelog)
const result = data as { text: string; stale: boolean } | undefined
const versions = result ? parseChangelog(result.text) : []
// 与侧栏共用 /v1/status 的缓存,只为拿到正在运行的版本号
const { data: status } = useSWR('/v1/status', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
revalidateOnFocus: false,
})
const running = formatVersion((status as { version?: string } | undefined)?.version)
const top = versions[0]?.version
// CHANGELOG.md 未必随每次发版更新;运行版本比日志里最新一条还新时如实标出,
// 不把旧条目标成「最新」
const runningIsNewer = !!running && !!top && compareVersion(running, top) > 0
return (
<>
<Header
style={{
backgroundColor: 'var(--semi-color-bg-1)',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
<PageHeader
icon={<IconBook size="large" />}
title="更新日志"
description="biliup 版本更新记录"
/>
</Header>
<Content>
<main className={styles.content}>
{isLoading && (
<div className={styles.center}>
<Spin size="large" />
</div>
)}
{error && (
<Empty
title="无法加载更新日志"
description="请检查网络连接(更新日志内容来自 GitHub)"
/>
)}
{!isLoading && !error && versions.length > 0 && (
<div className={styles.feed}>
{result?.stale && (
<div
style={{
fontSize: 12,
color: 'var(--semi-color-text-2)',
textAlign: 'center',
padding: '4px 0 12px',
}}
>
当前展示的是离线缓存版本,联网后将自动更新。
</div>
)}
{runningIsNewer && (
<div
style={{
fontSize: 12,
color: 'var(--semi-color-text-2)',
textAlign: 'center',
padding: '4px 0 12px',
}}
>
当前运行的 v{running} 尚未收录到更新日志,以下为已发布的历史版本。
</div>
)}
{versions.map((v, idx) => (
<Card
key={v.version}
className={styles.card}
shadows="hover"
bodyStyle={{ padding: '20px 24px' }}
title={
<div className={styles.cardHeader}>
<div className={styles.versionBlock}>
<Title heading={5} className={styles.versionTitle}>
{v.version}
</Title>
{idx === 0 && !runningIsNewer && (
<Tag color="green" type="light" size="small">
最新
</Tag>
)}
{running && v.version === running && (
<Tag color="blue" type="light" size="small">
当前运行
</Tag>
)}
</div>
{v.compareUrl && (
<Tooltip content="完整变更" position="top">
<Button
type="tertiary"
theme="borderless"
icon={<IconExport />}
onClick={() => window.open(v.compareUrl, '_blank', 'noopener,noreferrer')}
/>
</Tooltip>
)}
</div>
}
>
{renderBody(v.body, v.version)}
</Card>
))}
</div>
)}
</main>
</Content>
</>
)
}
@@ -0,0 +1,53 @@
/* 右下角背景设置浮层:竖向卡片(上传/清除 + 遮罩透明度滑块) */
.float {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 50;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 6px;
border-radius: 999px;
background: var(--semi-color-bg-1);
border: 1px solid var(--semi-color-border);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
transition: border-radius 0.15s ease;
}
/* 展开态:竖向卡片 */
.float.open {
flex-direction: column;
align-items: stretch;
gap: 6px;
padding: 8px;
border-radius: 14px;
min-width: 180px;
}
.btnRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 0 2px;
}
.opacityRow {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 8px 4px;
border-top: 1px solid var(--semi-color-border);
}
.opacityLabel {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
color: var(--semi-color-text-2);
font-variant-numeric: tabular-nums;
}
+162
View File
@@ -0,0 +1,162 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Button, Slider, Toast, Tooltip } from '@douyinfe/semi-ui'
import { IconImage, IconUpload, IconDelete } from '@douyinfe/semi-icons'
import {
getBg,
setBg,
getBgOpacity,
setBgOpacity,
} from '@/app/lib/useGlobalBackground'
import styles from './BackgroundSetter.module.scss'
/**
* 背景图压缩:统一压成 JPEG 并限制体积,避免超大的 data URL 塞进
* localStorage(配额约 5MB)导致刷新后背景丢失。
*/
const MAX_BG_BYTES = 2 * 1024 * 1024 // 压缩后上限 2MB(data URL 字符数约等于字节数×1.33)
const MAX_BG_DIMENSION = 1600
function compressImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
// 仅接受可转 JPEG 的位图格式;动图(GIF/AVIF)会被取首帧、特殊格式会失真或过大,不在此列
if (!/^image\/(jpeg|png|webp|bmp)$/i.test(file.type)) {
reject(new Error('不支持的图片格式'))
return
}
const reader = new FileReader()
reader.onerror = reject
reader.onload = () => {
const img = new Image()
img.onerror = reject
img.onload = () => {
const max = MAX_BG_DIMENSION
let { width, height } = img
if (width > max || height > max) {
const ratio = Math.min(max / width, max / height)
width = Math.round(width * ratio)
height = Math.round(height * ratio)
}
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('图片处理失败'))
return
}
ctx.drawImage(img, 0, 0, width, height)
// 统一压成 JPEG;1600px 上限下体积通常在几百 KB,这里再做一层硬校验,
// 极端高分辨率图若仍超限则拒绝,避免超大 data URL 撑爆 localStorage。
const dataUrl = canvas.toDataURL('image/jpeg', 0.7)
if (dataUrl.length > MAX_BG_BYTES) {
reject(new Error('图片过大,请换一张更小的图片'))
return
}
resolve(dataUrl)
}
img.src = reader.result as string
}
reader.readAsDataURL(file)
})
}
export default function BackgroundSetter() {
const fileRef = useRef<HTMLInputElement>(null)
const [open, setOpen] = useState(false)
const [hasBg, setHasBg] = useState(false)
const [opacity, setOpacity] = useState(0.35)
useEffect(() => {
setHasBg(!!getBg())
setOpacity(getBgOpacity())
}, [])
const onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (!file.type.startsWith('image/')) {
Toast.error('请选择图片文件')
return
}
try {
const data = await compressImage(file)
setBg(data)
setHasBg(true)
setOpen(false)
Toast.success('背景已应用')
} catch (err: any) {
// 区分具体失败原因:格式不支持 / 图片过大 / 处理失败
Toast.error(err?.message || '图片处理失败')
}
e.target.value = ''
}
const clear = () => {
setBg('')
setHasBg(false)
setOpen(false)
Toast.success('已恢复默认背景')
}
const onOpacityChange = (v: number | number[] | undefined) => {
const raw = Array.isArray(v) ? (v[0] ?? 0) : (v ?? 0)
const val = Math.min(0.8, Math.max(0.05, Number(raw) / 100))
setOpacity(val)
setBgOpacity(val)
}
return (
<div className={`${styles.float} ${open ? styles.open : ''}`}>
<input ref={fileRef} type="file" accept="image/*" hidden onChange={onFile} />
{open && (
<>
<div className={styles.btnRow}>
<Tooltip content="上传背景" position="top">
<Button
theme="borderless"
size="small"
icon={<IconUpload />}
onClick={() => fileRef.current?.click()}
/>
</Tooltip>
<Tooltip content="清除背景" position="top">
<Button
theme="borderless"
size="small"
icon={<IconDelete />}
disabled={!hasBg}
onClick={clear}
/>
</Tooltip>
</div>
<div className={styles.opacityRow}>
<div className={styles.opacityLabel}>
<span>遮罩透明度</span>
<span>{Math.round(opacity * 100)}%</span>
</div>
<Slider
min={5}
max={80}
step={5}
value={Math.round(opacity * 100)}
onChange={onOpacityChange}
tipFormatter={(v) => `${v}%`}
/>
</div>
</>
)}
<Tooltip content="背景设置" position="top">
<Button
theme={open ? 'solid' : 'borderless'}
size="small"
icon={<IconImage />}
onClick={() => setOpen((v) => !v)}
/>
</Tooltip>
</div>
)
}
@@ -0,0 +1,38 @@
.header {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
background: var(--semi-color-bg-1);
border-bottom: 1px solid var(--semi-color-border);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--semi-border-radius-large);
background: var(--semi-color-primary-light-default);
color: var(--semi-color-primary);
flex-shrink: 0;
}
.titles {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
+39
View File
@@ -0,0 +1,39 @@
'use client'
import { Typography } from '@douyinfe/semi-ui'
import { ReactNode } from 'react'
import styles from './PageHeader.module.scss'
const { Title, Text } = Typography
/**
* 统一的页面头部范式:标题 + 描述 + 右侧操作区。
* 所有业务页面都应以 <PageHeader /> 开头,保证全局一致。
*/
export default function PageHeader({
title,
description,
icon,
actions,
}: {
title: ReactNode
description?: ReactNode
icon?: ReactNode
actions?: ReactNode
}) {
return (
<header className={styles.header}>
{icon ? <div className={styles.icon}>{icon}</div> : null}
<div className={styles.titles}>
<Title heading={4} style={{ margin: 0 }}>
{title}
</Title>
{description ? (
<Text type="tertiary" size="small">
{description}
</Text>
) : null}
</div>
{actions ? <div className={styles.actions}>{actions}</div> : null}
</header>
)
}
@@ -0,0 +1,24 @@
.wrap {
display: inline-flex;
align-items: center;
column-gap: 8px;
}
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--semi-border-radius-medium);
background: var(--semi-color-primary-light-default);
color: var(--semi-color-primary);
flex-shrink: 0;
}
.title {
font-size: 15px;
font-weight: 600;
color: var(--semi-color-text-0);
vertical-align: bottom;
}
+23
View File
@@ -0,0 +1,23 @@
'use client'
import { ReactNode } from 'react'
import styles from './SectionTitle.module.scss'
/**
* 统一的 section 标题范式:主色图标 + 标题。
* 替代 dashboard 里各 frame 的「硬编码 RGB 色块 + 白色图标」野路子结构,
* 让所有 section 头共享同一套设计语言(主色图标容器)。
*/
export default function SectionTitle({
icon,
title,
}: {
icon: ReactNode
title: ReactNode
}) {
return (
<div className={styles.wrap}>
<span className={styles.icon}>{icon}</span>
<span className={styles.title}>{title}</span>
</div>
)
}
+55 -79
View File
@@ -1,11 +1,8 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import EditTemplate from '@/app/(app)/upload-manager/edit/page'
import React, { useRef, useState } from 'react'
import {
Button,
Form,
Layout,
Nav,
Collapse,
Avatar,
Select,
@@ -16,7 +13,6 @@ import {
Tabs,
TabPane,
} from '@douyinfe/semi-ui'
import { registerMediaQuery, responsiveMap } from '@/app/lib/utils'
import { IconPlusCircle, IconStar, IconGlobe } from '@douyinfe/semi-icons'
import useSWR from 'swr'
import { fetcher, put } from '@/app/lib/api-streamer'
@@ -24,16 +20,18 @@ import useSWRMutation from 'swr/mutation'
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import { useBiliUsers } from '../../lib/use-streamers'
import styles from '../../styles/dashboard.module.scss'
import PageHeader from '../components/PageHeader'
import SectionTitle from '../components/SectionTitle'
import dc from '@/app/ui/data-card.module.scss'
// 注册各平台组件
import plugins from '../../ui/plugins'
import { PlatformPanels } from '../../ui/plugins'
import Global from '../../ui/plugins/global'
import Developer from '../../ui/plugins/developer'
const Dashboard: React.FC = () => {
const { Header, Content } = Layout
const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
const { data: entity, error, isLoading } = useSWR('/v1/configuration', fetcher)
const { trigger } = useSWRMutation('/v1/configuration', put)
const formRef = useRef<FormApi>()
// const [formKey, setFormKey] = useState(0); // 初始化一个key
@@ -63,6 +61,9 @@ const Dashboard: React.FC = () => {
const { biliUsers } = useBiliUsers()
// 平台设置:左列平台名 + 右栏仅展开选中平台。列表来自插件注册表 PlatformPanels
const [activePlatform, setActivePlatform] = useState(PlatformPanels[0].key)
if (isLoading) {
return <>Loading</>
}
@@ -88,48 +89,23 @@ const Dashboard: React.FC = () => {
return (
<>
<Header
style={{
backgroundColor: 'var(--semi-color-bg-1)',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
<Nav
header={
<>
<div
style={{
backgroundColor: '#6b6c75ff',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
// justifyContent: 'center',
padding: '6px',
}}
>
<IconStar size="large" />
</div>
<h4 style={{ marginLeft: '12px' }}>空间配置</h4>
</>
}
footer={
<Button
onClick={() => {
formRef.current?.submitForm()
}}
icon={<IconPlusCircle />}
theme="solid"
style={{ marginRight: 10 }}
>
保存
</Button>
}
mode="horizontal"
></Nav>
</Header>
<Content>
<PageHeader
icon={<IconStar size="large" />}
title="空间配置"
description="全局下载 / 上传参数、各平台录制参数与开发者选项。修改后需点击右上角「保存」才会生效"
actions={
<Button
onClick={() => {
formRef.current?.submitForm()
}}
icon={<IconPlusCircle />}
theme="solid"
>
保存
</Button>
}
/>
<div className={dc.content}>
<main className={styles.rootConfigPanel}>
<div className={styles.main}>
<div className={styles.content}>
@@ -165,43 +141,43 @@ const Dashboard: React.FC = () => {
<Tabs
type="line"
contentStyle={{
maxWidth: 965,
// marginLeft: 'auto',
// marginRight: 'auto',
margin: '10px auto 0 auto',
margin: '10px 0 0 0',
}}
>
<TabPane tab="全局设置" itemKey="1">
{/* 全局设置 */}
<Global />
</TabPane>
<TabPane tab="各平台下载" itemKey="2">
{/* 各平台下载 */}
<TabPane tab="平台设置" itemKey="2">
{/* 平台设置:左列平台名 + 右栏选中表单 */}
<div className={styles.framePlatformConfig}>
<div className={styles.frameInside}>
<div className={styles.group}>
<div className={styles.buttonOnlyIconSecond}>
<div
className={styles.lineStory}
style={{
color: 'var(--semi-color-bg-0)',
display: 'flex',
}}
<SectionTitle icon={<IconGlobe size="small" />} title="平台设置" />
<div className={styles.platformLayout}>
<nav className={styles.platformNav}>
{PlatformPanels.map(p => (
<button
key={p.key}
type="button"
className={`${styles.platformNavItem} ${
activePlatform === p.key ? styles.platformNavItemActive : ''
}`}
onClick={() => setActivePlatform(p.key)}
>
<IconGlobe size="small" />
</div>
</div>
</div>
<p className={styles.meegoSharedWebSettin}>各平台下载设置</p>
</div>
<Collapse keepDOM style={{ width: '100%' }}>
{Object.entries(plugins)
.filter(([key]) => key !== 'Cookie')
.map(([key, Plugin]) => (
<Plugin key={key} entity={entity} list={list} />
{p.name}
</button>
))}
<plugins.Cookie entity={entity} list={list} />
</Collapse>
</nav>
<div className={styles.platformBody}>
{/* 所有平台组件保持挂载(keepDOM),仅展开当前平台面板。
卸载会注销 Semi Form 字段状态,提交时仅剩挂载字段;后端 PUT /configuration
整表覆盖保存,会清空其他平台的参数与凭据 */}
<Collapse keepDOM activeKey={[activePlatform]}>
{PlatformPanels.map(p => (
<p.Component key={p.key} entity={entity} list={list} />
))}
</Collapse>
</div>
</div>
</div>
</TabPane>
<TabPane tab="开发者选项" itemKey="3">
@@ -215,7 +191,7 @@ const Dashboard: React.FC = () => {
</div>
</div>
</main>
</Content>
</div>
</>
)
}
+37 -73
View File
@@ -1,36 +1,36 @@
'use client'
import { Layout, Modal, Nav, Typography } from '@douyinfe/semi-ui'
import { IconUserCardVideo, IconVideoListStroked } from '@douyinfe/semi-icons'
import { Table } from '@douyinfe/semi-ui'
import { Modal, Table, Typography } from '@douyinfe/semi-ui'
import { IconVideoListStroked } from '@douyinfe/semi-icons'
import { SortOrder } from '@douyinfe/semi-ui/lib/es/table'
import useSWR from 'swr'
import { fetcher, FileList } from '@/app/lib/api-streamer'
import { useState } from 'react'
import dynamic from 'next/dynamic'
import { humDate } from '@/app/lib/utils'
import { formatSize } from '@/app/lib/use-dashboard'
import PageHeader from '../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
const Players = dynamic(() => import('@/app/ui/Player'), {
ssr: false,
})
export default function Home() {
const { Header, Footer, Sider, Content } = Layout
const { data: data, error, isLoading } = useSWR<FileList[]>('/v1/videos', fetcher)
export default function History() {
const { Text } = Typography
const { data: data, error, isLoading } = useSWR<FileList[]>('/v1/videos', fetcher)
const [fileName, setFileName] = useState<string>()
const [visible, setVisible] = useState(false)
const columns = [
{
title: '标题',
dataIndex: 'name',
render: (text: any, record: any, index: any) => {
return <Text strong>{text}</Text>
},
// onFilter: (value, record) => record.name.includes(value)
render: (text: any) => <Text strong>{text}</Text>,
},
{
title: '大小',
dataIndex: 'size',
render: (size: number) => `${(size / 1024 / 1024).toFixed(2)} MB`,
render: (size: number) => formatSize(size || 0),
},
{
title: '更新日期',
@@ -42,85 +42,49 @@ export default function Home() {
{
title: '',
dataIndex: 'operate',
render: (text: any, record: any, index: number) => {
return (
<IconUserCardVideo
style={{ cursor: 'pointer' }}
onClick={() => showDialog(record.name)}
/>
)
},
render: (text: any, record: any) => (
<Text link style={{ cursor: 'pointer' }} onClick={() => showDialog(record.name)}>
播放
</Text>
),
},
]
const [visible, setVisible] = useState(false)
const showDialog = (name: string) => {
setVisible(true)
setFileName(name)
// setTimeout(()=>{
// let player = new Player({
// id: 'mse',
// url: (process.env.NEXT_PUBLIC_API_SERVER ?? '') + '/static/' + name,
// height: '100%',
// // plugins: [FlvPlugin],
// plugins: [FlvJsPlugin],
// width: '100%',
// });
// }, 500)
}
const handleCancel = () => {
setVisible(false)
console.log('Cancel button clicked')
}
return (
<>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
style={{ border: 'none' }}
header={
<>
<div
style={{
backgroundColor: 'rgba(var(--semi-green-4), 1)',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
// justifyContent: 'center',
padding: '6px',
}}
>
<IconVideoListStroked size="large" />
</div>
<h4 style={{ marginLeft: '12px' }}>历史记录</h4>
</>
}
mode="horizontal"
></Nav>
</Header>
<Content
style={{
paddingLeft: 12,
paddingRight: 12,
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<main>
<Table size="small" columns={columns} dataSource={data} />
</main>
<PageHeader
icon={<IconVideoListStroked size="large" />}
title="历史记录"
description="已录制的视频文件,可在线回放"
/>
<div className={dc.content}>
<div className={dc.card}>
<Table
size="small"
scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
loading={isLoading}
/>
</div>
<Modal
visible={visible}
onCancel={handleCancel}
onCancel={() => setVisible(false)}
closeOnEsc={true}
style={{ width: 'min(600px, 90vw)' }}
size="large"
bodyStyle={{ height: 500 }}
footer={null}
>
<Players
url={(process.env.NEXT_PUBLIC_API_SERVER ?? '') + '/static/' + fileName}
></Players>
<div id="mse"></div>
<Players url={(process.env.NEXT_PUBLIC_API_SERVER ?? '') + '/static/' + fileName} />
<div id="mse" />
</Modal>
</Content>
</div>
</>
)
}
-1
View File
@@ -2,7 +2,6 @@ import { AutoComplete } from '@douyinfe/semi-ui'
import { IconSearch } from '@douyinfe/semi-icons'
const Filter = (props: any) => {
console.log('renderFilterDropdown', props)
const { tempFilteredValue, setTempFilteredValue, confirm, clear, close } = props
const handleChange = (value: any) => {
+59 -73
View File
@@ -1,30 +1,35 @@
'use client'
import { AutoComplete, Layout, Nav, Spin, Table, Typography } from '@douyinfe/semi-ui'
import { Spin, Table, Typography } from '@douyinfe/semi-ui'
import { SortOrder } from '@douyinfe/semi-ui/lib/es/table'
import useSWR from 'swr'
import { fetcher, FileList } from '@/app/lib/api-streamer'
import {
JSXElementConstructor,
Key,
ReactElement,
ReactNode,
ReactPortal,
useRef,
useState,
} from 'react'
import { fetcher } from '@/app/lib/api-streamer'
import { IconHistory } from '@douyinfe/semi-icons'
import { humDate } from '@/app/lib/utils'
import Filter from "@/app/(app)/job/Filter";
import Filter from '@/app/(app)/job/Filter'
import { useIsMobile } from '../../lib/useIsMobile'
import PageHeader from '../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
export default function Home() {
const { Header, Footer, Sider, Content } = Layout
export default function Job() {
const isMobile = useIsMobile()
const { Text } = Typography
const { data: data, error, isLoading } = useSWR<any[]>('/v1/streamer-info', fetcher)
if (isLoading) {
return <Spin size="large" />
return (
<>
<PageHeader
icon={<IconHistory size="large" />}
title="直播历史"
description="按主播查看历史直播记录"
/>
<div style={{ padding: '80px 0', textAlign: 'center' }}>
<Spin size="large" />
</div>
</>
)
}
const { Text } = Typography
const columns = [
{
title: '名称',
@@ -35,20 +40,26 @@ export default function Home() {
{
title: '标题',
dataIndex: 'title',
render: (text: any, record: any, index: any) => {
return <Text strong>{text}</Text>
},
render: (text: any) => (
<Text strong style={{ whiteSpace: 'nowrap' }}>
{text}
</Text>
),
onFilter: (value: any, record: any) => record.title.includes(value),
renderFilterDropdown: Filter,
},
{
title: '链接',
dataIndex: 'url',
},
{
title: '封面',
dataIndex: 'live_cover_path',
},
...(isMobile
? []
: [
{
title: '链接',
dataIndex: 'url',
},
{
title: '封面',
dataIndex: 'live_cover_path',
},
]),
{
title: '更新日期',
dataIndex: 'date',
@@ -60,71 +71,46 @@ export default function Home() {
return (
<>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
style={{ border: 'none' }}
header={
<>
<div
style={{
backgroundColor: 'rgb(250 102 76)',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '6px',
}}
>
<IconHistory size="large" />
</div>
<h4 style={{ marginLeft: '12px' }}>直播历史</h4>
</>
}
mode="horizontal"
></Nav>
</Header>
<Content
style={{
paddingLeft: 12,
paddingRight: 12,
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<main>
<PageHeader
icon={<IconHistory size="large" />}
title="直播历史"
description="按主播查看历史直播记录"
/>
<div className={dc.content}>
<div className={dc.card}>
<Table
size="small"
rowKey="id"
scroll={{ x: 'max-content' }}
columns={columns}
dataSource={data}
expandedRowRender={expandRowRender}
/>
</main>
</Content>
</div>
</div>
</>
)
}
// 创建一个子组件
// 展开子行:该次直播的录制文件列表
const FileLists = ({ recordId }: { recordId: string }) => {
const { data: files, isLoading } = useSWR(
`/v1/streamer-info/files/${recordId}`,
fetcher
)
const { data: files, isLoading } = useSWR(`/v1/streamer-info/files/${recordId}`, fetcher)
if (isLoading) return <div>加载中...</div>
if (!files || files.length === 0) return <div>暂无文件</div>
return (
<>
文件列表:
{files.map((it: any) => (
<div key={it.id}>&nbsp;&nbsp;文件名:{it.file}</div>
))}
</>
<div style={{ padding: '4px 8px', fontSize: 13, color: 'var(--semi-color-text-1)' }}>
文件列表:
{files.map((it: any) => (
<div key={it.id} style={{ padding: '2px 0 2px 24px', fontVariantNumeric: 'tabular-nums' }}>
{it.file}
</div>
))}
</div>
)
}
// 在 expandRowRender 中使用
const expandRowRender = (record: any) => {
return <FileLists recordId={record.id} />
}
+339
View File
@@ -0,0 +1,339 @@
/* ===== 应用骨架:自绘侧边栏 ===== */
.app {
display: flex;
min-height: 100vh;
background: var(--semi-color-bg-0);
}
/* 自定义背景开启时:自绘布局透出 body 背景(替代原 bg-global.css 的 .semi-* 规则) */
:global(body.app-has-bg) .app {
background: transparent;
}
:global(body.app-has-bg) .sider {
/* 侧栏也透出背景(文字可读由遮罩层保证) */
background: color-mix(in srgb, var(--semi-color-bg-1) 55%, transparent);
}
/* ===== 侧边栏 ===== */
.sider {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
background: var(--semi-color-bg-1);
border-right: 1px solid var(--semi-color-border);
transition: width 0.18s ease, transform 0.2s ease;
z-index: 30;
overflow: hidden;
}
.sider.collapsed {
width: 60px;
}
/* ===== 品牌区 ===== */
.brand {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 15px 16px;
color: var(--semi-color-text-0);
flex-shrink: 0;
}
.brand a {
text-decoration: none;
color: inherit;
}
.brand img {
border-radius: 8px;
flex-shrink: 0;
}
.brandText {
display: flex;
flex-direction: column;
line-height: 1.25;
min-width: 0;
}
.brandName {
font-size: 15.5px;
font-weight: 700;
letter-spacing: 0.2px;
}
/* 版本号 = 更新日志入口(点击进入 /changelog) */
.brandVer {
font-size: 11px;
color: var(--semi-color-text-2);
font-variant-numeric: tabular-nums;
transition: color 0.12s ease;
}
.brandVer:hover {
color: var(--semi-color-primary);
text-decoration: underline !important;
}
/* ===== 导航 ===== */
.nav {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 4px 8px 12px;
}
.group {
margin-top: 14px;
}
.group:first-child {
margin-top: 4px;
}
.groupTitle {
font-size: 11px;
font-weight: 600;
color: var(--semi-color-text-2);
letter-spacing: 0.8px;
text-transform: uppercase;
padding: 0 10px;
margin-bottom: 4px;
white-space: nowrap;
}
.item {
position: relative;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
margin: 2px 0;
border-radius: 8px;
color: var(--semi-color-text-1);
font-size: 13.5px;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: background 0.12s ease, color 0.12s ease;
}
.item:hover {
background: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
}
.itemIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--semi-color-text-2);
transition: color 0.12s ease;
}
.itemIcon svg {
width: 17px;
height: 17px;
}
.item:hover .itemIcon {
color: var(--semi-color-text-1);
}
/* 选中态:primary 底色 + 左侧指示条 */
.itemActive,
.itemActive:hover {
background: rgba(var(--semi-primary-5), 0.1);
color: var(--semi-color-primary);
font-weight: 600;
}
.itemActive .itemIcon {
color: var(--semi-color-primary);
}
.itemActive::before {
content: '';
position: absolute;
left: -8px;
top: 7px;
bottom: 7px;
width: 3px;
border-radius: 0 3px 3px 0;
background: var(--semi-color-primary);
}
/* 折叠态:只留图标 */
.sider.collapsed .item {
justify-content: center;
padding: 9px 0;
}
.sider.collapsed .group {
margin-top: 10px;
}
/* ===== 底部 ===== */
.foot {
flex-shrink: 0;
padding: 10px 12px;
border-top: 1px solid var(--semi-color-border);
display: flex;
flex-direction: column;
gap: 8px;
}
.statusRow {
display: flex;
align-items: center;
gap: 7px;
padding: 2px 4px;
}
.statusDot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.statusDot.online {
background: rgb(var(--semi-green-5));
animation: pulse 1.6s infinite;
}
.statusDot.offline {
background: rgb(var(--semi-red-5));
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--semi-green-4), 0.5);
}
70% {
box-shadow: 0 0 0 5px rgba(var(--semi-green-4), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--semi-green-4), 0);
}
}
.statusText {
font-size: 12px;
color: var(--semi-color-text-2);
white-space: nowrap;
}
.footBtns {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.footBtn {
width: 30px;
height: 30px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--semi-color-text-2);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.footBtn:hover {
background: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
}
.footBtn svg {
width: 16px;
height: 16px;
}
/* ===== 主内容区 ===== */
.main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* ===== 移动端抽屉 ===== */
.siderMobile {
position: fixed;
left: 0;
top: 0;
height: 100dvh;
transform: translateX(-102%);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
/* 必须高于遮罩(overlay z-index 999),否则抽屉内的导航项会被遮罩拦截无法点击 */
z-index: 1000;
}
.siderMobile.open {
transform: translateX(0);
}
.burger {
position: fixed;
top: 12px;
left: 12px;
z-index: 1001;
width: 36px;
height: 36px;
border: 1px solid var(--semi-color-border);
border-radius: 8px;
background: var(--semi-color-bg-0);
box-shadow: var(--semi-shadow-elevated);
color: var(--semi-color-text-1);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.burger svg {
width: 18px;
height: 18px;
}
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
z-index: 999;
}
/* ===== 无障碍 ===== */
.item:focus-visible,
.footBtn:focus-visible,
.burger:focus-visible,
.brand:focus-visible {
outline: 2px solid var(--semi-color-primary);
outline-offset: 2px;
}
/* 减少动态偏好 */
@media (prefers-reduced-motion: reduce) {
.sider {
transition: none;
}
.statusDot {
animation: none;
}
}
+247 -312
View File
@@ -1,329 +1,264 @@
'use client'
import styles from './page.module.css'
import { useCallback, useMemo, useState, useEffect } from 'react'
import './bg-global.css'
import { useGlobalBackgroundInit } from '../lib/useGlobalBackground'
import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Button, Nav } from '@douyinfe/semi-ui'
import { OnSelectedData } from '@douyinfe/semi-ui/lib/es/navigation'
import { Layout as SeLayout } from '@douyinfe/semi-ui/lib/es/layout'
import {
IconCloudStroked,
IconCustomerSupport,
IconDoubleChevronLeft,
IconDoubleChevronRight,
IconStar,
IconVideoListStroked,
IconHome,
IconSetting,
IconHistory,
IconUserCardVideo,
} from '@douyinfe/semi-icons'
import Image from 'next/image'
import useSWR from 'swr'
import { fetcher } from '../lib/api-streamer'
import ThemeButton from '../ui/ThemeButton'
import { useSystemTheme, useTheme } from '../lib/utils'
import { useWindowSize } from 'react-use';
import { formatVersion } from '../lib/status'
import { SLOW_REFRESH_MS } from '../lib/use-dashboard'
import { useIsMobile } from '../lib/useIsMobile'
import styles from './layout.module.scss'
/* ============ 导航信息架构:5 组 9 项 ============ */
function Ic({ d, extra }: { d: string; extra?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d={d} />
{extra ? <path d={extra} /> : null}
</svg>
)
}
const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: ReactNode }[] }[] = [
{
title: '总览',
items: [
{
href: '/',
label: '控制台',
icon: <Ic d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" />,
},
],
},
{
title: '录制',
items: [
{
href: '/streamers',
label: '直播管理',
icon: <Ic d="M3 6h13v12H3zM16 9.5l5-2.5v10l-5-2.5" />,
},
{
href: '/history',
label: '历史记录',
icon: <Ic d="M4 6h16M4 12h16M4 18h10" extra="M18 15v4m0 0l-2-2m2 2l2-2" />,
},
{
href: '/job',
label: '直播历史',
icon: <Ic d="M12 7v5l3 2" extra="M12 21a9 9 0 110-18 9 9 0 010 18z" />,
},
],
},
{
title: '投稿',
items: [
{
href: '/upload-manager',
label: '投稿管理',
icon: <Ic d="M12 16V4m0 0L8 8m4-4l4 4" extra="M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />,
},
{
href: '/archives',
label: 'B站稿件',
icon: <Ic d="M4 5h16v14H4z" extra="M8 15c0-2 1.5-3 4-3s4 1 4 3M12 8a2 2 0 100 4 2 2 0 000-4z" />,
},
],
},
{
title: '设置',
items: [
{
href: '/dashboard',
label: '空间配置',
icon: <Ic d="M12 15a3 3 0 100-6 3 3 0 000 6z" extra="M19.4 15a1.7 1.7 0 00.3 1.9l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.7 1.7 0 00-1.9-.3 1.7 1.7 0 00-1 1.5V21a2 2 0 11-4 0v-.2a1.7 1.7 0 00-1-1.5 1.7 1.7 0 00-1.9.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.7 1.7 0 00.3-1.9 1.7 1.7 0 00-1.5-1H3a2 2 0 110-4h.2a1.7 1.7 0 001.5-1 1.7 1.7 0 00-.3-1.9l-.1-.1a2 2 0 112.8-2.8l.1-.1a1.7 1.7 0 001.9.3h0a1.7 1.7 0 001-1.5V3a2 2 0 114 0v.2a1.7 1.7 0 001 1.5h0a1.7 1.7 0 001.9-.3l.1-.1a2 2 0 112.8 2.8l-.1-.1a1.7 1.7 0 00-.3 1.9v0a1.7 1.7 0 001.5 1H21a2 2 0 110 4h-.2a1.7 1.7 0 00-1.5 1z" />,
},
],
},
{
title: '系统',
items: [
{
href: '/logviewer',
label: '实时日志',
icon: <Ic d="M4 5h16M4 12h16M4 19h10" extra="M18 15l3 3-3 3" />,
},
{
href: '/status',
label: '任务平台',
icon: <Ic d="M4 4h16v16H4z" extra="M4 9h16M9 4v5" />,
},
],
},
]
/* 服务状态:布局级轻量轮询,所有页面共享 */
const SIDER_KEY = 'biliup_sider_collapsed'
export default function Layout({ children }: { children: React.ReactNode }) {
const { Sider } = SeLayout
const pathname = usePathname()
let initOpenKeys: any = []
if (pathname.slice(1) === 'streamers' || pathname.slice(1) === 'history') {
initOpenKeys = ['manager']
const pathname = usePathname()
const isMobile = useIsMobile()
const [collapsed, setCollapsed] = useState(false)
const [mobileNavOpen, setMobileNavOpen] = useState(false)
const [mode, setMode] = useState<any>('auto')
useGlobalBackgroundInit()
// 折叠偏好持久化
useEffect(() => {
try {
setCollapsed(localStorage.getItem(SIDER_KEY) === '1')
} catch {
/* ignore */
}
}, [])
const toggleCollapsed = () => {
setCollapsed((c) => {
try {
localStorage.setItem(SIDER_KEY, c ? '0' : '1')
} catch {
/* ignore */
}
return !c
})
}
const [openKeys, setOpenKeys] = useState(initOpenKeys)
const [selectedKeys, setSelectedKeys] = useState<any>([pathname.slice(1)])
// 主题
useEffect(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('mode') : null
if (saved) setMode(saved)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const systemTheme = useSystemTheme()
useTheme(mode, systemTheme)
const { width } = useWindowSize()
const [isCollapsed, setIsCollapsed] = useState(width <= 640)
const [mode, setMode] = useState(
(typeof window !== 'undefined' && localStorage.getItem('mode')) || 'auto'
)
const systemTheme = useSystemTheme()
useTheme(mode, systemTheme)
let navStyle = isCollapsed ? { height: '100%', overflow: 'visible' } : { height: '100%' }
// 服务状态指示(离线时变红,不影响页面)
const { data: status, error: statusError } = useSWR('/v1/status', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
revalidateOnFocus: false,
})
const online = status !== undefined && !statusError
const version = (status as { version?: string } | undefined)?.version
const versionText = formatVersion(version)
// 兼容 PC 切移动端
useEffect(() => {
if (width <= 640) {
setIsCollapsed(true)
}
}, [width]);
const navCollapsed = !isMobile && collapsed
const items = useMemo(
() =>
[
{
itemKey: 'home',
text: '主页',
icon: (
<div
style={{
backgroundColor: '#ffaa00ff',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
// justifyContent: 'center',
padding: '4px',
}}
>
<IconHome size="small" />
</div>
),
},
{
itemKey: 'manager',
text: '录播管理',
items: [
{ itemKey: 'streamers', text: '直播管理' },
{ itemKey: 'history', text: '历史记录' },
],
icon: (
<div
style={{
backgroundColor: '#5ac262ff',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
// justifyContent: 'center',
padding: '4px',
}}
>
<IconVideoListStroked size="small" />
</div>
),
},
{
itemKey: 'upload-manager',
text: '投稿管理',
icon: (
<div
style={{
backgroundColor: '#885bd2ff',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconCloudStroked size="small" />
</div>
),
},
{
itemKey: 'archives',
text: 'B站稿件',
icon: (
<div
style={{
backgroundColor: '#00a1d6',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconUserCardVideo size="small" />
</div>
),
},
{
itemKey: 'dashboard',
text: '空间配置',
icon: (
<div
style={{
backgroundColor: '#6b6c75ff',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconStar size="small" />
</div>
),
},
{
itemKey: 'job',
text: '直播历史',
icon: (
<div
style={{
backgroundColor: 'rgb(250 102 76)',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconHistory size="small" />
</div>
),
},
{
text: '实时日志',
icon: (
<div
style={{
backgroundColor: 'rgba(var(--semi-blue-4), 1)',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconCustomerSupport size="small" />
</div>
),
itemKey: 'logViewer',
},
{
text: '任务平台',
icon: (
<div
style={{
backgroundColor: 'rgba(var(--semi-lime-2), 1)',
borderRadius: 'var(--semi-border-radius-medium)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '4px',
}}
>
<IconSetting size="small" />
</div>
),
itemKey: 'status',
// items: [{itemKey: 'About', text: '任务管理'}, {itemKey: 'Dashboard', text: '用户任务查询'}],
},
].map((value: any) => {
value.text = (
<div
style={{
color:
selectedKeys.some((key: string) => value.itemKey === key) ||
(selectedKeys.some((key: string) =>
openKeys.some((o: string | number) => isSub(key, o))
) &&
openKeys.some((key: any) => value.itemKey === key))
? 'var(--semi-color-text-0)'
: 'var(--semi-color-text-2)',
fontWeight: 600,
}}
>
{value.text}
</div>
const isActive = (href: string) =>
href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/')
return (
<div className={styles.app}>
{/* 移动端:汉堡按钮 + 遮罩(抽屉打开时隐藏汉堡,点击其位置即点遮罩关闭) */}
{isMobile && !mobileNavOpen && (
<button
className={styles.burger}
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
</button>
)}
{isMobile && mobileNavOpen && (
<div className={styles.overlay} onClick={() => setMobileNavOpen(false)} />
)}
<aside
className={`${styles.sider} ${navCollapsed ? styles.collapsed : ''} ${
isMobile ? styles.siderMobile : ''
} ${mobileNavOpen ? styles.open : ''}`}
>
{/* 品牌区 */}
<div className={styles.brand}>
<Link
href="/"
onClick={() => isMobile && setMobileNavOpen(false)}
aria-label="回到控制台"
>
<Image
src="/logo.png"
alt="biliup"
width={30}
height={30}
style={{ width: 30, height: 30, objectFit: 'contain' }}
unoptimized
/>
</Link>
{!navCollapsed && (
<span className={styles.brandText}>
<Link
href="/"
className={styles.brandName}
onClick={() => isMobile && setMobileNavOpen(false)}
>
biliup
</Link>
<Link href="/changelog" className={styles.brandVer} title="更新日志">
v{versionText ?? '—'}
</Link>
</span>
)}
</div>
{/* 导航 */}
<nav className={styles.nav}>
{NAV_GROUPS.map((group) => (
<div key={group.title} className={styles.group}>
{!navCollapsed && <div className={styles.groupTitle}>{group.title}</div>}
{group.items.map((item) => {
const active = isActive(item.href)
return (
<Link
key={item.href}
href={item.href}
className={`${styles.item} ${active ? styles.itemActive : ''}`}
title={navCollapsed ? item.label : undefined}
onClick={() => isMobile && setMobileNavOpen(false)}
>
<span className={styles.itemIcon}>{item.icon}</span>
{!navCollapsed && <span className={styles.itemText}>{item.label}</span>}
</Link>
)
return value
}),
[openKeys, selectedKeys]
)
const renderWrapper = useCallback(({ itemElement, isSubNav, isInSubNav, props }: any) => {
const routerMap: Record<string, string> = {
home: '/',
history: '/history',
dashboard: '/dashboard',
streamers: '/streamers',
'upload-manager': '/upload-manager',
archives: '/archives',
job: '/job',
status: '/status',
logViewer: '/logviewer',
}
if (!routerMap[props.itemKey]) {
return itemElement
}
return (
<Link
style={{
textDecoration: 'none',
fontWeight: '600 !important',
}}
href={routerMap[props.itemKey]}
>
{itemElement}
</Link>
)
// return itemElement;
}, [])
})}
</div>
))}
</nav>
const onSelect = (data: OnSelectedData) => {
setSelectedKeys([...data.selectedKeys])
}
const onOpenChange = (data: any) => {
setOpenKeys([...data.openKeys])
}
const onCollapseChange = useCallback(() => {
setIsCollapsed(!isCollapsed)
}, [isCollapsed])
return (
<html lang="zh-Hans">
<body style={{ width: '100%' }}>
<SeLayout className="components-layout-demo semi-light-scrollbar">
<Sider>
<Nav
style={navStyle}
// toggleIconPosition={'left'}
// defaultOpenKeys={['job']}
openKeys={openKeys}
selectedKeys={selectedKeys}
isCollapsed={isCollapsed}
// bodyStyle={{height: '100%'}}
renderWrapper={renderWrapper}
items={items}
// onCollapseChange={onCollapseChange}
onOpenChange={onOpenChange}
onSelect={onSelect}
// header={{
// logo: <IconSemiLogo style={{height: '36px', fontSize: 36}}/>,
// text: 'BILIUP'
// }}
// footer={{
// collapseButton: true,
// }}
>
<Nav.Header
logo={
// <IconSemiLogo
// style={{ height: "36px", fontSize: 36 }}
// />
<Image src="/logo.png" alt="{}" height={10} width={20}></Image>
}
style={isCollapsed ? { flexDirection: 'column', paddingLeft: 0, paddingRight: 0, paddingBottom: 0, gap: '8px' } : { justifyContent: 'flex-start' }}
text="BILIUP"
>
<div
style={{
flexGrow: 1,
display: width <= 640 ? 'none' : 'flex',
flexDirection: 'row-reverse',
zIndex: 2,
}}
>
<Button
onClick={onCollapseChange}
type="tertiary"
className={styles.shadow}
theme="borderless"
icon={isCollapsed ? <IconDoubleChevronRight /> : <IconDoubleChevronLeft />}
/>
</div>
</Nav.Header>
<Nav.Footer collapseButton={false}>
<ThemeButton mode={mode} setMode={setMode} systemTheme={systemTheme} />
</Nav.Footer>
</Nav>
</Sider>
<SeLayout style={{ height: '100vh' }}>{children}</SeLayout>
</SeLayout>
</body>
</html>
)
}
{/* 底部:服务状态 + 工具 */}
<div className={styles.foot}>
{!navCollapsed && (
<div className={styles.statusRow}>
<span className={`${styles.statusDot} ${online ? styles.online : styles.offline}`} />
<span className={styles.statusText}>{online ? '服务运行中' : '服务未连接'}</span>
</div>
)}
<div className={styles.footBtns}>
<ThemeButton mode={mode} setMode={setMode} systemTheme={systemTheme} />
{!isMobile && (
<button
className={styles.footBtn}
onClick={toggleCollapsed}
aria-label={navCollapsed ? '展开侧栏' : '收起侧栏'}
title={navCollapsed ? '展开侧栏' : '收起侧栏'}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
{navCollapsed ? <path d="M9 6l6 6-6 6" /> : <path d="M15 6l-6 6 6 6" />}
</svg>
</button>
)}
</div>
</div>
</aside>
function isSub(key1: string, key2: string | number) {
const routerMap: any = {
manager: ['streamers', 'history'],
}
return routerMap[key2]?.includes(key1)
<main className={styles.main}>{children}</main>
</div>
)
}
+72 -152
View File
@@ -1,49 +1,41 @@
'use client'
import { useEffect, useState, useRef } from 'react'
import { Layout, Nav, Spin, Typography, Select, Card, Button, Toast, Tabs, TabPane } from '@douyinfe/semi-ui'
import {
IconCustomerSupport,
IconRefresh,
IconClear,
IconSave,
} from '@douyinfe/semi-icons'
import { useIsMobile } from '../../lib/useIsMobile'
import { Button, Spin, Typography, Tabs, TabPane, Toast } from '@douyinfe/semi-ui'
import { IconCustomerSupport, IconRefresh, IconClear, IconSave } from '@douyinfe/semi-icons'
import PageHeader from '../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
// 日志内容组件
interface LogContentProps {
logs: string[];
logContainerRef: React.RefObject<HTMLDivElement>;
isLoading: boolean;
logs: string[]
logContainerRef: React.RefObject<HTMLDivElement>
isLoading: boolean
}
const LogContent = ({ logs, logContainerRef, isLoading }: LogContentProps) => {
// 判断滚动条是否接近底部
const isScrolledToBottom = () => {
const containers = document.getElementsByClassName('log-container');
if (containers.length === 0) return false;
const containers = document.getElementsByClassName('log-container')
if (containers.length === 0) return false
const container = containers[0] as HTMLElement
const diff = container.scrollHeight - container.scrollTop
return diff - container.clientHeight <= 50
}
const container = containers[0] as HTMLElement;
// 50px 容许
const diff = container.scrollHeight - container.scrollTop;
return diff - container.clientHeight <= 50;
};
// 滚动到底部
const scrollToBottom = () => {
const containers = document.getElementsByClassName('log-container');
const containers = document.getElementsByClassName('log-container')
if (containers.length > 0) {
const container = containers[0] as HTMLElement;
container.scrollTop = container.scrollHeight;
const container = containers[0] as HTMLElement
container.scrollTop = container.scrollHeight
}
};
}
useEffect(() => {
// 如果已经滚动到底部,那么在日志更新时自动滚动到底部
if (logs.length > 0 && isScrolledToBottom()) {
console.log("自动滚动到底部")
scrollToBottom();
scrollToBottom()
}
}, [logs]);
}, [logs])
if (isLoading) {
return (
@@ -58,22 +50,27 @@ const LogContent = ({ logs, logContainerRef, isLoading }: LogContentProps) => {
className="log-container"
ref={logContainerRef}
style={{
height: 'calc(100% - 40px)', // 减去 tabs 的高度
maxHeight: 'calc(100vh - 180px)', // 设置最大高度
height: 'calc(100vh - 220px)',
minHeight: 320,
overflow: 'auto',
padding: 12,
backgroundColor: 'var(--semi-color-bg-1)',
borderRadius: 4,
padding: 14,
backgroundColor: 'var(--semi-color-fill-0)',
borderRadius: 8,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all'
wordBreak: 'break-all',
fontFamily: 'ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace',
fontSize: 12.5,
lineHeight: 1.7,
}}
>
{logs.length > 0 ? (
logs.map((log, index) => (
<div key={index} style={{ marginBottom: 2 }}>{log}</div>
<div key={index} style={{ marginBottom: 1, color: 'var(--semi-color-text-1)' }}>
{log}
</div>
))
) : (
<div style={{ color: 'var(--semi-color-text-2)', textAlign: 'center', marginTop: 20 }}>
<div style={{ color: 'var(--semi-color-text-2)', textAlign: 'center', marginTop: 24 }}>
暂无日志内容
</div>
)}
@@ -82,30 +79,28 @@ const LogContent = ({ logs, logContainerRef, isLoading }: LogContentProps) => {
}
export default function LogViewer() {
const { Header, Content } = Layout
const [logs, setLogs] = useState<string[]>([])
const [isConnected, setIsConnected] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('ds_update')
const wsRef = useRef<WebSocket | null>(null)
const logContainerRef = useRef<HTMLDivElement>(null)
const isMobile = useIsMobile()
const connectWebSocket = () => {
setIsLoading(true)
setLogs([])
// 关闭现有连接
if (wsRef.current) {
wsRef.current.close()
}
// 创建新的WebSocket连接
const isDev = process.env.NODE_ENV === 'development';
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const isDev = process.env.NODE_ENV === 'development'
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const server = isDev
? process.env.NEXT_PUBLIC_API_SERVER?.replace(/^http/, 'ws') // 使用环境变量中配置的API服务器地址
: `${protocol}//${window.location.host}`;
const wsUrl = `${server}/v1/ws/logs?file=${activeTab}.log`;
? process.env.NEXT_PUBLIC_API_SERVER?.replace(/^http/, 'ws')
: `${protocol}//${window.location.host}`
const wsUrl = `${server}/v1/ws/logs?file=${activeTab}.log`
const ws = new WebSocket(wsUrl)
wsRef.current = ws
@@ -117,148 +112,73 @@ export default function LogViewer() {
}
ws.onmessage = (event) => {
setLogs(prev => [...prev, event.data])
setLogs((prev) => [...prev, event.data])
}
ws.onerror = (error) => {
console.error('WebSocket错误:', error)
// 检查是否是连接建立前WebSocket已关闭的错误
// 这种情况通常发生在组件卸载或用户切换标签时
ws.onerror = () => {
if (ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
console.log('WebSocket在连接建立前已关闭')
// 连接建立前已关闭,忽略
} else {
// 其他错误仍然显示Toast提示
Toast.error('连接错误,请重试')
}
setIsLoading(false)
}
ws.onclose = () => {
setIsConnected(false)
console.log('WebSocket连接已关闭')
}
}
useEffect(() => {
connectWebSocket()
return () => {
// 组件卸载时关闭WebSocket连接
if (wsRef.current) {
console.log('主动关闭WebSocket连接')
wsRef.current.close()
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab])
const handleFileChange = (value: string) => {
setActiveTab(value)
}
const handleRefresh = () => connectWebSocket()
const handleClear = () => setLogs([])
const handleRefresh = () => {
connectWebSocket()
}
const handleClear = () => {
setLogs([])
}
const actions = (
<>
<Button icon={<IconSave />} onClick={() => (window.location.href = `/static/${activeTab}.log`)} type="primary" theme="solid" size="small">
{isMobile ? null : '下载'}
</Button>
<Button icon={<IconRefresh />} onClick={handleRefresh} theme="light" size="small">
{isMobile ? null : '刷新'}
</Button>
<Button icon={<IconClear />} onClick={handleClear} theme="light" size="small">
{isMobile ? null : '清空'}
</Button>
<Typography.Text type={isConnected ? 'success' : 'danger'} style={{ marginLeft: 6 }}>
{isConnected ? '● 已连接' : '○ 未连接'}
</Typography.Text>
</>
)
return (
<>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
style={{ border: 'none' }}
header={
<>
<div
style={{
backgroundColor: 'rgba(var(--semi-blue-4), 1)',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '6px',
}}
>
<IconCustomerSupport size="large" />
</div>
<h4 style={{ marginLeft: '12px' }}>实时日志</h4>
</>
}
mode="horizontal"
></Nav>
</Header>
<Content
style={{
padding: 12,
backgroundColor: 'var(--semi-color-bg-0)',
height: 'calc(100vh - 60px)',
display: 'flex',
flexDirection: 'column',
}}
>
<Card
style={{ flex: 1, overflow: 'hidden' }}
bodyStyle={{ height: '100%', overflow: 'hidden', boxSizing: 'border-box' }}
>
<Tabs
type="line"
style={{
marginTop: -20,
marginBottom: -20
}}
activeKey={activeTab}
onChange={handleFileChange}
tabBarExtraContent={
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<Button
icon={<IconSave />}
onClick={() => (window.location.href = `/static/${activeTab}.log`)}
type="primary"
theme="solid"
size="small"
>
下载
</Button>
<Button
icon={<IconRefresh />}
onClick={handleRefresh}
theme="light"
size="small"
>
刷新
</Button>
<Button
icon={<IconClear />}
onClick={handleClear}
theme="light"
size="small"
>
清空
</Button>
<Typography.Text
type={isConnected ? 'success' : 'danger'}
style={{ marginLeft: 8, display: 'flex', alignItems: 'center' }}
>
{isConnected ? '已连接' : '未连接'}
</Typography.Text>
</div>
}
>
<PageHeader
icon={<IconCustomerSupport size="large" />}
title="实时日志"
description="主程序运行与下载上传日志,WebSocket 实时推送"
actions={actions}
/>
<div className={dc.content}>
<div className={dc.card} style={{ padding: 16 }}>
<Tabs type="line" activeKey={activeTab} onChange={setActiveTab}>
<TabPane tab="主程序运行日志" itemKey="ds_update">
<LogContent logs={logs} logContainerRef={logContainerRef} isLoading={isLoading} />
</TabPane>
<TabPane tab="biliup下载和上传日志" itemKey="download">
<LogContent logs={logs} logContainerRef={logContainerRef} isLoading={isLoading} />
</TabPane>
{/*<TabPane tab="biliup-rs上传日志" itemKey="upload">*/}
{/* <LogContent logs={logs} logContainerRef={logContainerRef} isLoading={isLoading} />*/}
{/*</TabPane>*/}
</Tabs>
</Card>
</Content>
</div>
</div>
</>
)
}
}
-9
View File
@@ -1,9 +0,0 @@
.shadow {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1) !important;
background-color: var(--semi-color-bg-0) !important;
/*margin-left: 11px;*/
}
body[theme-mode=dark] .shadow {
box-shadow: var(--semi-shadow-elevated) !important;
}
+201
View File
@@ -0,0 +1,201 @@
.page {
padding: 24px 36px 48px;
width: 100%;
box-sizing: border-box;
}
/* ===== 顶栏 ===== */
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 22px;
gap: 16px;
flex-wrap: wrap;
}
.docLink {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 13px;
font-weight: 600;
color: var(--semi-color-text-1);
text-decoration: none;
padding: 6px 10px;
border-radius: 8px;
white-space: nowrap;
}
.docLink:hover {
background: var(--semi-color-fill-0);
color: var(--semi-color-text-0);
}
.version {
font-size: 12px;
color: var(--semi-color-text-2);
font-variant-numeric: tabular-nums;
}
.statusPill {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 7px 14px;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.statusPill.online {
background: rgba(var(--semi-green-4), 0.15);
color: rgb(var(--semi-green-5));
}
.statusPill.offline {
background: rgba(var(--semi-red-4), 0.15);
color: rgb(var(--semi-red-5));
}
.dot.online {
background: rgb(var(--semi-green-5));
box-shadow: 0 0 0 4px rgba(var(--semi-green-4), 0.18);
}
.dot.offline {
background: rgb(var(--semi-red-5));
box-shadow: 0 0 0 4px rgba(var(--semi-red-4), 0.18);
}
/* ===== 概览条 ===== */
.overview {
display: flex;
flex-wrap: wrap;
gap: 22px;
align-items: center;
margin-bottom: 26px;
padding: 14px 20px;
background: var(--semi-color-bg-1);
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
}
.ovItem {
font-size: 13px;
color: var(--semi-color-text-2);
display: inline-flex;
align-items: center;
gap: 6px;
}
.ovItem b {
color: var(--semi-color-text-0);
font-size: 15px;
font-weight: 650;
font-variant-numeric: tabular-nums;
}
.ovDot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.ovDot.rec {
background: rgb(var(--semi-green-5));
animation: pulse 1.4s infinite;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--semi-green-4), 0.5);
}
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);
}
}
/* ===== 区块 ===== */
.section {
margin-bottom: 28px;
}
.secHead {
display: flex;
align-items: baseline;
justify-content: space-between;
margin: 0 0 12px;
}
.secLabel {
font-size: 13px;
font-weight: 700;
color: var(--semi-color-text-2);
text-transform: uppercase;
letter-spacing: 0.6px;
}
.secCount {
font-weight: 600;
margin-left: 6px;
color: var(--semi-color-text-1);
}
.secLink {
font-size: 13px;
font-weight: 600;
color: rgb(var(--semi-blue-4));
text-decoration: none;
}
.secLink:hover {
text-decoration: underline;
}
.secNote {
font-size: 12px;
color: var(--semi-color-text-2);
}
/* ===== 卡片网格(卡片本体样式在 ui/streamer-card.module.scss) ===== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(262px, 1fr));
gap: 12px;
align-items: start;
}
.timelineCard {
background: var(--semi-color-bg-1);
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
padding: 18px 20px;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
}
/* ===== 错误 / 警告 ===== */
.errorBox {
padding: 40px;
text-align: center;
}
.warnBox {
margin-bottom: 20px;
padding: 12px 16px;
border-radius: 10px;
background: rgba(var(--semi-orange-4), 0.12);
color: rgb(var(--semi-orange-5));
font-size: 13px;
border: 1px solid rgba(var(--semi-orange-4), 0.32);
}
/* ===== 响应式 ===== */
@media (max-width: 880px) {
.page {
padding: 20px 24px 36px;
}
}
@media (max-width: 520px) {
.page {
padding: 16px 16px 28px;
}
.grid {
grid-template-columns: 1fr;
}
.overview {
gap: 14px;
padding: 12px 16px;
}
}
+172 -10
View File
@@ -1,13 +1,175 @@
'use client'
import Link from 'next/link'
import { Typography, Space, Spin } from '@douyinfe/semi-ui'
import { IconExternalOpen } from '@douyinfe/semi-icons'
import {
useDashboard,
formatSize,
} from '@/app/lib/use-dashboard'
import { formatVersion } from '@/app/lib/status'
import StreamerCard from '../ui/StreamerCard'
import EventTimeline from '../ui/EventTimeline'
import BackgroundSetter from './components/BackgroundSetter'
import styles from './page.module.scss'
const Home: React.FC = () => (
<iframe
style={{
borderWidth: 0,
}}
height="100%"
src="https://biliup.github.io/biliup/docs/guide/changelog/"
></iframe>
)
const { Text } = Typography
export default Home
/**
* 控制台主页 —— Monitor 表面:2 秒内回答"几个在录、有没有问题"。
* 数据全部来自现有后端接口(见 use-dashboard),零后端改动。
*/
export default function Home() {
const d = useDashboard()
const streamers = d.streamers ?? []
const live = streamers.filter((s) => s.status === 'Working')
const offline = streamers.filter((s) => s.status !== 'Working')
const versionText = formatVersion(d.version)
return (
<div className={styles.page}>
{/* 顶栏:状态 + 文档 */}
<div className={styles.head}>
<Space>
<a
className={styles.docLink}
href="https://biliup.github.io/biliup/docs/guide/introduction/"
target="_blank"
rel="noreferrer"
>
<IconExternalOpen size="small" /> 文档
</a>
{versionText ? <span className={styles.version}>v{versionText}</span> : null}
<span
className={`${styles.statusPill} ${
d.connectError ? styles.offline : styles.online
}`}
>
<span
className={`${styles.dot} ${
d.connectError ? styles.offline : styles.online
}`}
/>
{d.connectError
? '服务未连接'
: d.loading
? '连接中…'
: `运行中 · ${d.recording} 路录制中`}
</span>
</Space>
</div>
<BackgroundSetter />
{d.loading ? (
<div style={{ padding: '80px 0', textAlign: 'center' }}>
<Spin size="large" />
</div>
) : d.connectError ? (
<div className={styles.errorBox}>
<Text>无法连接 biliup 后端,请确认服务正在运行且当前会话已登录。</Text>
</div>
) : (
<>
{d.streamersFailed && (
<div className={styles.warnBox}>
<Text>
主播列表加载失败,监控数量与卡片暂不可用,请检查后端连接或稍后重试。
</Text>
</div>
)}
{d.infosFailed && (
<div className={styles.warnBox}>
<Text>直播信息(标题 / 录制时长)加载失败,相关字段可能缺失。</Text>
</div>
)}
{d.videosFailed && (
<div className={styles.warnBox}>
<Text>录制文件列表加载失败,文件总量与文件事件暂不可用。</Text>
</div>
)}
{d.statusFailed && (
<div className={styles.warnBox}>
<Text>服务状态接口加载失败,版本号暂不可用。</Text>
</div>
)}
{/* 概览条:全部来自真实聚合(无虚构指标) */}
<div className={styles.overview}>
<span className={styles.ovItem}>
<b>{d.streamersFailed ? '—' : d.total}</b> 个监控
</span>
<span className={styles.ovItem}>
<span className={`${styles.ovDot} ${styles.rec}`} />
<b>{d.recording}</b> 路录制中
</span>
<span className={styles.ovItem}>
<b>{d.pending}</b> 待上传
</span>
<span className={styles.ovItem}>
<b>{d.uploading}</b> 上传中
</span>
<span className={styles.ovItem}>
录制文件 <b>{formatSize(d.totalSize)}</b>
</span>
<span className={styles.ovItem}>
今日新增 <b>{formatSize(d.todaySize)}</b>
</span>
</div>
{/* 进行中 */}
<section className={styles.section}>
<div className={styles.secHead}>
<span className={styles.secLabel}>
进行中<span className={styles.secCount}>{live.length}</span>
</span>
<Link href="/streamers" className={styles.secLink}>
直播管理 →
</Link>
</div>
{d.streamersFailed ? (
<Text type="tertiary">主播列表加载失败,无法显示卡片。</Text>
) : live.length === 0 ? (
<Text type="tertiary">当前没有正在录制的直播间。</Text>
) : (
<div className={styles.grid}>
{live.map((s) => (
<StreamerCard key={s.id} streamer={s} info={d.infoByUrl.get(s.url)} />
))}
</div>
)}
</section>
{/* 未开播 */}
<section className={styles.section}>
<div className={styles.secHead}>
<span className={styles.secLabel}>
未开播<span className={styles.secCount}>{offline.length}</span>
</span>
</div>
{offline.length === 0 ? (
<Text type="tertiary">全部直播间均在录制中。</Text>
) : (
<div className={styles.grid}>
{offline.map((s) => (
<StreamerCard key={s.id} streamer={s} info={d.infoByUrl.get(s.url)} />
))}
</div>
)}
</section>
{/* 最近事件:前端合成的 24h 活动流 */}
<section className={styles.section}>
<div className={styles.secHead}>
<span className={styles.secLabel}>最近事件</span>
<span className={styles.secNote}>最近 24h</span>
</div>
<div className={styles.timelineCard}>
<EventTimeline events={d.events} />
</div>
</section>
</>
)}
</div>
)
}
+64 -45
View File
@@ -1,58 +1,77 @@
'use client'
import { Layout, Nav, Spin, Typography } from '@douyinfe/semi-ui'
import { Spin, Typography } from '@douyinfe/semi-ui'
import useSWR from 'swr'
import { fetcher } from '@/app/lib/api-streamer'
import {
IconCustomerSupport,
IconSearch,
IconSetting,
IconVideoListStroked,
} from '@douyinfe/semi-icons'
import { IconSetting } from '@douyinfe/semi-icons'
import { JSONTree } from 'react-json-tree'
import PageHeader from '../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
export default function Home() {
const { Header, Footer, Sider, Content } = Layout
const { data: data, error, isLoading } = useSWR<any[]>('/v1/status', fetcher)
interface StatusPayload {
version?: string
rooms?: unknown[]
download_semaphore?: number
update_semaphore?: number
}
export default function Status() {
const { Text } = Typography
const { data, error, isLoading } = useSWR<StatusPayload>('/v1/status', fetcher)
if (isLoading) {
return <Spin size="large" />
return (
<>
<PageHeader
icon={<IconSetting size="large" />}
title="任务平台"
description="系统运行状态(调试视图)"
/>
<div style={{ padding: '80px 0', textAlign: 'center' }}>
<Spin size="large" />
</div>
</>
)
}
const rooms = data?.rooms?.length ?? 0
return (
<>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<Nav
style={{ border: 'none' }}
header={
<>
<div
style={{
backgroundColor: 'rgba(var(--semi-lime-2), 1)',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
display: 'flex',
padding: '6px',
}}
>
<IconSetting size="large" />
</div>
<h4 style={{ marginLeft: '12px' }}>任务平台</h4>
</>
}
mode="horizontal"
></Nav>
</Header>
<Content
style={{
paddingLeft: 12,
paddingRight: 12,
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<main>
<JSONTree data={data} />
</main>
</Content>
<PageHeader
icon={<IconSetting size="large" />}
title="任务平台"
description="系统运行状态(调试视图)"
/>
<div className={dc.content}>
{/* 概览 */}
<div className={dc.kpiStrip}>
<span className={dc.kpiItem}>
版本 <b>{data?.version ?? '—'}</b>
</span>
<span className={dc.kpiItem}>
监控房间 <b>{rooms}</b>
</span>
<span className={dc.kpiItem}>
下载信号量 <b>{data?.download_semaphore ?? '—'}</b>
</span>
<span className={dc.kpiItem}>
上传任务 <b>{data?.update_semaphore ?? '—'}</b>
</span>
{error ? (
<Text type="danger">状态获取失败,请检查后端连接</Text>
) : null}
</div>
{/* 原始状态(调试) */}
<div className={dc.card} style={{ padding: 16 }}>
<Text type="tertiary" size="small" style={{ display: 'block', marginBottom: 8 }}>
原始 JSON(调试用)
</Text>
<div style={{ overflow: 'auto', fontSize: 12.5 }}>
<JSONTree data={data} />
</div>
</div>
</div>
</>
)
}
+214
View File
@@ -0,0 +1,214 @@
.content {
padding: 24px;
min-height: calc(100vh - 69px);
}
.center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: calc(100vh - 140px);
}
/* ===== 工具栏 ===== */
.toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.search {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--semi-color-fill-2);
border-radius: 8px;
padding: 6px 12px;
background: var(--semi-color-bg-1);
width: 240px;
max-width: 100%;
color: var(--semi-color-text-2);
transition: border-color 0.15s ease;
}
.search:focus-within {
border-color: var(--semi-color-primary);
}
.search input {
border: none;
outline: none;
background: transparent;
color: var(--semi-color-text-0);
font-size: 13px;
width: 100%;
}
.filterChips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.fchip {
font-size: 12.5px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--semi-color-fill-2);
background: var(--semi-color-bg-1);
color: var(--semi-color-text-1);
transition: all 0.12s ease;
}
.fchip:hover {
border-color: var(--semi-color-primary);
color: var(--semi-color-primary);
}
.fchipActive {
background: rgba(var(--semi-primary-5), 0.1);
border-color: var(--semi-color-primary);
color: var(--semi-color-primary);
font-weight: 600;
}
.toolbarSpacer {
flex: 1;
}
.seg {
display: inline-flex;
border: 1px solid var(--semi-color-fill-2);
border-radius: 8px;
overflow: hidden;
}
.seg button {
border: none;
background: var(--semi-color-bg-1);
color: var(--semi-color-text-1);
padding: 5px 12px;
font-size: 12.5px;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.12s ease;
}
.seg button + button {
border-left: 1px solid var(--semi-color-fill-2);
}
.segActive {
background: rgba(var(--semi-primary-5), 0.1) !important;
color: var(--semi-color-primary) !important;
font-weight: 600;
}
/* ===== 批量条 ===== */
.batchbar {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px;
margin-bottom: 14px;
background: rgba(var(--semi-primary-5), 0.08);
border: 1px solid var(--semi-color-primary);
border-radius: 8px;
font-size: 13px;
color: var(--semi-color-primary);
font-weight: 600;
}
.batchbar b {
font-variant-numeric: tabular-nums;
}
.batchSpacer {
flex: 1;
}
/* ===== 网格(卡片本体样式在 ui/streamer-card.module.scss) ===== */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(262px, 1fr));
gap: 12px;
align-items: start;
}
.cardActions {
gap: 4px;
}
.cardActions :global(.semi-button) {
padding: 5px;
}
/* ===== 列表视图 ===== */
.tableWrap {
background: var(--semi-color-bg-1);
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
overflow-x: auto;
overflow-y: hidden;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
}
.listTable {
width: 100%;
min-width: 640px;
border-collapse: collapse;
font-size: 13px;
}
.listTable th {
text-align: left;
font-size: 11.5px;
font-weight: 600;
color: var(--semi-color-text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 10px 14px;
border-bottom: 1px solid var(--semi-color-border);
background: var(--semi-color-bg-2);
}
.listTable td {
padding: 9px 14px;
border-bottom: 1px solid var(--semi-color-border);
vertical-align: middle;
}
.listTable tr:last-child td {
border-bottom: none;
}
.listTable tbody tr {
cursor: pointer;
transition: background 0.1s ease;
}
.listTable tbody tr:hover td {
background: var(--semi-color-fill-0);
}
.rowSel td {
background: rgba(var(--semi-primary-5), 0.08) !important;
}
.chk {
width: 15px;
height: 15px;
accent-color: var(--semi-color-primary);
cursor: pointer;
}
.cellName {
font-weight: 600;
color: var(--semi-color-text-0);
}
.cellSub {
font-size: 12px;
color: var(--semi-color-text-2);
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowActions {
display: flex;
gap: 2px;
justify-content: flex-end;
}
.rowActions :global(.semi-button) {
padding: 4px 6px;
}
/* ===== 响应式 ===== */
@media (max-width: 680px) {
.content {
padding: 16px;
}
.search {
width: 100%;
}
}
+379 -228
View File
@@ -1,35 +1,72 @@
'use client'
import {
Layout,
Nav,
Button,
Tag,
Typography,
Popconfirm,
Notification,
Card, Dropdown, Badge,
Layout,
Button,
ButtonGroup,
Popconfirm,
Spin,
Empty,
Notification,
Typography,
} from '@douyinfe/semi-ui'
import {
IconHelpCircle,
IconPlusCircle,
IconVideoListStroked,
IconEdit2Stroked,
IconDeleteStroked,
IconWrench, IconTreeTriangleDown, IconPause, IconPlay, IconLock, IconUpload,
IconPlusCircle,
IconEdit2Stroked,
IconDeleteStroked,
IconWrench,
IconVideoListStroked,
} from '@douyinfe/semi-icons'
import { List, ButtonGroup } from '@douyinfe/semi-ui'
import React, { useState } from 'react'
import useStreamers from '../../lib/use-streamers'
import React, { useMemo, useState } from 'react'
import useSWR from 'swr'
import useSWRMutation from 'swr/mutation'
import TemplateModal from '../../ui/TemplateModal'
import OverrideModal from '../../ui/OverrideModal'
import { LiveStreamerEntity, put, requestDelete, sendRequest } from '../../lib/api-streamer'
import useSWRMutation from 'swr/mutation'
import {PauseButton} from "@/app/ui/StreamerActions/PauseButton";
import {
LiveStreamerEntity,
put,
requestDelete,
sendRequest,
fetcher,
proxy,
StreamerInfo,
} from '../../lib/api-streamer'
import { PauseButton } from '@/app/ui/StreamerActions/PauseButton'
import { platformName, streamerStatusTag } from '@/app/lib/status'
import { hookStepListToForm, formListToHookStep } from '@/app/lib/postprocessor'
import { timeAgo, STREAMERS_REFRESH_MS, SLOW_REFRESH_MS } from '@/app/lib/use-dashboard'
import StreamerCard from '@/app/ui/StreamerCard'
import PageHeader from '../components/PageHeader'
import styles from './page.module.scss'
export default function Home() {
const { Header, Content } = Layout
const { Text } = Typography
const { streamers, isLoading } = useStreamers()
const { Content } = Layout
const { Text } = Typography
/**
* 直播管理:卡片 / 列表双视图 + 搜索 + 平台筛选 + 批量操作。
* 卡片与首页共用 StreamerCard;接口失败与空列表分开处理。
*/
export default function StreamersPage() {
const { data: streamers, error, isLoading, mutate } = useSWR<LiveStreamerEntity[]>(
'/v1/streamers',
fetcher,
{ refreshInterval: STREAMERS_REFRESH_MS }
)
const { data: infos } = useSWR<StreamerInfo[]>('/v1/streamer-info', fetcher, {
refreshInterval: SLOW_REFRESH_MS,
})
// url -> 最新一次录制的直播标题
const infoByUrl = useMemo(() => {
const map = new Map<string, StreamerInfo>()
for (const i of infos ?? []) {
if (!i.url) continue
const cur = map.get(i.url)
if (!cur || i.date > cur.date) map.set(i.url, i)
}
return map
}, [infos])
// ---- 增删改 ----
const { trigger: deleteStreamers } = useSWRMutation('/v1/streamers', requestDelete)
const { trigger: updateStreamers } = useSWRMutation('/v1/streamers', put)
const { trigger } = useSWRMutation('/v1/streamers', sendRequest)
@@ -37,252 +74,366 @@ export default function Home() {
const onConfirm = async (id: number) => {
await deleteStreamers(id)
}
const handleEntityPostprocessor = (values: any) => {
// 回显:后端 HookStep → 表单 {cmd, value}
if (values?.postprocessor) {
values.postprocessor = values.postprocessor.map(
(element: { [key: string]: string } | string) => {
if (element === 'rm') {
return { cmd: 'rm' }
} else if (typeof element === 'object' && !element.cmd) {
const [key, value] = Object.entries(element)[0]
return { cmd: key, value: value }
}
return element
}
)
// console.log(values.postprocessor);
values.postprocessor = hookStepListToForm(values.postprocessor)
}
return values
}
const data: LiveStreamerEntity[] | undefined = streamers?.map(live => {
let statusTag
switch (live.status) {
case 'Working':
statusTag = <Tag color="red">直播中</Tag>
break
case 'Idle':
statusTag = <Tag color="green">空闲</Tag>
break
case 'Pending':
statusTag = <Tag color="indigo">检测中</Tag>
break
case 'OutOfSchedule':
statusTag = <Tag color="green">非录播时间</Tag>
break
case 'TitleExcluded':
statusTag = <Tag color="orange">标题已排除</Tag>
break
case 'Pause':
statusTag = <Tag color="pink">暂停中</Tag>
break
}
return { ...handleEntityPostprocessor(live), statusTag }
})
const handleOk = async (values: any) => {
// 提交:表单 {cmd, value} → 后端 HookStep
if (values?.postprocessor) {
values.postprocessor = values.postprocessor.map(
({ cmd, value }: { cmd: string; value: string }) => (cmd === 'rm' ? 'rm' : { [cmd]: value })
)
values.postprocessor = formListToHookStep(values.postprocessor)
}
try {
const res = await trigger(values)
await trigger(values)
} catch (e: any) {
Notification.error({
title: '创建失败',
content: <Typography.Paragraph style={{ maxWidth: 450 }}>{e.message}</Typography.Paragraph>,
style: { width: 'min-content' },
content: e?.message ?? String(e),
})
// 重新抛出,Modal onOk 保持打开,不丢失已填输入
throw e
}
}
const handleUpdate = async (values: any) => {
console.log(values);
delete values.status
delete values.statusTag
delete values.upload_status
if (values?.postprocessor) {
values.postprocessor = values.postprocessor.map(
({ cmd, value }: { cmd: string; value: string }) => (cmd === 'rm' ? 'rm' : { [cmd]: value })
)
values.postprocessor = formListToHookStep(values.postprocessor)
}
try {
const res = await updateStreamers(values)
await updateStreamers(values)
} catch (e: any) {
Notification.error({
title: '更新失败',
content: <Typography.Paragraph style={{ maxWidth: 450 }}>{e.message}</Typography.Paragraph>,
style: { width: 'min-content' },
content: e?.message ?? String(e),
})
// 重新抛出,Modal onOk 保持打开,不丢失已填输入
throw e
}
}
// ---- 搜索 / 筛选 / 视图 ----
const [query, setQuery] = useState('')
const [filter, setFilter] = useState('全部')
const [layout, setLayout] = useState<'grid' | 'list'>('grid')
const [selected, setSelected] = useState<Set<number>>(new Set())
const platforms = useMemo(() => {
const set = new Set<string>()
for (const s of streamers ?? []) set.add(platformName(s.url))
return ['全部', ...Array.from(set).sort((a, b) => a.localeCompare(b, 'zh'))]
}, [streamers])
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
return (streamers ?? []).filter((s) => {
const okPlat = filter === '全部' || platformName(s.url) === filter
const okQ =
!q ||
(s.remark ?? '').toLowerCase().includes(q) ||
s.url.toLowerCase().includes(q)
return okPlat && okQ
})
}, [streamers, query, filter])
// ---- 批量操作 ----
const toggleSel = (id: number) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleAll = (checked: boolean) => {
setSelected(checked ? new Set(filtered.map((s) => s.id)) : new Set())
}
const batchPause = async () => {
const ids = Array.from(selected)
if (ids.length === 0) return
// 后端 PUT /pause 是 toggle:已暂停(Pause)的会被恢复为 Idle,必须过滤
const targets = (streamers ?? []).filter((s) => selected.has(s.id) && s.status !== 'Pause')
const skipped = ids.length - targets.length
if (targets.length === 0) {
Notification.warning({ title: '所选直播间均已暂停,无需操作' })
return
}
const results = await Promise.allSettled(
targets.map((s) => proxy(`/v1/streamers/${s.id}/pause`, { method: 'PUT' }))
)
const failedIds: number[] = []
results.forEach((r, i) => {
if (r.status === 'rejected') failedIds.push(targets[i].id)
})
const ok = targets.length - failedIds.length
setSelected(new Set(failedIds))
await mutate().catch(() => undefined)
if (failedIds.length === 0) {
Notification.success({
title: '已暂停 ' + ok + ' 个直播间' + (skipped > 0 ? ',' + skipped + ' 个已暂停已跳过' : ''),
})
} else {
Notification.error({
title: '成功暂停 ' + ok + ' 项,' + failedIds.length + ' 项失败',
content: '失败项已保留选中,请检查后端状态后重试。',
})
}
}
const batchDelete = async () => {
const ids = Array.from(selected)
if (ids.length === 0) return
const results = await Promise.all(
ids.map(async (id) => {
try {
await proxy(`/v1/streamers/${id}`, { method: 'DELETE' })
return { id, ok: true }
} catch {
return { id, ok: false }
}
})
)
const failedIds = results.filter((result) => !result.ok).map((result) => result.id)
const successCount = results.length - failedIds.length
setSelected(new Set(failedIds))
await mutate().catch(() => undefined)
if (failedIds.length === 0) {
Notification.success({ title: `已删除 ${successCount} 个直播间` })
} else {
Notification.error({
title: `成功删除 ${successCount} 项,${failedIds.length} 项失败`,
content: '失败项已保留选中,请检查后端状态后重试。',
})
}
}
// 编辑 / 暂停 / 删除 / 高级四个操作,网格卡片与列表行共用,只是外层容器不同
const actionButtons = (item: LiveStreamerEntity) => (
<>
<TemplateModal onOk={handleUpdate} entity={handleEntityPostprocessor({ ...item })}>
<Button theme="borderless" type="primary" icon={<IconEdit2Stroked />} aria-label="编辑" />
</TemplateModal>
<PauseButton streamer={item} />
<Popconfirm title="确定是否要删除?" content="此操作将不可逆" onConfirm={() => onConfirm(item.id)}>
<Button theme="borderless" type="danger" icon={<IconDeleteStroked />} aria-label="删除" />
</Popconfirm>
<OverrideModal onOk={handleUpdate} entity={handleEntityPostprocessor({ ...item })}>
<Button theme="borderless" type="tertiary" icon={<IconWrench />} aria-label="高级" />
</OverrideModal>
</>
)
const renderActions = (item: LiveStreamerEntity) => (
<ButtonGroup theme="borderless" className={styles.cardActions}>
{actionButtons(item)}
</ButtonGroup>
)
const renderRowActions = (item: LiveStreamerEntity) => (
<div className={styles.rowActions}>{actionButtons(item)}</div>
)
return (
<>
<Header
style={{
backgroundColor: 'var(--semi-color-bg-1)',
position: 'sticky',
top: 0,
zIndex: 1,
}}
>
<nav
style={{
display: 'flex',
paddingLeft: '25px',
paddingRight: '25px',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}
>
<div
style={{
display: 'flex',
gap: 10,
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<IconVideoListStroked
size="large"
style={{
backgroundColor: 'rgba(var(--semi-green-4), 1)',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
padding: '6px',
}}
/>
<h4>录播管理</h4>
<PageHeader
title="直播管理"
description="管理需要录制的直播间,支持新增、编辑与删除"
icon={<IconVideoListStroked size="large" />}
actions={
<TemplateModal onOk={handleOk}>
<Button icon={<IconPlusCircle />} theme="solid">
新建
</Button>
</TemplateModal>
}
/>
<Content className={styles.content}>
{isLoading ? (
<div className={styles.center}>
<Spin size="large" />
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}}
>
<Button
theme="borderless"
icon={<IconHelpCircle size="large" />}
style={{
color: 'var(--semi-color-text-2)',
}}
onClick={() => (window.location.href = '/static/ds_update.log')}
) : error ? (
/* 修复:接口失败 ≠ 空列表,给出错误态而不是 Empty */
<div className={styles.center}>
<Empty
title="加载失败"
description="无法获取主播列表,请检查后端连接"
style={{ marginBottom: 12 }}
/>
<TemplateModal onOk={handleOk}>
<Button icon={<IconPlusCircle />} theme="solid" style={{ marginRight: 10 }}>
新建
</Button>
</TemplateModal>
<Button onClick={() => mutate()}>重试</Button>
</div>
</nav>
</Header>
<Content
style={{
padding: '24px',
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<main>
<List
grid={{
gutter: 12,
xs: 24,
sm: 24,
md: 12,
lg: 8,
xl: 6,
xxl: 4,
}}
dataSource={data}
renderItem={item => (
<List.Item>
<Card
shadows="hover"
style={{
// maxWidth: 360,
margin: '9px 0px',
width: '100%',
// flexGrow: 1,
}}
bodyStyle={
{
// display: 'flex',
// alignItems: 'center',
// justifyContent: 'space-between'
}
}
) : (
<>
{/* 工具栏 */}
<div className={styles.toolbar}>
<label className={styles.search}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
width="15"
height="15"
>
<div style={{ position: 'absolute', right: 20, top: 9 }}>{item.statusTag}</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
{item.upload_status === "Pending" ? <Badge count={<IconUpload />}> </Badge> : null}
<h3
style={{
color: 'var(--semi-color-text-0)',
fontWeight: 500,
maxWidth: '80%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.remark}
</h3>
</div>
<Text style={{ width: '101%' }} ellipsis={{ showTooltip: true }} type="tertiary">
{item.url}
</Text>
<div
style={{
margin: '0',
display: 'flex',
padding: '0 0 32px 0px',
justifyContent: 'flex-end',
}}
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
<input
type="text"
placeholder="搜索主播 / URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<div className={styles.filterChips}>
{platforms.map((p) => (
<button
key={p}
className={`${styles.fchip} ${filter === p ? styles.fchipActive : ''}`}
onClick={() => setFilter(p)}
>
<ButtonGroup
theme="borderless"
style={{ position: 'absolute', right: 20, bottom: 15 }}
>
<TemplateModal onOk={handleUpdate} entity={item}>
<Button theme="borderless" icon={<IconEdit2Stroked />}></Button>
</TemplateModal>
<span className="semi-button-group-line semi-button-group-line-borderless semi-button-group-line-primary"></span>
<PauseButton streamer={item}/>
<span className="semi-button-group-line semi-button-group-line-borderless semi-button-group-line-primary"></span>
<Popconfirm
title="确定是否要删除?"
content="此操作将不可逆"
onConfirm={async () => await onConfirm(item.id)}
// onCancel={onCancel}
>
<Button theme="borderless" icon={<IconDeleteStroked />}></Button>
</Popconfirm>
<span className="semi-button-group-line semi-button-group-line-borderless semi-button-group-line-primary"></span>
<OverrideModal onOk={handleUpdate} entity={item}>
<Button theme="borderless" icon={<IconWrench />}></Button>
</OverrideModal>
</ButtonGroup>
</div>
</Card>
{p}
</button>
))}
</div>
<span className={styles.toolbarSpacer} />
<div className={styles.seg}>
<button
className={layout === 'grid' ? styles.segActive : ''}
onClick={() => setLayout('grid')}
aria-label="网格视图"
>
▦ 网格
</button>
<button
className={layout === 'list' ? styles.segActive : ''}
onClick={() => setLayout('list')}
aria-label="列表视图"
>
☰ 列表
</button>
</div>
</div>
</List.Item>
{/* 批量操作条 */}
{selected.size > 0 && (
<div className={styles.batchbar}>
<span>
已选 <b>{selected.size}</b> 项
</span>
<span className={styles.batchSpacer} />
<Button size="small" onClick={batchPause}>
批量暂停
</Button>
<Popconfirm
title={`确定删除选中的 ${selected.size} 个直播间?`}
content="此操作不可逆,删除结果将逐项反馈"
onConfirm={batchDelete}
>
<Button size="small" type="danger" theme="borderless">
批量删除
</Button>
</Popconfirm>
<Button size="small" theme="borderless" onClick={() => setSelected(new Set())}>
取消
</Button>
</div>
)}
/>
</main>
{filtered.length === 0 ? (
<div className={styles.center}>
<Empty
title={streamers && streamers.length > 0 ? '没有匹配的直播间' : '还没有监控任何直播间'}
description={
streamers && streamers.length > 0
? '调整搜索或筛选条件试试'
: '点击右上角「新建」开始'
}
/>
</div>
) : layout === 'grid' ? (
<div className={styles.grid}>
{filtered.map((item) => (
<StreamerCard
key={item.id}
streamer={item}
info={infoByUrl.get(item.url)}
actions={renderActions(item)}
/>
))}
</div>
) : (
<div className={styles.tableWrap}>
<table className={styles.listTable}>
<thead>
<tr>
<th style={{ width: 36 }}>
<input
type="checkbox"
className={styles.chk}
checked={filtered.length > 0 && filtered.every((s) => selected.has(s.id))}
onChange={(e) => toggleAll(e.target.checked)}
aria-label="全选"
/>
</th>
<th>状态</th>
<th>主播</th>
<th>平台</th>
<th>最近录制</th>
<th style={{ textAlign: 'right' }}>操作</th>
</tr>
</thead>
<tbody>
{filtered.map((item) => {
const info = infoByUrl.get(item.url)
return (
<tr
key={item.id}
className={selected.has(item.id) ? styles.rowSel : ''}
onClick={() => toggleSel(item.id)}
>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className={styles.chk}
checked={selected.has(item.id)}
onChange={() => toggleSel(item.id)}
aria-label={`选择 ${item.remark}`}
/>
</td>
<td>{streamerStatusTag(item.status)}</td>
<td>
<div className={styles.cellName}>{item.remark || item.url}</div>
{info?.title ? (
<div className={styles.cellSub}>{info.title}</div>
) : null}
</td>
<td>
<Text type="tertiary" size="small">
{platformName(item.url)}
</Text>
</td>
<td>
<Text type="tertiary" size="small">
{info?.date ? timeAgo(info.date) : '—'}
</Text>
</td>
<td onClick={(e) => e.stopPropagation()}>{renderRowActions(item)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</>
)}
</Content>
</>
)
+69 -76
View File
@@ -1,13 +1,15 @@
'use client'
import React, { useEffect, useRef, useState } from 'react'
import { Form, Button, Toast, Typography, Notification } from '@douyinfe/semi-ui'
import { Form, Button, Toast, Notification, Typography } from '@douyinfe/semi-ui'
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import { IconChevronDown, IconChevronUp, IconPlusCircle } from '@douyinfe/semi-icons'
import { IconPlusCircle } from '@douyinfe/semi-icons'
import { registerMediaQuery, responsiveMap } from '../../../lib/utils'
import { sendRequest, StudioEntity } from '../../../lib/api-streamer'
import useSWRMutation from 'swr/mutation'
import { useRouter } from 'next/navigation'
import TemplateFields from '../../../ui/TemplateFields'
import PageHeader from '../../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
export default function Add() {
const { Paragraph } = Typography
@@ -27,83 +29,74 @@ export default function Add() {
return () => unRegister()
}, [])
const handleCreate = async () => {
const values = await api.current?.validate()
if (!values) return
try {
const studioEntity: StudioEntity = {
template_name: values.template_name,
user_cookie: values.user_cookie,
copyright: values.copyright,
id: values.id,
copyright_source: values.copyright_source ?? '',
tid: values.tid[1],
tid_v2: values.tid_v2 ?? null,
cover_path: values.cover_path ?? '',
title: values.title ?? '',
description: values.description ?? '',
dynamic: values.dynamic ?? '',
tags: values.tags ?? [],
dolby: values.sound?.includes('dolby') ? 1 : 0,
hires: values.sound?.includes('hires') ? 1 : 0,
up_selection_reply: values.interaction?.includes('up_selection_reply') ? 1 : 0,
up_close_reply: values.interaction?.includes('up_close_reply') ? 1 : 0,
up_close_danmu: values.interaction?.includes('up_close_danmu') ? 1 : 0,
charging_pay: values.charging_pay ? 1 : 0,
no_reprint: values.no_reprint ? 1 : 0,
is_only_self: values.is_only_self ? 1 : 0,
mission_id: values.mission_id,
dtime: values.isDtime ? values?.dtime : null,
credits: values.credits,
uploader: values.uploader,
extra_fields: values.extra_fields ?? '',
}
const result = await trigger(studioEntity)
Toast.success('创建成功')
router.push('/upload-manager')
} catch (e: any) {
Notification.error({
title: '创建失败',
content: <Paragraph style={{ maxWidth: 450 }}>{e.message}</Paragraph>,
style: { width: 'min-content' },
})
throw e
}
}
return (
<>
<div style={{ display: 'flex', flexDirection: 'row-reverse', paddingRight: 12 }}>
<Button
onClick={() => {
api.current?.submitForm()
}}
type="primary"
icon={<IconPlusCircle size="large" />}
theme="solid"
style={{ marginTop: 12, marginRight: 4 }}
>
创建模板
</Button>
<PageHeader
icon={<IconPlusCircle size="large" />}
title="新建投稿模板"
description="配置投稿模板,保存后可用于上传录制文件"
actions={
<Button onClick={handleCreate} type="primary" icon={<IconPlusCircle />} theme="solid">
创建模板
</Button>
}
/>
<div className={dc.content}>
<div className={dc.card} style={{ padding: '28px 32px 40px' }}>
<Form
autoScrollToError
onSubmit={handleCreate}
component={TemplateFields}
getFormApi={(formApi) => (api.current = formApi)}
labelWidth="180px"
labelPosition={labelPosition}
/>
</div>
</div>
<main
style={{
backgroundColor: 'var(--semi-color-bg-0)',
display: 'flex',
justifyContent: 'space-around',
}}
>
<Form
autoScrollToError
onSubmit={async values => {
try {
const studioEntity: StudioEntity = {
template_name: values.template_name,
user_cookie: values.user_cookie,
copyright: values.copyright,
id: values.id,
copyright_source: values.copyright_source ?? '',
tid: values.tid[1],
tid_v2: values.tid_v2 ?? null,
cover_path: values.cover_path ?? '',
title: values.title ?? '',
description: values.description ?? '',
dynamic: values.dynamic ?? '',
tags: values.tags ?? [],
// interactive: values.interactive ?? 0,
dolby: values.sound?.includes('dolby') ? 1 : 0,
hires: values.sound?.includes('hires') ? 1 : 0,
up_selection_reply: values.interaction?.includes('up_selection_reply') ? 1 : 0,
up_close_reply: values.interaction?.includes('up_close_reply') ? 1 : 0,
up_close_danmu: values.interaction?.includes('up_close_danmu') ? 1 : 0,
charging_pay: values.charging_pay ? 1 : 0,
no_reprint: values.no_reprint ? 1 : 0,
is_only_self: values.is_only_self ? 1 : 0,
mission_id: values.mission_id,
dtime: values.isDtime ? values?.dtime : null,
credits: values.credits,
uploader: values.uploader,
extra_fields: values.extra_fields ?? '',
}
const result = await trigger(studioEntity)
Toast.success('创建成功')
router.push('/upload-manager')
} catch (e: any) {
// error handling
Notification.error({
title: '创建失败',
content: <Paragraph style={{ maxWidth: 450 }}>{e.message}</Paragraph>,
// theme: 'light',
// duration: 0,
style: { width: 'min-content' },
})
throw e
}
}}
component={TemplateFields}
getFormApi={formApi => (api.current = formApi)}
labelWidth="180px"
labelPosition={labelPosition}
/>
</main>
</>
)
}
+104 -67
View File
@@ -17,6 +17,8 @@ import { useRouter, useSearchParams } from 'next/navigation'
import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import useSWR from 'swr'
import { useTypeTree } from '@/app/lib/use-streamers'
import PageHeader from '../../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
const Edit = () => {
const { Paragraph } = Typography
@@ -42,14 +44,47 @@ const Edit = () => {
return () => unRegister()
}, [])
if (error || isError) return <div>{error?.message}</div>
// 分区树来自 /bili/archive/pre,需要一个可用的 B 站 cookie;没有账号时后端返回 500,
// 这里必须给出可读的原因而不是空白页。
const serverMessage = (e: any): string | undefined => {
const raw = e?.message
if (typeof raw !== 'string' || !raw) return undefined
try {
const parsed = JSON.parse(raw)
return typeof parsed?.message === 'string' ? parsed.message : raw
} catch {
return raw
}
}
const loadError: string | undefined = error
? `模板加载失败:${serverMessage(error) ?? '未知错误'}`
: isError
? `分区列表加载失败:${serverMessage(isError) ?? '未知错误'}(需要至少一个可用的 B 站账号)`
: undefined
if (loadError) {
return (
<>
<PageHeader
icon={<IconPlusCircle size="large" />}
title="编辑投稿模板"
description="修改模板信息并保存"
/>
<div className={dc.content}>
<div className={dc.card} style={{ padding: '28px 32px' }}>
<Typography.Text type="danger">{loadError}</Typography.Text>
</div>
</div>
</>
)
}
if (isLoading) return <div>Loading...</div>
if (!data || !typeTree) return null
let uploadStreamers = {
...data,
tid: [
typeTree.find((tt: BiliType) => {
return tt.children.some(ct => ct.id === data?.tid)
return tt.children.some((ct) => ct.id === data?.tid)
})?.value,
data.tid,
],
@@ -62,73 +97,75 @@ const Edit = () => {
is_only_self: data.is_only_self === 1,
isDtime: data.dtime ? true : false,
}
const handleSave = async () => {
const values = await api.current?.validate()
try {
const studioEntity: StudioEntity = {
template_name: values?.template_name,
user_cookie: values?.user_cookie,
copyright: values?.copyright,
id: values?.id,
copyright_source: values?.copyright_source ?? '',
tid: values?.tid[1],
tid_v2: values?.tid_v2 ?? null,
cover_path: values?.cover_path ?? '',
title: values?.title ?? '',
description: values?.description ?? '',
dynamic: values?.dynamic ?? '',
tags: values?.tags ?? [],
dolby: values?.sound.includes('dolby') ? 1 : 0,
hires: values?.sound.includes('hires') ? 1 : 0,
up_selection_reply: values?.interaction.includes('up_selection_reply') ? 1 : 0,
up_close_reply: values?.interaction.includes('up_close_reply') ? 1 : 0,
up_close_danmu: values?.interaction.includes('up_close_danmu') ? 1 : 0,
charging_pay: values?.charging_pay ? 1 : 0,
no_reprint: values?.no_reprint ? 1 : 0,
is_only_self: values?.is_only_self ? 1 : 0,
mission_id: values?.mission_id,
dtime: values?.isDtime ? values?.dtime : null,
credits: values?.credits ?? null,
uploader: values?.uploader ?? null,
extra_fields: values?.extra_fields ?? '',
}
const result = await trigger(studioEntity)
await mutate(result)
Toast.success('更新成功')
router.push('/upload-manager')
} catch (e: any) {
Notification.error({
title: '保存失败',
content: <Paragraph style={{ maxWidth: 450 }}>{e.message}</Paragraph>,
style: { width: 'min-content' },
})
}
}
return (
<>
<div style={{ display: 'flex', flexDirection: 'row-reverse', paddingRight: 12 }}>
<Button
onClick={async () => {
let values = await api.current?.validate()
try {
const studioEntity = {
template_name: values?.template_name,
user_cookie: values?.user_cookie,
copyright: values?.copyright,
id: values?.id,
copyright_source: values?.copyright_source ?? '',
tid: values?.tid[1],
tid_v2: values?.tid_v2 ?? null,
cover_path: values?.cover_path ?? '',
title: values?.title ?? '',
description: values?.description ?? '',
dynamic: values?.dynamic ?? '',
tags: values?.tags ?? [],
// interactive: values?.interactive ?? 0,
dolby: values?.sound.includes('dolby') ? 1 : 0,
hires: values?.sound.includes('hires') ? 1 : 0,
up_selection_reply: values?.interaction.includes('up_selection_reply') ? 1 : 0,
up_close_reply: values?.interaction.includes('up_close_reply') ? 1 : 0,
up_close_danmu: values?.interaction.includes('up_close_danmu') ? 1 : 0,
charging_pay: values?.charging_pay ? 1 : 0,
no_reprint: values?.no_reprint ? 1 : 0,
is_only_self: values?.is_only_self ? 1 : 0,
mission_id: values?.mission_id,
dtime: values?.isDtime ? values?.dtime : null,
credits: values?.credits ?? null,
uploader: values?.uploader ?? null,
extra_fields: values?.extra_fields ?? '',
}
const result = await trigger(studioEntity)
await mutate(result)
Toast.success('更新成功')
router.push('/upload-manager')
} catch (e: any) {
// error handling
Notification.error({
title: '创建失败',
content: <Paragraph style={{ maxWidth: 450 }}>{e.message}</Paragraph>,
// theme: 'light',
// duration: 0,
style: { width: 'min-content' },
})
}
}}
type="primary"
icon={<IconPlusCircle size="large" />}
theme="solid"
style={{ marginTop: 12, marginRight: 4 }}
>
保存模板
</Button>
</div>
<Form
initValues={uploadStreamers}
style={{ paddingLeft: 30, paddingBottom: 40 }}
getFormApi={formApi => (api.current = formApi)}
autoScrollToError
component={TemplateFields}
labelWidth="180px"
labelPosition={labelPosition}
<PageHeader
icon={<IconPlusCircle size="large" />}
title="编辑投稿模板"
description="修改模板信息并保存"
actions={
<Button onClick={handleSave} type="primary" icon={<IconPlusCircle />} theme="solid">
保存模板
</Button>
}
/>
<div className={dc.content}>
<div className={dc.card} style={{ padding: '28px 32px 40px' }}>
<Form
initValues={uploadStreamers}
autoScrollToError
onSubmit={handleSave}
component={TemplateFields}
getFormApi={(formApi) => (api.current = formApi)}
labelWidth="180px"
labelPosition={labelPosition}
/>
</div>
</div>
</>
)
}
@@ -136,7 +173,7 @@ const Edit = () => {
const EditTemplate: React.FC = () => {
return (
<Suspense>
<Edit></Edit>
<Edit />
</Suspense>
)
}
+78 -131
View File
@@ -2,44 +2,48 @@
import {
Button,
ButtonGroup,
Layout,
List,
Nav,
Popconfirm,
Notification,
Typography,
Modal,
Transfer,
Card,
} from '@douyinfe/semi-ui'
import { IconCloudStroked, IconPlusCircle, IconUserListStroked } from '@douyinfe/semi-icons'
import { SetStateAction, useState } from 'react'
import {
IconCloudStroked,
IconPlusCircle,
IconUserListStroked,
IconEdit2Stroked,
IconSendStroked,
IconDeleteStroked,
} from '@douyinfe/semi-icons'
import { useState } from 'react'
import Link from 'next/link'
import { Card } from '@douyinfe/semi-ui'
import { IconEdit2Stroked, IconSendStroked, IconDeleteStroked } from '@douyinfe/semi-icons'
import { fetcher, FileList, requestDelete, sendRequest, StudioEntity } from '../../lib/api-streamer'
import useSWR from 'swr'
import { useRouter } from 'next/navigation'
import UserList from '../../ui/UserList'
import useSWRMutation from 'swr/mutation'
import { useBiliUsers } from '../../lib/use-streamers'
import PageHeader from '../components/PageHeader'
import dc from '@/app/ui/data-card.module.scss'
export default function Union() {
const { Meta } = Card
const { Paragraph, Title, Text } = Typography
const { Header, Content } = Layout
export default function UploadManager() {
const { Text } = Typography
const [visible, setVisible] = useState(false)
const router = useRouter()
const { trigger: deleteUpload } = useSWRMutation('/v1/upload/streamers', requestDelete)
const {
data: templates,
error,
isLoading,
} = useSWR<StudioEntity[]>('/v1/upload/streamers', fetcher)
const { data: templates, error, isLoading } = useSWR<StudioEntity[]>(
'/v1/upload/streamers',
fetcher
)
const { biliUsers } = useBiliUsers()
const handleAddLinkClick = (event: React.MouseEvent) => {
if (biliUsers.length === 0) {
event.preventDefault() // 阻止Link的默认跳转事件
change() // 运行change函数
event.preventDefault()
change()
Notification.info({
title: '用户列表为空',
position: 'top',
@@ -49,9 +53,7 @@ export default function Union() {
}
}
const change = () => {
setVisible(!visible)
}
const change = () => setVisible(!visible)
const onConfirm = async (id: number) => {
await deleteUpload(id)
}
@@ -72,22 +74,14 @@ export default function Union() {
})
setVisibleModal(false)
}
const handleCancel = () => {
setVisibleModal(false)
console.log('Cancel button clicked')
}
const handleAfterClose = () => {
console.log('After Close callback executed')
}
const { data: fileList } = useSWR<FileList[]>('/v1/videos', fetcher)
const data = fileList?.map(v => {
return {
label: v.name,
value: v.name,
disabled: false,
key: v.key,
}
})
const data = fileList?.map((v) => ({
label: v.name,
value: v.name,
disabled: false,
key: v.key,
}))
const [transferData, setTransferData] = useState<(string | number)[]>([])
const handleTransferChange = (values: (string | number)[], items: any[]) => {
@@ -95,9 +89,26 @@ export default function Union() {
setTransferData(values)
}
const actions = (
<>
<Button
onClick={change}
type="tertiary"
icon={<IconUserListStroked />}
aria-label="用户管理"
title="用户管理"
/>
<Link href="/upload-manager/add" onClick={handleAddLinkClick}>
<Button icon={<IconPlusCircle />} theme="solid">
新建
</Button>
</Link>
</>
)
return (
<>
<UserList visible={visible} onCancel={change}></UserList>
<UserList visible={visible} onCancel={change} />
<Modal
size="medium"
title="文件选择"
@@ -105,11 +116,8 @@ export default function Union() {
style={{ width: 'min(600px, 90vw)' }}
visible={visibleModal}
onOk={handleOk}
afterClose={handleAfterClose}
onCancel={handleCancel}
bodyStyle={{
overflow: 'auto',
}}
onCancel={() => setVisibleModal(false)}
bodyStyle={{ overflow: 'auto' }}
closeOnEsc={true}
>
<Transfer
@@ -120,72 +128,14 @@ export default function Union() {
onChange={handleTransferChange}
/>
</Modal>
<Header style={{ backgroundColor: 'var(--semi-color-bg-1)' }}>
<nav
style={{
display: 'flex',
paddingLeft: '25px',
paddingRight: '25px',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
}}
>
<div
style={{
display: 'flex',
gap: 10,
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<IconCloudStroked
style={{
backgroundColor: 'rgba(var(--semi-violet-4), 1)',
borderRadius: 'var(--semi-border-radius-large)',
color: 'var(--semi-color-bg-0)',
padding: '6px',
}}
size="large"
/>
<h4>投稿管理</h4>
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
}}
>
<Button
onClick={change}
// theme="borderless"
type="tertiary"
icon={<IconUserListStroked />}
style={{
// color: 'var(--semi-color-text-2)',
borderRadius: 'var(--semi-border-radius-circle)',
marginRight: '12px',
}}
/>
<Link href="/upload-manager/add" onClick={handleAddLinkClick}>
<Button icon={<IconPlusCircle />} theme="solid" style={{ marginRight: 10 }}>
新建
</Button>
</Link>
</div>
</nav>
</Header>
<Content
style={{
padding: '24px',
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<PageHeader
icon={<IconCloudStroked size="large" />}
title="投稿管理"
description="管理上传模板,选择录制文件一键投稿"
actions={actions}
/>
<div className={dc.content}>
<List
grid={{
gutter: 12,
@@ -197,57 +147,54 @@ export default function Union() {
xxl: 4,
}}
dataSource={templates}
renderItem={item => (
loading={isLoading}
renderItem={(item: StudioEntity) => (
<List.Item>
<Card
shadows="hover"
style={{
maxWidth: 360,
margin: '8px 2px',
flexGrow: 1,
height: '100%',
borderRadius: 12,
border: '1px solid var(--semi-color-border)',
}}
bodyStyle={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '16px 18px',
}}
>
<Meta
title={
<Text
ellipsis={{
showTooltip: true,
pos: 'middle',
}}
style={{ maxWidth: 150 }}
>
{item.template_name}
</Text>
}
/>
<ButtonGroup style={{ minWidth: 100 }} theme="borderless">
<Button icon={<IconSendStroked />} onClick={() => showDialog(item)}></Button>
{/* 模板名称:优先完整展示,占满剩余宽度,超出才尾部省略 */}
<Text
ellipsis={{ showTooltip: true }}
title={item.template_name}
style={{ flex: 1, minWidth: 0, fontSize: 14, fontWeight: 600 }}
>
{item.template_name}
</Text>
<ButtonGroup style={{ flexShrink: 0 }} theme="borderless">
<Button icon={<IconSendStroked />} onClick={() => showDialog(item)} />
<Button
icon={<IconEdit2Stroked />}
onClick={() => {
router.push(`/upload-manager/edit?id=${item.id}`)
}}
></Button>
onClick={() => router.push(`/upload-manager/edit?id=${item.id}`)}
/>
<Popconfirm
title="确定是否要删除?"
content="此操作将不可逆"
margin={50}
onConfirm={async () => await onConfirm(item.id)}
// onCancel={onCancel}
>
<Button theme="borderless" icon={<IconDeleteStroked />}></Button>
<Button theme="borderless" icon={<IconDeleteStroked />} />
</Popconfirm>
</ButtonGroup>
</Card>
</List.Item>
)}
/>
</Content>
</div>
</>
)
}
+3 -3
View File
@@ -194,12 +194,12 @@ const Component = () => {
{isRegisterMode ? '注册' : '登录'}
</Button>
{isRegisterMode && (
<div style={{ marginTop: '16px', textAlign: 'center', color: '#666' }}>
<div style={{ marginTop: '16px', textAlign: 'center', color: 'var(--semi-color-text-2)' }}>
<span style={{ fontSize: '14px' }}>
注册即表示同意
<a href="/terms" style={{ color: '#1890ff' }}>用户协议</a>
<a href="/terms" style={{ color: 'var(--semi-color-primary)' }}>用户协议</a>
和
<a href="/privacy" style={{ color: '#1890ff' }}>隐私政策</a>
<a href="/privacy" style={{ color: 'var(--semi-color-primary)' }}>隐私政策</a>
</span>
</div>
)}
+18
View File
@@ -4,7 +4,25 @@ import './globals.css'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-Hans">
<head>
{/* 首屏前置设置主题,避免水合期从默认主题闪到已保存主题 */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var m=localStorage.getItem('mode')||'auto';var s=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';var t=m==='auto'?s:m;document.documentElement.setAttribute('theme-mode',t);}catch(e){}})();`,
}}
/>
</head>
<body style={{ width: '100%' }}>
{/*
同步 <head> 脚本设到 <html> 上的 theme-mode 到 <body>。
Semi Design 的 CSS 变量绑定在 body[theme-mode] 上,
而 <head> 阻塞脚本执行时 body 尚未解析,只能写 html。
*/}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=document.documentElement.getAttribute('theme-mode')||'light';document.body.setAttribute('theme-mode',t);}catch(e){}})();`,
}}
/>
{children}
</body>
</html>
+10
View File
@@ -162,3 +162,13 @@ export interface FileList {
updateTime: number;
size: number;
}
export interface StreamerInfo {
id: number;
name: string;
url: string;
title: string;
/** Unix 时间戳(秒),由后端 ts_seconds 序列化 */
date: number;
live_cover_path: string;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 后处理步骤的双向转换:表单形态 {cmd, value} ↔ 后端 HookStep 形态。
*
* 后端 HookStep 是 #[serde(untagged)] 枚举(声明顺序即匹配顺序):
* Run { run: string } | Move { mv: string } | Remux { remux: string } | Remove("rm")
* 表单(ArrayField)中每个步骤的字段是 cmd(Select) + value(Input)。
*
* 严禁把 {mv: target} 改写成 {run: "mv target"} —— untagged 枚举先匹配 Run,
* 会经 sh -c / cmd /C 走 Shell 执行,路径里的特殊字符会被解释,且失去
* Rust fs::rename 的安全移动语义。
*/
export interface PostprocessorFormStep {
cmd?: string
value?: string
}
/** 后端 HookStep → 表单 {cmd, value}(回显 / 编辑用) */
export function hookStepToForm(step: unknown): PostprocessorFormStep {
if (typeof step === 'string') {
// Remove("rm")
return { cmd: 'rm' }
}
if (step && typeof step === 'object') {
const s = step as Record<string, unknown>
// 已是表单形态
if (typeof s.cmd === 'string') {
return { cmd: s.cmd, value: typeof s.value === 'string' ? s.value : undefined }
}
// {run}/{mv}/{remux} 等单键对象 → cmd/key, value/value
const keys = Object.keys(s).filter(k => s[k] !== undefined && s[k] !== null && s[k] !== '')
if (keys.length > 0) {
return { cmd: keys[0], value: String(s[keys[0]]) }
}
}
return { cmd: 'run', value: '' }
}
/** 表单 {cmd, value} → 后端 HookStep(提交前用) */
export function formToHookStep(step: PostprocessorFormStep): unknown {
const cmd = step && step.cmd
const value = step && step.value !== undefined ? step.value : ''
switch (cmd) {
case 'rm':
// Remove("rm")
return 'rm'
case 'run':
return { run: value }
case 'mv':
return { mv: value }
case 'remux':
return { remux: value }
default:
// webhook 等后端暂不支持的形态:原样返回,不静默丢弃
return step
}
}
/** 整段转换:后端 HookStep[] → 表单步骤 */
export function hookStepListToForm(list: unknown[] | undefined | null): PostprocessorFormStep[] {
return (list || []).map(hookStepToForm)
}
/** 整段转换:表单步骤 → 后端 HookStep[] */
export function formListToHookStep(list: PostprocessorFormStep[] | undefined | null): unknown[] {
return (list || []).map(formToHookStep)
}
+115
View File
@@ -0,0 +1,115 @@
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 '直播源'
}
}
+179
View File
@@ -0,0 +1,179 @@
'use client'
import useSWR from 'swr'
import { fetcher, LiveStreamerEntity, StreamerInfo, FileList } from './api-streamer'
import { platformName } from './status'
/**
* 控制台数据聚合 hook —— 纯前端实现,零后端改动。
*
* 数据源(全部为现有后端接口):
* - /v1/streamers 主播与实时状态(轮询快:4s)
* - /v1/streamer-info 直播标题 / 开始时间(轮询慢:30s)
* - /v1/videos 录制文件列表(轮询慢:30s,用于文件总量 / 今日新增 / 事件流)
* - /v1/status 服务版本等(轮询慢:30s,仅用于可达性)
*
* 设计说明:后端没有"错误/磁盘占用"等指标,因此控制台不展示虚构数据,
* KPI 全部来自上述接口的真实聚合。
*/
/** 直播中(后端 WorkerStatus Debug 字符串,已验证与 WorkerStatus 枚举一致) */
export const LIVE_STATUS = 'Working'
export const PAUSE_STATUS = 'Pause'
export interface DashboardEvent {
/** Unix 秒 */
ts: number
kind: 'start' | 'file'
/** 主文本 */
text: string
/** 右侧小标签(平台 / 大小) */
sub?: string
}
export function formatSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)}G`
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(0)}M`
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}K`
return `${bytes}B`
}
export function timeAgo(tsSec: number): string {
const diff = Math.floor(Date.now() / 1000 - tsSec)
if (diff < 60) return '刚刚'
if (diff < 3600) return `${Math.floor(diff / 60)} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return `${Math.floor(diff / 86400)} 天前`
}
/** 已录制时长(秒) → "1h 12min" 样式 */
export function formatDuration(sec: number): string {
if (sec < 60) return `${Math.max(0, Math.floor(sec))}s`
if (sec < 3600) return `${Math.floor(sec / 60)}min`
return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}min`
}
const DAY = 86400
const EVENT_WINDOW = DAY // 事件流只展示最近 24h
/**
* 轮询间隔。/v1/streamer-info 是全表查询、/v1/videos 每次扫一遍工作目录,
* 长期运行的实例上这两个接口不便宜,所以只有主播状态走较快的节奏。
*/
export const STREAMERS_REFRESH_MS = 10000
export const SLOW_REFRESH_MS = 60000
export function useDashboard() {
const { data: streamers, error: e1 } = useSWR<LiveStreamerEntity[]>(
'/v1/streamers',
fetcher,
{ refreshInterval: STREAMERS_REFRESH_MS, revalidateOnFocus: true }
)
const { data: infos, error: e2 } = useSWR<StreamerInfo[]>(
'/v1/streamer-info',
fetcher,
{ refreshInterval: SLOW_REFRESH_MS }
)
const { data: videos, error: e3 } = useSWR<FileList[]>(
'/v1/videos',
fetcher,
{ refreshInterval: SLOW_REFRESH_MS }
)
const { data: status, error: e4 } = useSWR(
'/v1/status',
fetcher,
{ refreshInterval: SLOW_REFRESH_MS }
)
// ---- KPI:主播与任务状态 ----
const list = streamers ?? []
const total = list.length
const recording = list.filter((s) => s.status === LIVE_STATUS).length
const pending = list.filter((s) => s.upload_status === 'Pending').length
const uploading = list.filter((s) => s.upload_status === LIVE_STATUS).length
// ---- KPI:录制文件聚合(来自 /v1/videos) ----
// "今日"按本地时区的自然日 0 点起算
const nowLocal = new Date()
const todayStart = Math.floor(
new Date(nowLocal.getFullYear(), nowLocal.getMonth(), nowLocal.getDate()).getTime() / 1000
)
let totalSize = 0
let todaySize = 0
for (const v of videos ?? []) {
totalSize += v.size || 0
if ((v.updateTime || 0) >= todayStart) todaySize += v.size || 0
}
// ---- url → 最新 StreamerInfo(标题 / 最近开始时间) ----
const infoByUrl = new Map<string, StreamerInfo>()
for (const i of infos ?? []) {
if (!i.url) continue
const cur = infoByUrl.get(i.url)
if (!cur || i.date > cur.date) infoByUrl.set(i.url, i)
}
// ---- 事件流:前端合成,零后端改动 ----
// 1) 开始录制:streamer-info 里 24h 内的直播开始时间(按 url 合并取最新)
// 2) 文件生成:/v1/videos 里 24h 内生成的录制文件
const events: DashboardEvent[] = []
const now = Math.floor(Date.now() / 1000)
for (const i of Array.from(infoByUrl.values())) {
if (i.date && now - i.date <= EVENT_WINDOW) {
events.push({
ts: i.date,
kind: 'start',
text: `${i.name || i.url} 开始录制`,
sub: i.url ? platformName(i.url) : undefined,
})
}
}
for (const v of videos ?? []) {
const t = v.updateTime || 0
if (t && now - t <= EVENT_WINDOW) {
events.push({
ts: t,
kind: 'file',
text: v.name || '录制文件',
sub: formatSize(v.size || 0),
})
}
}
events.sort((a, b) => b.ts - a.ts)
// ---- 错误与可达性 ----
// 只有四个接口全部失败才判定为「后端不可达」;单个接口失败(尤其是序列化整份配置的
// /v1/status)只降级对应区块,不能把主播卡片整页替换成断连提示。
const hasAnyResponse =
streamers !== undefined || infos !== undefined || videos !== undefined || status !== undefined
const hasAnyError = !!e1 || !!e2 || !!e3 || !!e4
const allFailed = !!e1 && !!e2 && !!e3 && !!e4
const loading = !hasAnyResponse && !hasAnyError
const connectError = allFailed || (!hasAnyResponse && hasAnyError)
const streamersFailed = !!e1
const infosFailed = !!e2
const videosFailed = !!e3
const statusFailed = !!e4
return {
streamers,
infos,
videos,
version: (status as { version?: string } | undefined)?.version,
// KPI
total,
recording,
pending,
uploading,
totalSize,
todaySize,
infoByUrl,
events: events.slice(0, 12),
// 状态
loading,
connectError,
streamersFailed,
infosFailed,
videosFailed,
statusFailed,
}
}
+92
View File
@@ -0,0 +1,92 @@
'use client'
import { useEffect } from 'react'
export const BG_KEY = 'biliup_ui_bg'
export const BG_OPACITY_KEY = 'biliup_ui_bg_opacity'
/** 默认遮罩透明度(0~1),保证文字可读 */
export const DEFAULT_BG_OPACITY = 0.35
/** 背景只接受本地 data URL;历史版本曾支持 http(s) 外链,启动时会把这类旧值清掉 */
function isAllowedBg(url: string): boolean {
return !url || url.startsWith('data:')
}
export function getBg(): string {
if (typeof window === 'undefined') return ''
return localStorage.getItem(BG_KEY) || ''
}
/** 读取遮罩透明度(0.05~0.8,与滑块范围一致,默认 0.35) */
export function getBgOpacity(): number {
if (typeof window === 'undefined') return DEFAULT_BG_OPACITY
const raw = parseFloat(localStorage.getItem(BG_OPACITY_KEY) ?? '')
if (Number.isNaN(raw)) return DEFAULT_BG_OPACITY
return Math.min(0.8, Math.max(0.05, raw))
}
/** 设置遮罩透明度并立即生效 */
export function setBgOpacity(v: number) {
if (typeof window === 'undefined') return
const clamped = Math.min(0.8, Math.max(0.05, v))
try {
localStorage.setItem(BG_OPACITY_KEY, String(clamped))
} catch {
/* ignore */
}
document.body.style.setProperty('--app-bg-mask-opacity', String(clamped))
}
export function applyBg(url: string) {
if (typeof document === 'undefined') return
const body = document.body
// 去除可能引发 CSS 注入的引号/反斜杠,data URL 本身不含这些字符
const safe = url.replace(/["\\]/g, '')
if (url) {
body.style.setProperty('--app-bg-image', `url("${safe}")`)
body.classList.add('app-has-bg')
} else {
body.style.removeProperty('--app-bg-image')
body.classList.remove('app-has-bg')
}
// 同步遮罩透明度(默认 0.35,用户可调)
body.style.setProperty('--app-bg-mask-opacity', String(getBgOpacity()))
}
export function setBg(url: string) {
if (typeof window === 'undefined') return
if (url && !isAllowedBg(url)) {
// 拒绝远程 URL,避免外链背景带来的各类副作用
console.warn('[biliup] 仅支持本地图片作为背景,远程 URL 已被忽略')
return
}
// localStorage 写入失败(如配额满)不阻断:背景仍即时应用,仅刷新后丢失
if (url) {
try {
localStorage.setItem(BG_KEY, url)
} catch {
console.warn('[biliup] 背景图超过 localStorage 配额,本次仅即时生效')
}
} else {
try {
localStorage.removeItem(BG_KEY)
} catch {
/* ignore */
}
}
applyBg(url)
}
export function useGlobalBackgroundInit() {
useEffect(() => {
const url = getBg()
// 先同步透明度(即使无背景也无害,避免首帧闪烁)
document.body.style.setProperty('--app-bg-mask-opacity', String(getBgOpacity()))
if (!url) return
// 清理历史遗留的远程背景配置,避免旧数据继续生效
if (!isAllowedBg(url)) {
setBg('')
return
}
applyBg(url)
}, [])
}
+34
View File
@@ -0,0 +1,34 @@
'use client'
import { useEffect, useState } from 'react'
/**
* 水合安全的移动端判定。
* 关键:首屏(服务端 + 客户端首次渲染)统一返回 false,
* 等挂载完成后再通过 effect 读 window.innerWidth 更新,
* 避免和 react-use 的 useWindowSize 一样在渲染期读 window 导致 hydration mismatch。
*/
export function useIsMobile(breakpoint = 640): boolean {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
const check = () => setIsMobile(window.innerWidth <= breakpoint)
check()
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [breakpoint])
return isMobile
}
/**
* 水合安全的视口宽度。
* 首屏统一返回 Infinity(与 SSR 一致),挂载后由 effect 填真实值。
*/
export function useWindowWidth(): number {
const [width, setWidth] = useState(Infinity)
useEffect(() => {
const update = () => setWidth(window.innerWidth)
update()
window.addEventListener('resize', update)
return () => window.removeEventListener('resize', update)
}, [])
return width
}
+22 -12
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
export const responsiveMap = {
xs: '(max-width: 575px)',
@@ -72,19 +72,29 @@ export const useSystemTheme = () => {
return theme
}
/**
* 同步主题属性到 <html> 与 <body>。
* Semi Design 的 CSS 变量绑定在 body[theme-mode] 上;
* 我们自己的 CSS(如 .shadow)与 no-flash 脚本使用 <html> 上的 theme-mode。
* 两者都写才能同时兼容 Semi 与自定义选择器。
*/
export const applyThemeMode = (mode: 'light' | 'dark') => {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('theme-mode', mode)
document.body.setAttribute('theme-mode', mode)
}
export const useTheme = (mode: string, systemTheme: string) => {
const firstRun = useRef(true)
useEffect(() => {
localStorage.setItem('mode', mode)
switch (mode) {
case 'light':
document.body.setAttribute('theme-mode', 'light')
break
case 'dark':
document.body.setAttribute('theme-mode', 'dark')
break
default:
document.body.setAttribute('theme-mode', systemTheme)
break
// 首屏主题已由根布局 <head> 内联脚本前置设置;这里跳过首次执行,
// 避免水合后用默认值(auto→system)又把已保存的主题覆盖掉一次,造成闪烁。
if (firstRun.current) {
firstRun.current = false
return
}
localStorage.setItem('mode', mode)
const actualMode = (mode === 'auto' ? systemTheme : mode) as 'light' | 'dark'
applyThemeMode(actualMode)
}, [mode, systemTheme])
}
-9
View File
@@ -1,9 +0,0 @@
.shadow {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1) !important;
background-color: var(--semi-color-bg-0) !important;
/*margin-left: 11px;*/
}
body[theme-mode=dark] .shadow {
box-shadow: var(--semi-shadow-elevated) !important;
}
-139
View File
@@ -1,139 +0,0 @@
.shadow {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
background-color: var(--semi-color-bg-0);
/*margin-left: 11px;*/
}
body[theme-mode=dark] .shadow {
box-shadow: var(--semi-shadow-elevated);
}
/*.container {*/
/* padding: 0 2rem;*/
/*}*/
/*.main {*/
/* min-height: 100vh;*/
/* padding: 4rem 0;*/
/* flex: 1;*/
/* display: flex;*/
/* flex-direction: column;*/
/* justify-content: center;*/
/* align-items: center;*/
/*}*/
/*.footer {*/
/* display: flex;*/
/* flex: 1;*/
/* padding: 2rem 0;*/
/* border-top: 1px solid #eaeaea;*/
/* justify-content: center;*/
/* align-items: center;*/
/*}*/
/*.footer a {*/
/* display: flex;*/
/* justify-content: center;*/
/* align-items: center;*/
/* flex-grow: 1;*/
/*}*/
/*.title a {*/
/* color: #0070f3;*/
/* text-decoration: none;*/
/*}*/
/*.title a:hover,*/
/*.title a:focus,*/
/*.title a:active {*/
/* text-decoration: underline;*/
/*}*/
/*.title {*/
/* margin: 0;*/
/* line-height: 1.15;*/
/* font-size: 4rem;*/
/*}*/
/*.title,*/
/*.description {*/
/* text-align: center;*/
/*}*/
/*.description {*/
/* margin: 4rem 0;*/
/* line-height: 1.5;*/
/* font-size: 1.5rem;*/
/*}*/
/*.code {*/
/* background: #fafafa;*/
/* border-radius: 5px;*/
/* padding: 0.75rem;*/
/* font-size: 1.1rem;*/
/* font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono,*/
/* Bitstream Vera Sans Mono, Courier New, monospace;*/
/*}*/
/*.grid {*/
/* display: flex;*/
/* align-items: center;*/
/* justify-content: center;*/
/* flex-wrap: wrap;*/
/* max-width: 800px;*/
/*}*/
/*.card {*/
/* margin: 1rem;*/
/* padding: 1.5rem;*/
/* text-align: left;*/
/* color: inherit;*/
/* text-decoration: none;*/
/* border: 1px solid #eaeaea;*/
/* border-radius: 10px;*/
/* transition: color 0.15s ease, border-color 0.15s ease;*/
/* max-width: 300px;*/
/*}*/
/*.card:hover,*/
/*.card:focus,*/
/*.card:active {*/
/* color: #0070f3;*/
/* border-color: #0070f3;*/
/*}*/
/*.card h2 {*/
/* margin: 0 0 1rem 0;*/
/* font-size: 1.5rem;*/
/*}*/
/*.card p {*/
/* margin: 0;*/
/* font-size: 1.25rem;*/
/* line-height: 1.5;*/
/*}*/
/*.logo {*/
/* height: 1em;*/
/* margin-left: 0.5rem;*/
/*}*/
/*@media (max-width: 600px) {*/
/* .grid {*/
/* width: 100%;*/
/* flex-direction: column;*/
/* }*/
/*}*/
/*@media (prefers-color-scheme: dark) {*/
/* .card,*/
/* .footer {*/
/* border-color: #222;*/
/* }*/
/* .code {*/
/* background: #111;*/
/* }*/
/* .logo img {*/
/* filter: invert(1);*/
/* }*/
/*}*/
+77
View File
@@ -301,3 +301,80 @@
}
}
}
/* 表单卡片:必须加实色背景,避免全局背景图穿透导致文字不可读 */
.frameDownload,
.frameUpload,
.framePlatformConfig,
.frameDeveloper {
background: var(--semi-color-bg-1);
}
/* 平台设置:左列平台名 + 右栏选中表单 */
.platformLayout {
display: flex;
gap: 16px;
align-items: flex-start;
margin-top: 16px;
}
.platformNav {
width: 160px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px;
background: var(--semi-color-fill-0);
border-radius: 8px;
}
.platformNavItem {
appearance: none;
border: none;
text-align: left;
cursor: pointer;
padding: 8px 12px;
border-radius: 6px;
font-size: 13px;
color: var(--semi-color-text-1);
background: transparent;
transition: background 0.15s ease, color 0.15s ease;
}
.platformNavItem:hover {
background: var(--semi-color-fill-1);
color: var(--semi-color-text-0);
}
.platformNavItemActive,
.platformNavItemActive:hover {
background: var(--semi-color-primary);
color: var(--semi-color-white);
}
.platformBody {
flex: 1;
min-width: 0;
}
/* 移动端:卡片内边距收紧,避免左右留白/溢出 */
@media (max-width: 680px) {
.rootConfigPanel {
padding: 0 12px !important;
}
.frameDownload,
.frameUpload,
.framePlatformConfig,
.frameDeveloper {
padding: 16px 14px 24px !important;
border-radius: 10px;
}
.platformLayout {
flex-direction: column;
}
.platformNav {
width: 100%;
flex-direction: row;
overflow-x: auto;
flex-shrink: 0;
}
.platformNavItem {
white-space: nowrap;
}
}
+35
View File
@@ -0,0 +1,35 @@
'use client'
import React from 'react'
import { DashboardEvent } from '@/app/lib/use-dashboard'
import styles from './event-timeline.module.scss'
function fmtTime(ts: number): string {
const d = new Date(ts * 1000)
const pad = (n: number) => String(n).padStart(2, '0')
return `${pad(d.getHours())}:${pad(d.getMinutes())}`
}
/**
* 最近事件时间线:前端由 /v1/streamer-info(开始录制)与 /v1/videos(文件生成)
* 合成,零后端改动。
*/
export default function EventTimeline({ events }: { events: DashboardEvent[] }) {
if (events.length === 0) {
return (
<div className={styles.empty}>
最近 24h 没有录制活动
</div>
)
}
return (
<div className={styles.timeline}>
{events.map((e, idx) => (
<div key={`${e.kind}-${e.ts}-${idx}`} className={`${styles.item} ${styles[e.kind]}`}>
<span className={styles.time}>{fmtTime(e.ts)}</span>
<span className={styles.text}>{e.text}</span>
{e.sub ? <span className={styles.tag}>{e.sub}</span> : null}
</div>
))}
</div>
)
}
+72 -42
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react'
import { fetcher, sendRequest, proxy } from '@/app/lib/api-streamer'
import { fetcher, proxy } from '@/app/lib/api-streamer'
import { QRCodeSVG } from 'qrcode.react'
import { Notification, Spin, Typography } from '@douyinfe/semi-ui'
@@ -7,61 +7,91 @@ type QrcodeProps = {
onSuccess: (e: string) => void
}
/**
* 扫码登录:拉取二维码 → 挂起等待扫码 → 回调 onSuccess。
* 健壮性修复:
* - 响应字段安全取值(data.url 缺失时显示错误而非崩溃)
* - 组件卸载触发的 AbortError 静默忽略(不再弹错误提示)
* - 加载 / 错误 / 二维码三态清晰
*/
const Qrcode: React.FC<QrcodeProps> = ({ onSuccess }) => {
const [url, setUrl] = useState('')
const [error, setError] = useState('')
useEffect(() => {
// Create an instance.
const controller = new AbortController()
const signal = controller.signal
// Register a listenr.
signal.addEventListener('abort', () => {
console.log('aborted!')
})
; (async () => {
let qrData = await fetcher('/v1/get_qrcode', undefined)
setUrl(qrData['data']['url'])
console.log(qrData['data']['url'])
let res = await proxy('/v1/login_by_qrcode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(qrData),
signal: signal,
})
const data = await res.json()
onSuccess(data['filename'])
})().catch(e => {
console.log(e)
setUrl(e.message)
Notification.error({
title: 'QRcode',
content: <Typography.Paragraph style={{ maxWidth: 450 }}>{e.message}</Typography.Paragraph>,
style: { width: 'min-content' },
})
;(async () => {
const qrData = await fetcher('/v1/get_qrcode', undefined)
const qrUrl: string | undefined = qrData?.['data']?.['url']
if (!qrUrl) {
// 带上响应内容,便于定位(后端偶发 5xx 时 data 结构会缺失)
const dump = JSON.stringify(qrData ?? {}).slice(0, 200)
setError(`二维码获取失败:响应中缺少 url 字段 · ${dump}`)
return
}
setUrl(qrUrl)
const res = await proxy('/v1/login_by_qrcode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(qrData),
signal,
})
const data = await res.json()
onSuccess(data['filename'])
})().catch((e: any) => {
// 组件卸载/切换导致的取消,静默忽略。
// 注意:abort(reason) 时 fetch 可能 reject 原始值(字符串)而非 DOMException,
// e.name 检查不可靠,用 signal.aborted 判断(与错误对象形态无关)。
if (controller.signal.aborted) return
const detail = e?.message || String(e) || '未知错误'
setError(`二维码获取失败:${detail}`)
Notification.error({
title: 'QRcode',
content: (
<Typography.Paragraph style={{ maxWidth: 450 }}>{detail}</Typography.Paragraph>
),
style: { width: 'min-content' },
})
})
return () => {
controller.abort('qrcode exit')
controller.abort()
}
}, [onSuccess])
if (url === '') {
return <Spin />
if (url) {
return (
<div
style={{
marginTop: 30,
marginLeft: 'auto',
marginRight: 'auto',
width: 'max-content',
}}
>
<QRCodeSVG value={url} />
</div>
)
}
if (!url.startsWith("http")) {
return <> {url} </>
if (error) {
return (
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Typography.Text type="danger">{error}</Typography.Text>
</div>
)
}
return (
<div
style={{
marginTop: 30,
marginLeft: 'auto',
marginRight: 'auto',
width: 'max-content',
}}
>
<QRCodeSVG value={url} />
<div style={{ marginTop: 30, textAlign: 'center' }}>
<Spin />
<div style={{ marginTop: 10, fontSize: 12, color: 'var(--semi-color-text-2)' }}>
正在获取二维码…
</div>
</div>
)
}
+6 -3
View File
@@ -2,10 +2,11 @@ import React, { useState } from 'react';
import { useSWRConfig } from 'swr';
import useSWRMutation from 'swr/mutation';
import {Button} from "@douyinfe/semi-ui";
import type {ButtonProps} from "@douyinfe/semi-ui/lib/es/button";
import {IconPause, IconPlay} from "@douyinfe/semi-icons";
import {API_BASE, LiveStreamerEntity} from "@/app/lib/api-streamer";
interface PauseButtonProps {
interface PauseButtonProps extends Omit<ButtonProps, 'onClick' | 'icon' | 'theme' | 'onError'> {
streamer: LiveStreamerEntity;
onSuccess?: () => void;
onError?: (error: Error) => void;
@@ -25,7 +26,9 @@ export const pauseStreamer = async (url: string, ) => {
export const PauseButton: React.FC<PauseButtonProps> = ({
streamer,
onSuccess,
onError
onError,
type = 'tertiary',
...rest
}) => {
const { mutate } = useSWRConfig();
@@ -47,6 +50,6 @@ export const PauseButton: React.FC<PauseButtonProps> = ({
};
return (
<Button onClick={handlePause} icon={streamer.status === 'Pause'? <IconPlay />: <IconPause />} theme="borderless" aria-label="暂停" />
<Button onClick={handlePause} icon={streamer.status === 'Pause'? <IconPlay />: <IconPause />} theme="borderless" type={type} aria-label="暂停" {...rest} />
);
};
+132
View File
@@ -0,0 +1,132 @@
'use client'
import React from 'react'
import { LiveStreamerEntity, StreamerInfo } from '@/app/lib/api-streamer'
import { streamerStatusMeta, uploadStatusTag, platformName } from '@/app/lib/status'
import { timeAgo, formatDuration } from '@/app/lib/use-dashboard'
import styles from './streamer-card.module.scss'
export interface StreamerCardProps {
streamer: LiveStreamerEntity
/** 匹配 url 的最新 streamer-info(标题 / 最近开始时间) */
info?: StreamerInfo
/** 直播管理页的批量选择 */
selectable?: boolean
selected?: boolean
onToggleSelect?: (id: number) => void
/** 卡片底部操作区(编辑 / 暂停 / 删除等) */
actions?: React.ReactNode
}
/** 平台品牌色:用于右上角平台 chip,打破卡片单调并提供平台识别 */
const PLAT_COLORS: Record<string, string> = {
哔哩哔哩: '#fb7299',
虎牙直播: '#ff6a00',
斗鱼直播: '#ff6b35',
抖音直播: '#22c1c3',
YouTube: '#ff0000',
Twitch: '#9146ff',
CC直播: '#3aac5f',
快手直播: '#ff4906',
}
const DEFAULT_PLAT_COLOR = '#6b6c75'
/** 平台色 → 10% 淡底(hex + alpha 后缀) */
function platTint(color: string): string {
return color + '1a'
}
/**
* 全局统一的直播间卡片:
* 状态徽章 + 平台色 chip + 名称 + 实时时长(直播中)/ 最近录制(未开播)
* + 标题 + URL + 上传状态。主页与直播管理页共用。
*/
export default function StreamerCard({
streamer,
info,
selectable,
selected,
onToggleSelect,
actions,
}: StreamerCardProps) {
const meta = streamerStatusMeta(streamer.status)
const live = streamer.status === 'Working'
const paused = streamer.status === 'Pause'
const name = streamer.remark || streamer.url
// 实时时长:直播中时由"直播开始时间"计算
let duration: string | null = null
if (live && info?.date) {
duration = formatDuration(Date.now() / 1000 - info.date)
}
const lastRec = info?.date && !live ? timeAgo(info.date) : null
const plat = platformName(streamer.url)
const platColor = PLAT_COLORS[plat] ?? DEFAULT_PLAT_COLOR
const cardCls = [
styles.card,
live ? styles.rec : '',
paused ? styles.paused : '',
]
.filter(Boolean)
.join(' ')
return (
<article className={cardCls} data-id={streamer.id}>
{/* 头部:状态徽章(左)+ 平台 chip(右) */}
<div className={styles.topRow}>
<span className={styles.left}>
{selectable ? (
<input
type="checkbox"
className={styles.chk}
checked={!!selected}
onChange={() => onToggleSelect?.(streamer.id)}
aria-label={`选择 ${name}`}
/>
) : null}
<span className={`${styles.badge} ${styles[meta.cls]}`}>
<span className={styles.bdot} />
{meta.label}
</span>
</span>
<span
className={styles.platChip}
style={{ backgroundColor: platTint(platColor), color: platColor }}
title={streamer.url}
>
<span className={styles.platDot} style={{ backgroundColor: platColor }} />
{plat}
</span>
</div>
{/* 名称 + 实时时长 / 最近录制 */}
<div className={styles.nameRow}>
<div className={styles.name} title={name}>
{name}
</div>
{duration ? (
<span className={styles.liveInfo}>{duration}</span>
) : lastRec ? (
<span className={styles.recInfo}>{lastRec}</span>
) : null}
</div>
{/* 标题行:始终渲染(空字符串占位),保证所有卡片等高、标题基线对齐 */}
<div className={styles.title} title={info?.title || ''}>
{info?.title || ''}
</div>
<a
className={styles.url}
href={streamer.url}
target="_blank"
rel="noreferrer"
title={streamer.url}
>
{streamer.url}
</a>
<div className={styles.meta}>{uploadStatusTag(streamer.upload_status)}</div>
{actions ? <div className={styles.actions}>{actions}</div> : null}
</article>
)
}
+1 -3
View File
@@ -116,7 +116,7 @@ const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>>
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: 1000, display: 'flex' }}>
<div key={key} style={{ width: '100%', display: 'flex' }}>
<InputGroup>
<Input field={`${field}.username`} label="需要@的用户名" placeholder="username" />
<Input field={`${field}.uid`} label="需要@的uid" placeholder="uid" />
@@ -265,7 +265,6 @@ const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>>
addOnBlur={true}
separator=","
placeholder="可用英文逗号分隔以批量输入标签,失焦/Enter 以保存"
onChange={v => console.log(v)}
style={{ width: 560 }}
rules={[{ required: true, message: 'Tag不能为空' }]}
onExceed={v => {
@@ -353,7 +352,6 @@ const TemplateFields: React.FC<FormFCChild<StudioEntity & { isDtime: boolean }>>
duration: 3,
position: 'top',
})
console.log(delaySeconds)
}}
>
确认
+9 -10
View File
@@ -219,7 +219,7 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
{collapsed}
</Collapsible>
<a onClick={toggle} style={{ ...linkStyle }}>
+ Show {isOpen ? 'Less' : 'More'}
{isOpen ? '收起' : '展开更多'}
</a>
</div>
</>
@@ -250,7 +250,6 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
text: '录制时间范围',
optional: true,
style: {
fontSize: '18px',
marginBottom: '4px',
paddingBottom: '8px',
borderBottom: '1px solid var(--semi-color-border)',
@@ -270,7 +269,7 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
添加关键词
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: 1000, display: 'flex' }}>
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={field}
label={`关键词${i + 1}`}
@@ -300,13 +299,13 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: 1000, display: 'flex' }}>
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ width: 400, marginRight: 16 }}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
@@ -331,13 +330,13 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: 1000, display: 'flex' }}>
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ width: 400, marginRight: 16 }}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
@@ -362,13 +361,13 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: 1000, display: 'flex' }}>
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={`${field}[run]`}
label={`run = `}
labelPosition="inset"
rules={[{ required: true, message }]}
style={{ width: 400, marginRight: 16 }}
style={{ flex: 1, minWidth: 0, marginRight: 16 }}
></Form.Input>
<Button
type="danger"
@@ -393,7 +392,7 @@ const TemplateModal: React.FC<TemplateModalProps> = ({ children, entity, onOk })
添加行
</Button>
{arrayFields.map(({ field, key, remove }, i) => (
<div key={key} style={{ width: 1000, display: 'flex' }}>
<div key={key} style={{ width: '100%', display: 'flex' }}>
<Form.Input
field={field}
label={`参数${i + 1}`}
+7 -18
View File
@@ -2,6 +2,7 @@
import { SetStateAction, useEffect, useState } from 'react'
import { Button } from '@douyinfe/semi-ui'
import { IconMoon, IconSun, IconContrast } from '@douyinfe/semi-icons'
import { applyThemeMode } from '../lib/utils'
interface ThemeButtonProps {
mode: string
@@ -17,26 +18,14 @@ const ThemeButton: React.FC<ThemeButtonProps> = props => {
const [icon, setIcon] = useState(<IconContrast size="large" />)
useEffect(() => {
{
// 按下按钮切换主题
// 按下按钮切换主题:一键明暗反转,避免 auto→light→dark 循环的第一下视觉无变化。
if (typeof window !== 'undefined' && switchTrigger === true) {
const body = document.body
const currentMode = props.mode
let nextMode = currentMode
switch (currentMode) {
case 'auto':
nextMode = 'light'
break
case 'light':
nextMode = 'dark'
break
default:
nextMode = 'auto'
break
}
body.removeAttribute('theme-mode')
nextMode === 'auto'
? body.setAttribute('theme-mode', props.systemTheme)
: body.setAttribute('theme-mode', nextMode)
const isDarkNow =
currentMode === 'dark' ||
(currentMode === 'auto' && props.systemTheme === 'dark')
const nextMode = isDarkNow ? 'light' : 'dark'
applyThemeMode(nextMode)
props.setMode(nextMode)
setSwitchTrigger(false)
}
+4 -5
View File
@@ -22,7 +22,7 @@ import { FormApi } from '@douyinfe/semi-ui/lib/es/form'
import useSWRMutation from 'swr/mutation'
import { useBiliUsers } from '../lib/use-streamers'
import QRcode from '@/app/ui/QRcode'
import { useWindowSize } from 'react-use';
import { useWindowWidth } from '../lib/useIsMobile';
type UserListProps = {
onCancel?: (e: React.MouseEvent<Element, MouseEvent> | React.KeyboardEvent<Element>) => void
@@ -35,7 +35,7 @@ const UserList: React.FC<UserListProps> = ({ onCancel, visible }) => {
const { biliUsers: list } = useBiliUsers()
const [modalVisible, setVisible] = useState(false)
const [confirmLoading, setConfirmLoading] = useState(false)
const { width } = useWindowSize()
const width = useWindowWidth()
const showDialog = () => {
setVisible(true)
}
@@ -74,10 +74,9 @@ const UserList: React.FC<UserListProps> = ({ onCancel, visible }) => {
}
const handleCancel = () => {
setVisible(false)
console.log('Cancel button clicked')
}
const handleAfterClose = () => {
console.log('After Close callback executed')
// noop
}
const updateList = async (id: number) => {
try {
@@ -125,7 +124,7 @@ const UserList: React.FC<UserListProps> = ({ onCancel, visible }) => {
<Button
onClick={showDialog}
icon={<IconPlusCircle size="large" />}
style={{ marginRight: 4, backgroundColor: 'rgba(var(--semi-indigo-0), 1)' }}
style={{ marginRight: 4 }}
>
新增
</Button>
+53
View File
@@ -0,0 +1,53 @@
/* 共享页面容器与数据卡片 —— 所有页面统一使用,保证视觉一致 */
.content {
padding: 24px 28px 48px;
width: 100%;
box-sizing: border-box;
max-width: 1280px;
margin: 0 auto;
}
/* 数据展示卡片(表格 / JSON 树 / 日志等内容的容器) */
.card {
background: var(--semi-color-bg-1);
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
overflow: hidden;
}
/* KPI 概览条(与主页 overview 同款) */
.kpiStrip {
display: flex;
flex-wrap: wrap;
gap: 26px;
align-items: center;
padding: 13px 20px;
margin-bottom: 24px;
background: var(--semi-color-bg-1);
border: 1px solid var(--semi-color-fill-2);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(var(--semi-grey-9), 0.04), 0 2px 6px rgba(var(--semi-grey-9), 0.03);
}
.kpiItem {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--semi-color-text-2);
}
.kpiItem b {
font-size: 15px;
font-weight: 650;
color: var(--semi-color-text-0);
font-variant-numeric: tabular-nums;
}
@media (max-width: 880px) {
.content {
padding: 18px 16px 36px;
}
}
+77
View File
@@ -0,0 +1,77 @@
/* 最近事件时间线 */
.timeline {
position: relative;
padding-left: 22px;
}
.timeline::before {
content: '';
position: absolute;
left: 6px;
top: 4px;
bottom: 4px;
width: 2px;
background: var(--semi-color-fill-1);
border-radius: 2px;
}
.item {
position: relative;
padding: 5px 0 12px;
display: flex;
gap: 10px;
align-items: baseline;
}
.item::before {
content: '';
position: absolute;
left: -20px;
top: 9px;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--semi-color-bg-1);
border: 2px solid var(--semi-color-text-2);
}
.item.start::before {
border-color: rgb(var(--semi-green-5));
}
.item.file::before {
border-color: rgb(var(--semi-blue-4));
}
.time {
font-size: 12px;
color: var(--semi-color-text-2);
width: 46px;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.text {
font-size: 13px;
color: var(--semi-color-text-1);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag {
margin-left: auto;
font-size: 11px;
color: var(--semi-color-text-2);
white-space: nowrap;
flex-shrink: 0;
}
.empty {
padding: 28px 20px;
text-align: center;
font-size: 13px;
color: var(--semi-color-text-2);
}
+53 -89
View File
@@ -29,16 +29,16 @@ const Bilibili: React.FC<Props> = props => {
field="bili_qn"
extraText={
<div style={{ fontSize: '14px' }}>
哔哩哔哩自选画质。默认原画。
录制画质,默认原画。
<br />
刚开播如果无选择的画质,会先录制原画, 后续视频分段时,如果下载插件为非
stream-gears,会切换到选择的画质。
刚开播时通常只有原画,会先录原画;下载插件为 ffmpeg / streamlink
时之后每次分段会重新取流并切到所选画质,stream-gears / mesio 整场沿用首次取到的流。
<br />
如果选择的画质没有提供,会使用次档画质中最接近的画质,免登录原画会使用最高画质。
所选画质不存在时录 B 站返回的最接近的次档画质;开启「免登录原画」时录最高画质。
</div>
}
label="画质等级(bili_qn)"
placeholder="10000(原画)"
placeholder="25000(原画)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
@@ -64,7 +64,7 @@ const Bilibili: React.FC<Props> = props => {
</Form.Select>
<Form.Switch
field="bilibili_danmaku"
extraText="录制哔哩哔哩弹幕,目前不支持视频按时长分段下的弹幕文件自动分段。仅限下载插件为非 stream-gears 时生效,默认关闭。"
extraText="录制哔哩哔哩弹幕,默认关闭。弹幕保存为与录像同名的 XML 文件,并随录像分段一起分段。"
label="录制弹幕(bilibili_danmaku)"
fieldStyle={{
alignSelf: 'stretch',
@@ -73,7 +73,13 @@ const Bilibili: React.FC<Props> = props => {
/>
<Form.Switch
field="bilibili_danmaku_detail"
extraText="录制的弹幕信息中包含发送者昵称、用户UID,同时保存醒目留言、上舰、礼物信息。仅 bilibili_danmaku 开启时生效,默认关闭(实验性质:可能与弹幕转ass工具不兼容)"
extraText={
<div style={{ fontSize: '14px' }}>
弹幕文件中额外记录发送者昵称、UID,并保存醒目留言、上舰、礼物记录。默认关闭。
<br />
需先开启「录制弹幕」。<strong>实验性功能</strong>:可能与弹幕转 ASS 工具不兼容。
</div>
}
label="录制详细弹幕(bilibili_danmaku_detail)"
fieldStyle={{
alignSelf: 'stretch',
@@ -82,7 +88,14 @@ const Bilibili: React.FC<Props> = props => {
/>
<Form.Switch
field="bilibili_danmaku_raw"
extraText="录制B站服务器返回的原始弹幕信息,方便有技术能力的用户对主播弹幕数据进行统计。仅 bilibili_danmaku 开启时生效,默认关闭,开启后弹幕文件会每隔5分钟写入一次(实验性质:可能导致弹幕文件巨大)"
extraText={
<div style={{ fontSize: '14px' }}>
额外保存 B 站服务器返回的原始弹幕数据,供有技术能力的用户做统计分析。默认关闭。
<br />
需先开启「录制弹幕」。<strong>实验性功能</strong>:开启后弹幕文件每 5
分钟才写入一次,且体积可能非常大。
</div>
}
label="录制完整弹幕(bilibili_danmaku_raw)"
fieldStyle={{
alignSelf: 'stretch',
@@ -92,17 +105,18 @@ const Bilibili: React.FC<Props> = props => {
<Form.Input
field="user.bili_cookie"
extraText={
<div className="semi-form-field-extra">
根据格式填入cookie。推荐使用「
<div style={{ fontSize: '14px' }}>
按占位符格式填入 B 站登录 Cookie,推荐使用「
<a
href="https://github.com/biliup/biliup-rs"
title="「biliup-rs」 Github 项目主页"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--semi-color-link)' }}
>
biliup-rs
</a>
」来获取。
<br />
」获取。未登录时部分直播间只能录到较低画质。
</div>
}
placeholder="SESSDATA=none;bili_jct=none;DedeUserID__ckMd5=none;DedeUserID=none;"
@@ -122,18 +136,19 @@ const Bilibili: React.FC<Props> = props => {
padding: 0,
}}
optionList={list}
extraText="只支持「biliup-rs」生成的文件。与 Cookie 文本 同时存在时,优先使用文件。"
extraText="从已登录的 B 站账号中选择,只支持「biliup-rs」生成的 Cookie 文件。与上方「Cookie 文本」同时填写时,以 Cookie 文本为准。"
showClear={true}
/>
<Form.Select
field="bili_protocol"
extraText={
<div style={{ fontSize: '14px' }}>
哔哩哔哩直播流协议。
直播流协议,默认 stream(FLV)。
<br />
该设置项遵循 hls_fmp4 转码等待时间(bili_hls_transcode_timeout)。
选 hls_fmp4 时,开播后会先等待下方「hls_fmp4 转码等待时间」,仍拿不到 fmp4 流则本场回退为
FLV。
<br />
stream-gears 尚未支持 hls_fmp4,请切换为 ffmpeg 或 streamlink 来录制。
<strong>stream-gears 不支持 hls_fmp4</strong>,需把下载插件改为 ffmpeg 或 streamlink。
</div>
}
label="直播流协议(bili_protocol)"
@@ -146,12 +161,11 @@ const Bilibili: React.FC<Props> = props => {
showClear={true}
>
<Select.Option value="stream">stream(flv,默认)</Select.Option>
{/* <Select.Option value="hls_ts">hls_ts</Select.Option> */}
<Select.Option value="hls_fmp4">hls_fmp4</Select.Option>
</Form.Select>
<Form.Input
field="bili_liveapi"
extraText="自定义哔哩哔哩直播主要 API,用于获取指定区域(大陆或海外)的直播流链接,默认使用官方 API。"
extraText="获取直播流地址时优先使用的 API,默认官方 API。填入反代地址可获取指定区域(大陆或海外)的直播流。"
label="哔哩哔哩直播主要API(bili_liveapi)"
style={{ width: '100%' }}
placeholder="https://api.live.bilibili.com"
@@ -172,13 +186,11 @@ const Bilibili: React.FC<Props> = props => {
field="bili_fallback_api"
extraText={
<div style={{ fontSize: '14px' }}>
上方的主要 API 不可用或受到区域限制时,回退使用的 API。默认使用官方 API。
上方主要 API 不可用、受区域限制或没有所选协议的流时,改用此 API 重新获取。默认官方 API。
<br />
海外机器玩法:哔哩哔哩直播API(bili_liveapi)设置为能获取大陆直播流的
API,并将哔哩哔哩直播回退API(bili_fallback_api)设置为官方
API,然后优选「fmp4」流并使用「streamlink」下载插件(downloader),
最后设置优选「cn-gotcha204,ov-gotcha05」两个节点。 这样大主播可以使用 cn204 的 fmp4
流稳定录制。
<strong>海外机器玩法</strong>:主要 API 填能取到大陆直播流的反代,回退 API 保持官方;直播流协议选
hls_fmp4,下载插件选 streamlink,直播 CDN 填 cn-gotcha204,ov-gotcha05。大主播即可走 cn204 的
fmp4 流稳定录制,没有 fmp4 流的小主播自动回退到 ov05 的 FLV 流。
</div>
}
label="哔哩哔哩直播回退API(bili_fallback_api)"
@@ -199,7 +211,7 @@ const Bilibili: React.FC<Props> = props => {
/>
<Form.TagInput
field="bili_cdn"
extraText="哔哩哔哩直播CDN,默认无。"
extraText="优先使用的 CDN 节点,默认不指定(使用 B 站返回的第一个节点)。可填多个,按先后顺序匹配;都匹配不上时仍用第一个节点。"
label="直播CDN(bili_cdn)"
placeholder="例: cn-gotcha204,ov-gotcha05。用英文逗号分隔以批量输入,失焦/Enter保存"
style={{ width: '100%' }}
@@ -220,7 +232,14 @@ const Bilibili: React.FC<Props> = props => {
/>
<Form.Switch
field="bili_cdn_fallback"
extraText="CDN 回退(Fallback),默认为关闭。例如海外机器优选 ov05 之后,如果 ov05 流一直无法下载,将会自动回退到 ov07 进行下载。仅限相同流协议。"
extraText={
<div style={{ fontSize: '14px' }}>
默认关闭。开启后会先探测选中的流地址能否下载,不可用时自动改用同一协议、同一画质下的其他 CDN
节点。
<br />
例:海外机器优选 ov-gotcha05,但该节点一直拉不到流,会自动回退到 ov-gotcha07。
</div>
}
label="CDN 回退(bili_cdn_fallback)"
fieldStyle={{
alignSelf: 'stretch',
@@ -229,49 +248,22 @@ const Bilibili: React.FC<Props> = props => {
/>
<Form.Switch
field="bili_anonymous_origin"
extraText="使用自定义API获取 master playlist 内的 hls_fmp4 原画流,无法录制特殊直播。默认关闭。"
extraText={
<div style={{ fontSize: '14px' }}>
默认关闭。开启后改从 master playlist 接口获取 hls_fmp4 原画流,<strong>不登录也能录到原画</strong>。
<br />
仅在直播流协议为 hls_fmp4 时生效;特殊类型直播间(如付费直播)未填 Cookie 时不走此通道,按普通方式取流。
</div>
}
label="免登录原画(bili_anonymous_origin)"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/>
{/* <Form.Switch
field="bili_ov2cn"
extraText={
<div style={{ fontSize: '14px' }}>
将海外cdn域名替换为大陆cdn域名,默认关闭。
<br />
例:将海外的 ov-gotcha07 替换为 cn-gotcha07,直播 cdn 处仍然填写 ov-gotcha07。
</div>
}
label="海外转大陆(bili_ov2cn)"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/>
<Form.Switch
field="bili_force_source"
extraText="移除streamName的二压小尾巴(仅限 hls_fmp4 流,且画质等级 bili_qn >= 10000),默认为关闭,不保证可用性。"
label="强制获取真原画(bili_force_source)"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/>
<Form.Switch
field="bili_normalize_cn204"
extraText="去除 cn-gotcha204 后面的小尾巴(-[1-4])"
label="标准化 CN204(bili_normalize_cn204)"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/> */}
<Form.InputNumber
field="bili_hls_transcode_timeout"
extraText="hls_fmp4 转码等待时间,超时后回退到 flv 流。默认 60 秒。"
extraText="直播流协议为 hls_fmp4 时,从 B 站记录的开播时间起最多等待这么多秒让 fmp4 流生成;超时仍没有则本场回退为 FLV 流。单位:秒,默认 60。"
label="hls_fmp4 转码等待时间(bili_hls_transcode_timeout)"
style={{ width: '100%' }}
placeholder="60"
@@ -281,34 +273,6 @@ const Bilibili: React.FC<Props> = props => {
}}
showClear={true}
/>
{/* <Form.TagInput
allowDuplicates={false}
addOnBlur={true}
separator=","
field="bili_replace_cn01"
extraText="该功能在 强制获取真原画 之前生效"
label="替换 CN01 sid (bili_replace_cn01)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
showClear={true}
placeholder="可用英文逗号分隔以批量输入 sid,失焦/Enter 保存"
// onChange={v => console.log(v)}
rules={[
{
validator: (rule, value) => {
value = value ?? []
return (
Array.isArray(value) &&
value.every(item => /^cn-[a-z]{2,6}-[a-z]{2,4}(-[0-9]{2}){2}$/.test(item))
)
},
message: '例: cn-hjlheb-cu-01-01,cn-tj-ct-01-01',
},
]}
/> */}
</Collapse.Panel>
</>
)
+1 -7
View File
@@ -25,13 +25,7 @@ const CC: React.FC<Props> = props => {
<Collapse.Panel header="CC" itemKey="cc">
<Form.Select
field="cc_protocol"
extraText={
<div style={{ fontSize: '14px' }}>
CC直播流协议。
<br />
如果录制经常分段,尝试切换。
</div>
}
extraText="CC 直播流协议,默认 hls。录制经常异常断开、分段过多时可尝试切换为 flv。"
label="直播流协议(cc_protocol)"
placeholder="hls(默认)"
style={{ width: '100%' }}
+5 -4
View File
@@ -16,10 +16,11 @@ const Cookie: React.FC<Props> = props => {
<Form.Input
field="user.kuaishou_cookie"
extraText={
<div className="semi-form-field-extra">
填入 Cookie 降低风控风险。 需要
client_key,kuaishou.live.bfb1s,kuaishou.live.web_st,kuaishou.live.web_ph,userId 的值,
请不要将所有 Cookie 填入。
<div style={{ fontSize: '14px' }}>
填入快手 Cookie 可降低被风控的概率。
<br />
只需 client_key、kuaishou.live.bfb1s、kuaishou.live.web_st、kuaishou.live.web_ph、userId
五项的值,<strong>不要把全部 Cookie 粘进来</strong>。
</div>
}
placeholder="client_key=none;kuaishou.live.bfb1s=none;kuaishou.live.web_st=none;kuaishou.live.web_ph=none;userId=none;"
+4 -18
View File
@@ -1,6 +1,7 @@
'use client'
import React from 'react'
import styles from '../../styles/dashboard.module.scss'
import SectionTitle from '../../(app)/components/SectionTitle'
import { Form, Select, useFormApi } from '@douyinfe/semi-ui'
import { IconSetting } from '@douyinfe/semi-icons'
@@ -23,24 +24,9 @@ const Developer: React.FC = () => {
return (
<>
<div className={styles.frameDeveloper}>
<div className={styles.frameInside}>
<div className={styles.group}>
<div className={styles.buttonOnlyIconSecond} />
<div
className={styles.lineStory}
style={{
color: 'var(--semi-color-bg-0)',
display: 'flex',
}}
>
<IconSetting size="small" />
</div>
</div>
<p className={styles.meegoSharedWebWorkIt}>开发者选项</p>
</div>
<SectionTitle icon={<IconSetting size="small" />} title="开发者选项" />
<Form.Select
label=" ds_update.log 日志输出等级(LOGGING.root.level, LOGGING.loggers.biliup.level)"
label="ds_update.log 日志输出等级(LOGGING.root.level, LOGGING.loggers.biliup.level)"
field="LOGGING.root.level"
placeholder={'INFO'}
style={{ width: '100%' }}
@@ -62,7 +48,7 @@ const Developer: React.FC = () => {
<Select.Option value="CRITICAL">CRITICAL</Select.Option>
</Form.Select>
<Form.Select
label=" 文件日志输出等级(download.log)"
label="文件日志输出等级(download.log)"
field="loggers_level"
placeholder={'INFO'}
style={{ width: '100%' }}
+18 -10
View File
@@ -27,10 +27,10 @@ const Douyin: React.FC<Props> = props => {
field="douyin_quality"
extraText={
<div style={{ fontSize: '14px' }}>
抖音自选画质,没有选中的画质则会自动选择相近的画质优先低清晰度。
录制画质,默认原画。所选画质不存在时自动选最接近的一档,<strong>优先更低</strong>的清晰度。
<br />
刚开播可能没有除了原画之外的画质,会先录制原画。当使用 ffmpeg 或 streamlink
时,后续视频分段将会录制设置的画质。
刚开播时可能只有原画,会先录原画;下载插件为 ffmpeg / streamlink
时之后每次分段会重新取流并切到所选画质,stream-gears / mesio 整场沿用首次取到的流。
</div>
}
label="画质等级(douyin_quality)"
@@ -60,10 +60,10 @@ const Douyin: React.FC<Props> = props => {
<Form.Input
field="user.douyin_cookie"
extraText={
<div className="semi-form-field-extra">
如需要录制抖音 www.douyin.com/user/ 类型链接,或遭到风控,请在此填入 Cookie。
<div style={{ fontSize: '14px' }}>
录制 www.douyin.com/user/… 形式的主页链接,或遇到风控时,需要在此填入 Cookie。
<br />
需要__ac_nonce、__ac_signature、sessionid的值,请不要将所有 Cookie 填入。
只需 __ac_nonce、__ac_signature、sessionid 三项的值,<strong>不要把全部 Cookie 粘进来</strong>。
</div>
}
placeholder="__ac_nonce=none;__ac_signature=none;sessionid=none;"
@@ -93,10 +93,11 @@ const Douyin: React.FC<Props> = props => {
field="douyin_double_screen"
extraText={
<div style={{ fontSize: '14px' }}>
是否录制抖音双屏直播的原像素拼接流,默认关闭。
主播开双屏直播时录哪一路拼接流,默认关闭。
<br />
关闭时录制 横像素不变的 缩放拼接流,可能存在画质损失;开启时录制 纵像素不变的 raw
双屏拼接流。
关闭:录横向像素不变的缩放拼接流,画面被压缩,可能有画质损失。
<br />
开启:录纵向像素不变的原始(raw)双屏拼接流。
</div>
}
label="双屏直播录制方式(douyin_double_screen)"
@@ -107,7 +108,14 @@ const Douyin: React.FC<Props> = props => {
/>
<Form.Switch
field="douyin_true_origin"
extraText="仅限直播流协议为 FLV 时生效,默认关闭。开启后可能录制到 HEVC 编码,而 stream-gears(默认下载器)暂不支持,请切换下载器后录制。"
extraText={
<div style={{ fontSize: '14px' }}>
默认关闭。开启后录制抖音的真原画流,<strong>仅在画质为原画、直播流协议为 flv 时生效</strong>。
<br />
真原画可能是 HEVC 编码,默认下载插件 stream-gears 不支持 HEVC 会导致录制失败,请先把下载插件换成
ffmpeg 或 streamlink。
</div>
}
label="抖音真原画(douyin_true_origin)"
fieldStyle={{
alignSelf: 'stretch',
+12 -6
View File
@@ -27,8 +27,15 @@ const Douyu: React.FC<Props> = props => {
allowCreate={true}
filter
field="douyu_rate"
extraText="刚开播可能没有除了原画之外的画质 会先录制原画 后续视频分段(仅ffmpeg streamlink)时录制设置的画质
0 原画,8 蓝光8M,4 蓝光4m,3 超清,2 高清"
extraText={
<div style={{ fontSize: '14px' }}>
录制画质,默认 0(最高画质)。可选:0 最高画质 / 8 蓝光 8M / 4 蓝光 4M / 3 超清 / 2
高清,也可手动输入其他数值。
<br />
刚开播时可能只有原画,会先录原画;下载插件为 ffmpeg / streamlink
时之后每次分段会重新取流并切到所选画质,stream-gears / mesio 整场沿用首次取到的流。
</div>
}
label="画质等级(douyu_rate)"
style={{ width: '100%' }}
fieldStyle={{
@@ -62,8 +69,7 @@ const Douyu: React.FC<Props> = props => {
allowCreate={true}
filter
field="douyu_cdn"
extraText="如遇到斗鱼录制卡顿可以尝试切换线路。可选以下线路
tctc-h5(线路4), tct-h5(线路5), ali-h5(线路6), hw-h5(线路7), hs-h5(线路13)"
extraText="录制卡顿时可尝试切换线路,默认 hw-h5(线路 7)。可选:tctc-h5(线路 4)、tct-h5(线路 5)、ali-h5(线路 6)、hw-h5(线路 7)、hs-h5(线路 13)。"
label="访问线路(douyu_cdn)"
style={{ width: '100%' }}
fieldStyle={{
@@ -80,7 +86,7 @@ tctc-h5(线路4), tct-h5(线路5), ali-h5(线路6), hw-h5(线路7
</Form.Select>
<Form.Switch
field="douyu_force_hs"
extraText="强制 hs 流使用构造链接,防止部分海外机器遇到频繁断流。使用时需将 douyu_cdn 设置为 hs-h5。"
extraText="默认关闭。开启后 hs 线路改用程序自行构造的流地址,可缓解部分海外机器频繁断流的问题。仅在访问线路(douyu_cdn)为 hs-h5 时生效。"
label="强制 hs 流(douyu_force_hs)"
fieldStyle={{
alignSelf: 'stretch',
@@ -89,7 +95,7 @@ tctc-h5(线路4), tct-h5(线路5), ali-h5(线路6), hw-h5(线路7
/>
<Form.Switch
field="douyu_disable_interactive_game"
extraText="当主播运行了互动游戏,下个分段拒绝录制。小窗运行互动游戏也算入在内,请谨慎开启。"
extraText="默认关闭。开启后,检测到主播正在运行互动游戏时按未开播处理,不会开始或继续录制。小窗运行互动游戏也算在内,请谨慎开启。"
label="斗鱼拒绝互动游戏(douyu_disable_interactive_game)"
fieldStyle={{
alignSelf: 'stretch',
+38 -43
View File
@@ -1,31 +1,20 @@
'use client'
import React, { useEffect } from 'react'
import React from 'react'
import styles from '../../styles/dashboard.module.scss'
import { Form, Select, Space, useFormApi } from '@douyinfe/semi-ui'
import SectionTitle from '../../(app)/components/SectionTitle'
import { Form, Select, Space, useFormState } from '@douyinfe/semi-ui'
import { IconUpload, IconDownload } from '@douyinfe/semi-icons'
const Global: React.FC = () => {
const formApi = useFormApi()
// useFormApi 不订阅表单值变化,切换下拉框后条件渲染不会刷新;useFormState 会
const { values } = useFormState()
const isSyncDownloader = values?.downloader === 'sync-downloader'
return (
<>
{/* 全局下载 */}
<div className={styles.frameDownload}>
<div className={styles.frameInside}>
<div className={styles.group}>
<div className={styles.buttonOnlyIconSecond} />
<div
className={styles.lineStory}
style={{
color: 'var(--semi-color-bg-0)',
display: 'flex',
}}
>
<IconDownload size="small" />
</div>
</div>
<p className={styles.meegoSharedWebWorkIt}>全局下载设置</p>
</div>
<SectionTitle icon={<IconDownload size="small" />} title="全局下载设置" />
<Form.Select
label="下载插件(downloader)"
field="downloader"
@@ -33,20 +22,41 @@ const Global: React.FC = () => {
// initValue="stream-gears"
extraText={
<div style={{ fontSize: '14px' }}>
选择全局默认的下载插件, 可选:
全局默认的下载插件,可在单个主播的覆写设置里另选。可选:
<br />
1. streamlink(仅限 hls 流,不支持的流将回退到 ffmpeg。非 Docker 用户需自行安装 FFmpeg)
1. <strong>stream-gears</strong>(默认):内置,无需额外安装,可防 FLV 流花屏;不支持 HEVC 编码和
hls_fmp4 流。
<br />
2. ffmpeg(非 Docker 用户需自行安装 FFmpeg)
2. ffmpeg:非 Docker 用户需自行安装 FFmpeg。
<br />
3. stream-gears(默认。防 FLV 流花屏)
3. streamlink:多线程下载 HLS 分片,也可下载 FLV 直链;需系统中有 streamlink 命令。
<br />
4. sync-downloader(流式边录边传,需先为主播设定上传模板。不受
pool2/threads/segment_time 控制,默认 3 线程上传,请确保上传带宽充足。非 Docker 用户需自行安装 FFmpeg)详见 Wiki <a href="https://github.com/biliup/biliup/wiki/%E8%BE%B9%E5%BD%95%E8%BE%B9%E4%BC%A0%E5%8A%9F%E8%83%BD" target="_blank" rel="noopener noreferrer" >点击查看</a>
4. sync-downloader(边录边传):录制的同时流式上传,<strong>需先为主播设置上传模板</strong>;不受
pool2 / threads / segment_time 控制,固定 3 线程上传,请确保上传带宽充足;非 Docker 用户需自行安装
FFmpeg。详见 Wiki{' '}
<a
href="https://github.com/biliup/biliup/wiki/%E8%BE%B9%E5%BD%95%E8%BE%B9%E4%BC%A0%E5%8A%9F%E8%83%BD"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--semi-color-link)' }}
>
边录边传功能
</a>
。
<br />
5. ytarchive(仅适用于 Youtube Live)
5. ytarchive:仅适用于 YouTube 直播。
<br />
6. mesio(内置 rust-srec 引擎,进程内下载 FLV/HLS 并修复时间戳、注入关键帧索引,支持按大小/时长分段,无需额外安装)详见 <a href="https://github.com/hua0512/rust-srec" target="_blank" rel="noopener noreferrer" >项目主页</a>
6. mesio:内置 rust-srec 引擎,进程内下载 FLV / HLS 并修复时间戳、注入关键帧索引,支持按大小 /
时长分段,无需额外安装。详见{' '}
<a
href="https://github.com/hua0512/rust-srec"
target="_blank"
rel="noopener noreferrer"
style={{ color: 'var(--semi-color-link)' }}
>
项目主页
</a>
。
</div>
}
style={{ width: '100%' }}
@@ -63,7 +73,7 @@ const Global: React.FC = () => {
<Select.Option value="ytarchive">ytarchive(仅适用于 Youtube Live)</Select.Option>
<Select.Option value="mesio">mesio(内置流修复下载器)</Select.Option>
</Form.Select>
{formApi.getValue('downloader') === 'sync-downloader' ? (
{isSyncDownloader ? (
<>
<Form.Input
field="sync_save_dir"
@@ -75,7 +85,6 @@ const Global: React.FC = () => {
padding: 0,
}}
showClear={true}
disabled={formApi.getValue('downloader') === 'sync-downloader' ? false : true}
rules={[
{
pattern: /^[^*|?"<>]*$/,
@@ -265,21 +274,7 @@ const Global: React.FC = () => {
{/* 全局上传 */}
<div className={styles.frameUpload}>
<div className={styles.frameInside}>
<div className={styles.group}>
<div className={styles.buttonOnlyIconSecond} />
<div
className={styles.lineStory}
style={{
color: 'var(--semi-color-bg-0)',
display: 'flex',
}}
>
<IconUpload size="small" />
</div>
</div>
<p className={styles.meegoSharedWebWorkIt}>全局上传设置</p>
</div>
<SectionTitle icon={<IconUpload size="small" />} title="全局上传设置" />
<Form.Select
field="submit_api"
+23 -16
View File
@@ -27,10 +27,15 @@ const Huya: React.FC<Props> = props => {
allowCreate={true}
filter
field="huya_max_ratio"
extraText="虎牙自选录制码率
可以避免录制如20M的码率,每小时8G左右大小,上传及转码耗时过长。
20000(蓝光20M), 10000(蓝光10M), 8000(蓝光8M), 2000(超清), 500(流畅)
设置为10000则录制小于等于蓝光10M的画质"
extraText={
<div style={{ fontSize: '14px' }}>
录制码率上限,默认 0 表示不限制、录原画。
<br />
设为 10000 则录制码率不超过 10000 的最高一档画质,可避免录 20M 码率原画时每小时约 8 GB、上传和转码过慢的问题。
<br />
参考:20000 蓝光 20M / 10000 蓝光 10M / 8000 蓝光 8M / 2000 超清 / 500 流畅。
</div>
}
label="画质等级(huya_max_ratio)"
style={{ width: '100%' }}
fieldStyle={{
@@ -63,13 +68,12 @@ const Huya: React.FC<Props> = props => {
field="huya_cdn"
extraText={
<div style={{ fontSize: '14px' }}>
如遇到虎牙录制卡顿可以尝试切换线路。可选以下线路
录制卡顿时可尝试切换线路,默认不指定(使用虎牙返回的首个可用线路)。
<br />
AL(阿里云 - 直播线路3), TX(腾讯云 - 直播线路5), HW(华为云 - 直播线路6),
WS(网宿), HS(火山引擎 - 直播线路14), AL13(阿里云), TX15(腾讯云),
HW16(华为云)
可选:AL(阿里云,线路 3)、TX(腾讯云,线路 5)、HW(华为云,线路 6)、WS(网宿)、HS(火山引擎,线路
14)、AL13(阿里云)、TX15(腾讯云)、HW16(华为云)。
<br />
HY、HYZJ(虎牙自建 - 直播线路66) 已屏蔽。如设置,将切换为首个可用线路。
HY、HYZJ(虎牙自建,线路 66)已被屏蔽,填了也会改用首个可用线路。
</div>
}
label="访问线路(huya_cdn)"
@@ -82,16 +86,13 @@ const Huya: React.FC<Props> = props => {
>
<Select.Option value="AL">直播线路3(AL)</Select.Option>
<Select.Option value="TX">直播线路5(TX)</Select.Option>
{/* <Select.Option value="HW">直播线路6(HW)</Select.Option> */}
{/* <Select.Option value="WS">网宿(WS)</Select.Option> */}
<Select.Option value="AL13">直播线路13(AL13)</Select.Option>
<Select.Option value="HS">直播线路14(HS)</Select.Option>
<Select.Option value="TX15">直播线路15(TX15)</Select.Option>
{/* <Select.Option value="HW16">直播线路16(HW16)</Select.Option> */}
</Form.Select>
<Form.Switch
field="huya_cdn_fallback"
extraText="当访问线路(huya_cdn)不可用时,尝试其他线路(huya_cdn_fallback)"
extraText="默认关闭。开启后会先探测所选访问线路(huya_cdn)能否拉流,不可用时按顺序尝试其他线路。"
label="CDN 回退(huya_cdn_fallback)"
/>
<Form.Select
@@ -110,7 +111,13 @@ const Huya: React.FC<Props> = props => {
</Form.Select>
<Form.Switch
field="huya_imgplus"
extraText="是否录制二次编码的直播流。默认为启用,关闭后可能无法下载。部分直播间的分辨率超分(如2k/4k)和HDR画质依赖于二次编码,请谨慎关闭。"
extraText={
<div style={{ fontSize: '14px' }}>
是否录制虎牙二次编码后的直播流,默认开启。
<br />
关闭后改取未二次编码的流,部分直播间可能无法下载;2K / 4K 超分和 HDR 画质都依赖二次编码,<strong>请谨慎关闭</strong>。
</div>
}
label="虎牙二次编码(huya_imgplus)"
initValue={entity?.hasOwnProperty('huya_imgplus') ? entity['huya_imgplus'] : true}
fieldStyle={{
@@ -120,7 +127,7 @@ const Huya: React.FC<Props> = props => {
/>
<Form.Switch
field="huya_mobile_api"
extraText="移动端 API 请求直播间信息,可能解决部分直播分区 2 分钟分段问题"
extraText="默认关闭。开启后改用移动端 API 请求直播间信息,可能解决部分直播分区每 2 分钟分段的问题。"
label="使用移动端 API(huya_mobile_api)"
fieldStyle={{
alignSelf: 'stretch',
@@ -129,7 +136,7 @@ const Huya: React.FC<Props> = props => {
/>
<Form.Switch
field="huya_use_wup"
extraText="使用 WUP 协议获取直播流,可能解决部分直播分区 2 分钟分段问题"
extraText="使用 WUP 协议获取直播流,可能解决部分直播分区每 2 分钟分段的问题。"
label="使用 WUP 协议(huya_use_wup)"
initValue={entity?.hasOwnProperty('huya_use_wup') ? entity['huya_use_wup'] : true}
fieldStyle={{
+18
View File
@@ -1,3 +1,4 @@
import type React from 'react'
import Bilibili from './bilibili'
import CC from './cc'
import Cookie from './cookie'
@@ -37,6 +38,23 @@ const plugins = {
Youtube,
}
/**
* 空间配置「平台设置」左栏的展示顺序与文案。
* key 必须与各插件 Collapse.Panel 的 itemKey 一致;新增插件时在这里登记一行即可出现在 UI 里。
*/
export const PlatformPanels: { key: string; name: string; Component: React.FC<any> }[] = [
{ key: 'bilibili', name: '哔哩哔哩', Component: Bilibili },
{ key: 'cc', name: 'CC', Component: CC },
{ key: 'douyin', name: '抖音', Component: Douyin },
{ key: 'douyu', name: '斗鱼', Component: Douyu },
{ key: 'huya', name: '虎牙', Component: Huya },
{ key: 'kilakila', name: '克拉克拉', Component: Kilakila },
{ key: 'twitcasting', name: 'TwitCasting', Component: Twitcasting },
{ key: 'twitch', name: 'Twitch', Component: Twitch },
{ key: 'youtube', name: 'YouTube', Component: Youtube },
{ key: 'user', name: '用户 Cookie', Component: Cookie },
]
const SupportedPlatforms = {
'https?:\/\/(b23\.tv|live\.bilibili\.com)': Bilibili,
'https?:\/\/(cc\.163\.com)': CC,
+2 -2
View File
@@ -25,8 +25,8 @@ const Kilakila: React.FC<Props> = props => {
<Collapse.Panel header="克拉克拉" itemKey="kilakila">
<Form.Select
field="kila_protocol"
extraText="直播流协议,默认hls"
label="直播流协议(kila_protocol)"
extraText="直播流协议,默认 hls"
label="直播流协议(kila_protocol)"
placeholder="hls"
style={{ width: '100%' }}
fieldStyle={{
+7 -11
View File
@@ -25,11 +25,7 @@ const TwitCasting: React.FC<Props> = props => {
<Collapse.Panel header="TwitCasting" itemKey="twitcasting">
<Form.Select
field="twitcasting_quality"
extraText={
<div style={{ fontSize: '14px' }}>
TwitCasting自选画质,没有选中的画质则会自动选择更低级别清晰度,如更低级别画质依旧没有则选择最清晰的。
</div>
}
extraText="录制画质,默认取最高可用画质。所选画质不存在时自动降到更低一档;更低的也没有时取最高可用画质。"
label="画质等级(twitcasting_quality)"
style={{ width: '100%' }}
fieldStyle={{
@@ -44,20 +40,19 @@ const TwitCasting: React.FC<Props> = props => {
</Form.Select>
<Form.Switch
field="twitcasting_danmaku"
extraText="录制TwitCasting弹幕,默认关闭"
extraText="录制 TwitCasting 弹幕,默认关闭"
label="录制弹幕(twitcasting_danmaku)"
fieldStyle={{
alignSelf: 'stretch',
padding: 0,
}}
/>
<Form.Input
<Form.Input
field="user.twitcasting_cookie"
extraText={
<div className="semi-form-field-extra">
Cookie格式:
<br />
<code style={{ color: 'blue' }}>tc_id=xxxxxxx; tc_ss=xxxxxxx;</code>
<div style={{ fontSize: '14px' }}>
TwitCasting 登录 Cookie,可选。格式:
<code style={{ color: 'var(--semi-color-primary)' }}>tc_id=xxxxxxx; tc_ss=xxxxxxx;</code>
</div>
}
label="TwitCasting Cookie(twitcasting_cookie)"
@@ -69,6 +64,7 @@ const TwitCasting: React.FC<Props> = props => {
/>
<Form.Input
field="twitcasting_password"
extraText="直播间设有观看密码时填写,未设密码留空。"
label="TwitCasting直播间密码(twitcasting_password)"
style={{ width: '100%' }}
fieldStyle={{
+21 -15
View File
@@ -25,7 +25,7 @@ const Twitch: React.FC<Props> = props => {
<Collapse.Panel header="Twitch" itemKey="twitch">
<Form.Switch
field="twitch_danmaku"
extraText="录制Twitch弹幕,默认关闭"
extraText="录制 Twitch 弹幕,默认关闭"
label="录制弹幕(twitch_danmaku)"
/>
<Form.Switch
@@ -33,9 +33,17 @@ const Twitch: React.FC<Props> = props => {
entity?.hasOwnProperty('twitch_disable_ads') ? entity['twitch_disable_ads'] : true
}
field="twitch_disable_ads"
extraText="去除Twitch广告功能,默认开启
这个功能会导致Twitch录播分段,因为遇到广告就自动断开了,这就是去广告。若需要录播完整一整段可以关闭这个,但是关了之后就会有紫色屏幕的CommercialTime
还有一个办法是去花钱开一个Turbo会员,然后下面的user里把twitch的cookie填上,也能去除广告"
extraText={
<div style={{ fontSize: '14px' }}>
默认开启。去广告的原理是遇到广告就断开重连,因此<strong>录像会在每次广告处分段</strong>。
<br />
关闭后录像不再因广告分段,但广告时段会录成紫色的「Commercial Time」画面。
<br />
更好的办法是开通 Twitch Turbo 会员并在下方填入 Twitch Cookie,可直接免广告。
<br />
<strong>仅下载插件为 streamlink 或 ffmpeg 时生效</strong>;默认的 stream-gears 直接拉流,此开关不起作用。
</div>
}
label="去除广告(twitch_disable_ads)"
fieldStyle={{
alignSelf: 'stretch',
@@ -45,21 +53,19 @@ const Twitch: React.FC<Props> = props => {
<Form.Input
field="user.twitch_cookie"
extraText={
<div className="semi-form-field-extra">
【仅限Turbo会员】如录制Twitch时遇见视频流中广告过多的情况,可尝试在此填入cookie,可以大幅减少视频流中的twitch广告
<div style={{ fontSize: '14px' }}>
<strong>仅限 Twitch Turbo 会员</strong>:填入后可大幅减少录像中的广告。
<br />
该 Cookie 存在过期风险,Cookie过期后会在日志输出警告,请注意及时更换。
此处填的是 auth-token 的值,不是整段 Cookie。获取方式:浏览器打开 twitch.tv,按 F12
打开控制台,执行:
<br />
当 Cookie 失效,录制时将忽略 Cookie。(经作者个人测试,可保持未过期状态四个月以上)
<code style={{ color: 'var(--semi-color-primary)' }}>
{`document.cookie.split("; ").find(item => item.startsWith("auth-token="))?.split("=")[1]`}
</code>
<br />
twitch_cookie 获取方式:在浏览器中打开 twitch.tv , F12
调出控制台,在控制台中执行如下代码:
该值会过期(作者实测可用四个月以上);失效后日志会输出警告并忽略它继续录制,请及时更换。
<br />
<code
style={{ color: 'blue' }}
>{`document.cookie.split("; ").find(item => item.startsWith("auth-token="))?.split("=")[1]`}</code>
<br />
twitch_cookie&nbsp;需要在&nbsp;downloader= &quot;ffmpeg&quot;&nbsp;时候才会生效。
<strong>仅下载插件为 streamlink 或 ffmpeg 时生效</strong>。
</div>
}
label="Twitch Cookie(twitch_cookie)"
+51 -37
View File
@@ -27,10 +27,9 @@ const YouTube: React.FC<Props> = props => {
field="user.youtube_cookie"
extraText={
<div style={{ fontSize: '14px' }}>
使用Cookies登陆YouTube帐号,可用于下载会限、私享等未登录账号无法访问的内容。
填 Cookie <strong>文件的路径</strong>(不是 Cookie 内容),用于以登录状态下载会员限定、私享等未登录无法访问的内容。
<br />
<style></style>可以使用 Chrome 插件「Get cookies.txt」来生成txt文件,请使用 Netscape
格式的 Cookies 文本路径。
文件需为 Netscape 格式的 cookies.txt,可用 Chrome 插件「Get cookies.txt」导出。
</div>
}
label="YouTube Cookie(youtube_cookie)"
@@ -47,13 +46,15 @@ const YouTube: React.FC<Props> = props => {
: true
}
field="youtube_enable_download_live"
extraText="### 是否下载直播 默认开启
关闭后将忽略直播下载(可以下载回放) 避免网络被风控(有些网络只能下载回放无法下载直播)的时候还会尝试下载直播
大量下载时极易风控 如对实时性要求不高推荐关闭
一个人同时开启多个直播只能录制最新录制的那个
如果正在录制直播将无法下载回放
例如录制https://www.youtube.com/@NeneAmanoCh/streams,关闭后将忽略正在直播
"
extraText={
<div style={{ fontSize: '14px' }}>
是否录制正在进行的直播,默认开启。
<br />
关闭后跳过正在直播的条目,只下载回放和普通视频。有些网络环境只能下回放、拉直播会被风控,且大量下载时直播极易触发风控——<strong>对实时性要求不高时建议关闭</strong>。
<br />
同一频道同时开多场直播时只录最新的一场;正在录直播时不会同时下载回放。
</div>
}
label="下载直播(youtube_enable_download_live)"
fieldStyle={{
alignSelf: 'stretch',
@@ -67,11 +68,13 @@ const YouTube: React.FC<Props> = props => {
: true
}
field="youtube_enable_download_playback"
extraText="是否下载直播回放 默认开启
关闭后将忽略直播下载回放(不会影响正常的视频下载) 只想录制直播的可以开启
如果正在下载回放将无法录制直播
例如录制https://www.youtube.com/@NeneAmanoCh/streams,关闭后将忽略直播回放
"
extraText={
<div style={{ fontSize: '14px' }}>
是否下载直播回放,默认开启。
<br />
关闭后跳过直播回放,只录直播和下载普通视频;只想录直播的可以关闭。正在下载回放时不会同时录直播。
</div>
}
label="下载回放(youtube_enable_download_playback)"
fieldStyle={{
alignSelf: 'stretch',
@@ -80,7 +83,7 @@ const YouTube: React.FC<Props> = props => {
/>
<Form.Input
field="youtube_after_date"
extraText="仅下载该日期之后的视频,默认不限制"
extraText="只下载发布日期不早于该日的视频,默认不限制。格式 YYYYMMDD,如 20220201。"
label="下载起始日期(youtube_after_date)"
style={{ width: '100%' }}
fieldStyle={{
@@ -90,7 +93,7 @@ const YouTube: React.FC<Props> = props => {
/>
<Form.Input
field="youtube_before_date"
extraText="仅下载该日期之前的视频(可与上面的youtube_after_date配合使用,构成指定下载范围区间)默认不限制"
extraText="只下载发布日期不晚于该日的视频,默认不限制。格式 YYYYMMDD,如 20230501;与「下载起始日期」一起用可限定一个日期区间。"
label="下载截止日期(youtube_before_date)"
style={{ width: '100%' }}
fieldStyle={{
@@ -100,11 +103,13 @@ const YouTube: React.FC<Props> = props => {
/>
<Form.Input
field="youtube_max_videosize"
extraText="限制单个视频的最大大小
默认不限制
直播无此功能
注意:此参数优先级高于分辨率设置,并且不包括音频部分的大小,仅仅只是视频部分的大小。
此功能在一部分视频上无法使用 推荐使用画质限制不开启此功能"
extraText={
<div style={{ fontSize: '14px' }}>
单个视频的大小上限,默认不限制;对直播无效。格式如 100M、5G、10G。
<br />
只计视频轨、不含音频;优先级高于下方「视频分辨率上限」。部分视频没有大小信息会导致匹配不到可用格式,<strong>推荐改用分辨率上限来控制画质</strong>。
</div>
}
label="视频大小上限(youtube_max_videosize)"
style={{ width: '100%' }}
fieldStyle={{
@@ -114,10 +119,7 @@ const YouTube: React.FC<Props> = props => {
/>
<Form.InputNumber
field="youtube_max_resolution"
extraText="设置偏好的YouTube下载最高纵向分辨率
默认不限制
可以用此限制画质
例如设置为1080最高只会下载1080P画质"
extraText="下载的最高纵向分辨率,默认不限制。例如填 1080,最高只下载 1080P。"
label="视频分辨率上限(youtube_max_resolution)"
style={{ width: '100%' }}
fieldStyle={{
@@ -127,7 +129,18 @@ const YouTube: React.FC<Props> = props => {
/>
<Form.Input
field="youtube_prefer_vcodec"
label="偏好视频封装格式(youtube_prefer_vcodec)"
extraText={
<div style={{ fontSize: '14px' }}>
只下载指定视频编码,默认不限制。可填 avc、vp9、av01,多个用 | 分隔(如
av01|vp9|avc),会在指定编码中自动选画质最好的。
<br />
avc 最高约 1080p;vp9 最高 4K、少数 8K;av01 不是所有视频都有,但多数 8K 视频只有 av01。
<br />
需要安装 FFmpeg 合并音视频。无特殊需求不建议筛选,尤其录直播时多数 mp4 格式不可用;B 站支持
mp4 / mkv / webm,不筛选也能正常上传。
</div>
}
label="偏好视频编码(youtube_prefer_vcodec)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
@@ -136,16 +149,17 @@ const YouTube: React.FC<Props> = props => {
/>
<Form.Input
field="youtube_prefer_acodec"
extraText="设置偏好的YouTube下载封装格式
默认不限制
请务必记得安装ffmpeg
如无特殊需求不建议筛选封装格式 特别是录制直播时 多数直播mp4都是不可用的
bilibili支持 mp4 mkv webm 无需筛选也能上传
支持同时添加多个编码,自动优选指定编码格式里最好的画质/音质版本。
视频:其中avc编码最高可以下载到1080p的内容,vp9最高可以下载到4k以及很少部分8k内容,av01画质不是所有视频都有,但是大部分8k视频的8k画质只有av01编码。
音频:其中opus编码最高48KHz采样,mp4a(AAC)最高44.1KHz采样,理论上来说opus音质会更好一些。
如需指定封装格式,请按以下推荐设置。mp4:avc+mp4a;av01+mp4a. mkv:vp9+mp4a,avc+opus. webm:av01+opus;vp9+opus."
label="偏好音频封装格式(youtube_prefer_acodec)"
extraText={
<div style={{ fontSize: '14px' }}>
只下载指定音频编码,默认不限制。可填 opus、mp4a,多个用 | 分隔(如 opus|mp4a)。
<br />
opus 最高 48 kHz 采样,mp4a(AAC)最高 44.1 kHz,理论上 opus 音质更好。
<br />
想得到特定封装格式时按此搭配视频 / 音频编码:mp4 → avc+mp4a 或 av01+mp4a;mkv → vp9+mp4a 或
avc+opus;webm → av01+opus 或 vp9+opus。
</div>
}
label="偏好音频编码(youtube_prefer_acodec)"
style={{ width: '100%' }}
fieldStyle={{
alignSelf: 'stretch',
+228
View File
@@ -0,0 +1,228 @@
/* 全局统一直播间卡片 —— 主页 / 直播管理共用 */
.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;
}
/* ===== 名称 + 时长 ===== */
.nameRow {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 3px;
}
.name {
font-size: 15px;
font-weight: 650;
color: var(--semi-color-text-0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 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;
}
/* 未开播:最近录制时间 */
.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;
}
}
+1 -1
View File
@@ -11,4 +11,4 @@ $ zola serve
```shell
$ cd mysite
$ zola build
```
```
+1 -269
View File
@@ -16,7 +16,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-json-tree": "^0.19.0",
"react-use": "^17.6.0",
"swr": "^2.3.0"
},
"devDependencies": {
@@ -431,12 +430,6 @@
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@mdx-js/mdx": {
"version": "3.1.1",
"resolved": "https://registry.npmmirror.com/@mdx-js/mdx/-/mdx-3.1.1.tgz",
@@ -1296,12 +1289,6 @@
"@types/unist": "*"
}
},
"node_modules/@types/js-cookie": {
"version": "2.2.7",
"resolved": "https://registry.npmmirror.com/@types/js-cookie/-/js-cookie-2.2.7.tgz",
"integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==",
"license": "MIT"
},
"node_modules/@types/json5": {
"version": "0.0.29",
"resolved": "https://registry.npmmirror.com/@types/json5/-/json5-0.0.29.tgz",
@@ -1949,12 +1936,6 @@
"win32"
]
},
"node_modules/@xobotyi/scrollbar-width": {
"version": "1.9.5",
"resolved": "https://registry.npmmirror.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
"integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==",
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.15.0",
"resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.15.0.tgz",
@@ -2601,15 +2582,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"license": "MIT",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/crelt/-/crelt-1.0.6.tgz",
@@ -2631,37 +2603,6 @@
"node": ">= 8"
}
},
"node_modules/css-in-js-utils": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz",
"integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==",
"license": "MIT",
"dependencies": {
"hyphenate-style-name": "^1.0.3"
}
},
"node_modules/css-tree": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-1.1.3.tgz",
"integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
"license": "MIT",
"dependencies": {
"mdn-data": "2.0.14",
"source-map": "^0.6.1"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/css-tree/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -2917,15 +2858,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/error-stack-parser": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz",
"integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==",
"license": "MIT",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/es-abstract": {
"version": "1.24.1",
"resolved": "https://registry.npmmirror.com/es-abstract/-/es-abstract-1.24.1.tgz",
@@ -3721,6 +3653,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-equals": {
@@ -3746,17 +3679,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/fast-shallow-equal": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
"integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
},
"node_modules/fastest-stable-stringify": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz",
"integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==",
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.19.1",
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.19.1.tgz",
@@ -4277,12 +4199,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/hyphenate-style-name": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz",
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz",
@@ -4352,15 +4268,6 @@
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/inline-style-prefixer": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz",
"integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==",
"license": "MIT",
"dependencies": {
"css-in-js-utils": "^3.1.0"
}
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4912,12 +4819,6 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==",
"license": "MIT"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5473,12 +5374,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
"node_modules/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/mdurl/-/mdurl-2.0.0.tgz",
@@ -6272,26 +6167,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nano-css": {
"version": "5.6.2",
"resolved": "https://registry.npmmirror.com/nano-css/-/nano-css-5.6.2.tgz",
"integrity": "sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==",
"license": "Unlicense",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"css-tree": "^1.1.2",
"csstype": "^3.1.2",
"fastest-stable-stringify": "^2.0.2",
"inline-style-prefixer": "^7.0.1",
"rtl-css-js": "^1.16.1",
"stacktrace-js": "^2.0.2",
"stylis": "^4.3.0"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
@@ -7134,41 +7009,6 @@
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-universal-interface": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
"integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
"peerDependencies": {
"react": "*",
"tslib": "*"
}
},
"node_modules/react-use": {
"version": "17.6.0",
"resolved": "https://registry.npmmirror.com/react-use/-/react-use-17.6.0.tgz",
"integrity": "sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g==",
"license": "Unlicense",
"dependencies": {
"@types/js-cookie": "^2.2.6",
"@xobotyi/scrollbar-width": "^1.9.5",
"copy-to-clipboard": "^3.3.1",
"fast-deep-equal": "^3.1.3",
"fast-shallow-equal": "^1.0.0",
"js-cookie": "^2.2.1",
"nano-css": "^5.6.2",
"react-universal-interface": "^0.6.2",
"resize-observer-polyfill": "^1.5.1",
"screenfull": "^5.1.0",
"set-harmonic-interval": "^1.0.1",
"throttle-debounce": "^3.0.1",
"ts-easing": "^0.2.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"react": "*",
"react-dom": "*"
}
},
"node_modules/react-window": {
"version": "1.8.11",
"resolved": "https://registry.npmmirror.com/react-window/-/react-window-1.8.11.tgz",
@@ -7406,12 +7246,6 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.11",
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.11.tgz",
@@ -7509,15 +7343,6 @@
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/rtl-css-js": {
"version": "1.16.1",
"resolved": "https://registry.npmmirror.com/rtl-css-js/-/rtl-css-js-1.16.1.tgz",
"integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.1.2"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -7627,18 +7452,6 @@
"loose-envify": "^1.1.0"
}
},
"node_modules/screenfull": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/screenfull/-/screenfull-5.2.0.tgz",
"integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
@@ -7695,15 +7508,6 @@
"node": ">= 0.4"
}
},
"node_modules/set-harmonic-interval": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz",
"integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==",
"license": "Unlicense",
"engines": {
"node": ">=6.9"
}
},
"node_modules/set-proto": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/set-proto/-/set-proto-1.0.0.tgz",
@@ -7875,51 +7679,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/stack-generator": {
"version": "2.0.10",
"resolved": "https://registry.npmmirror.com/stack-generator/-/stack-generator-2.0.10.tgz",
"integrity": "sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==",
"license": "MIT",
"dependencies": {
"stackframe": "^1.3.4"
}
},
"node_modules/stackframe": {
"version": "1.3.4",
"resolved": "https://registry.npmmirror.com/stackframe/-/stackframe-1.3.4.tgz",
"integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==",
"license": "MIT"
},
"node_modules/stacktrace-gps": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/stacktrace-gps/-/stacktrace-gps-3.1.2.tgz",
"integrity": "sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==",
"license": "MIT",
"dependencies": {
"source-map": "0.5.6",
"stackframe": "^1.3.4"
}
},
"node_modules/stacktrace-gps/node_modules/source-map": {
"version": "0.5.6",
"resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.5.6.tgz",
"integrity": "sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/stacktrace-js": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
"integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
"license": "MIT",
"dependencies": {
"error-stack-parser": "^2.0.6",
"stack-generator": "^2.0.5",
"stacktrace-gps": "^3.0.4"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -8230,12 +7989,6 @@
}
}
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz",
@@ -8282,15 +8035,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/throttle-debounce": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz",
"integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -8353,12 +8097,6 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmmirror.com/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -8392,12 +8130,6 @@
"typescript": ">=4.8.4"
}
},
"node_modules/ts-easing": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/ts-easing/-/ts-easing-0.2.0.tgz",
"integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==",
"license": "Unlicense"
},
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmmirror.com/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
+1 -2
View File
@@ -17,7 +17,6 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-json-tree": "^0.19.0",
"react-use": "^17.6.0",
"swr": "^2.3.0"
},
"devDependencies": {
@@ -31,4 +30,4 @@
"sass": "^1.87.0",
"typescript": "^5.7.2"
}
}
}