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:
1 parent
4e156ac683
commit
f06efbb3b9
65 files changed
+4385
-2030
No files matched your search
@@ -38,6 +38,7 @@ biliup/**/public
|
||||
!package-lock.json
|
||||
qrcode.png
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
/*.ts
|
||||
/biliup/stream-gears/
|
||||
data/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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}> 文件名:{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} />
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 '直播源'
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}, [])
|
||||
}
|
||||
@@ -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
@@ -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])
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}}
|
||||
>
|
||||
确认
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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%' }}
|
||||
|
||||
@@ -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;"
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
@@ -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 需要在 downloader= "ffmpeg" 时候才会生效。
|
||||
<strong>仅下载插件为 streamlink 或 ffmpeg 时生效</strong>。
|
||||
</div>
|
||||
}
|
||||
label="Twitch Cookie(twitch_cookie)"
|
||||
|
||||
+51
-37
@@ -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',
|
||||
|
||||
@@ -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
@@ -11,4 +11,4 @@ $ zola serve
|
||||
```shell
|
||||
$ cd mysite
|
||||
$ zola build
|
||||
```
|
||||
```
|
||||
Generated
+1
-269
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user