diff --git a/.gitignore b/.gitignore index 4390830..69af76a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ biliup/**/public !package-lock.json qrcode.png *.log +*.tsbuildinfo /*.ts /biliup/stream-gears/ data/ diff --git a/app/(app)/bg-global.css b/app/(app)/bg-global.css new file mode 100644 index 0000000..2ecdcf5 --- /dev/null +++ b/app/(app)/bg-global.css @@ -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; +} diff --git a/app/(app)/changelog/changelog.module.scss b/app/(app)/changelog/changelog.module.scss new file mode 100644 index 0000000..0614206 --- /dev/null +++ b/app/(app)/changelog/changelog.module.scss @@ -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); +} diff --git a/app/(app)/changelog/page.tsx b/app/(app)/changelog/page.tsx new file mode 100644 index 0000000..def8422 --- /dev/null +++ b/app/(app)/changelog/page.tsx @@ -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 ? ( + {p} + ) : ( + {p} + ) + ) + }) + } + 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 ? ( + + {m[1]} + + ) : ( + {m[1]} + ) + ) + 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( + + ) + } + lines.forEach((line, idx) => { + const h = line.match(/^###\s+(.+)$/) + if (h) { + flush(`${versionKey}-${idx}`) + const isContributors = /New Contributors/i.test(h[1]) + blocks.push( +
+ {h[1]} +
+ ) + } else if (/^[-*]\s+/.test(line)) { + listBuf.push(line) + } else if (line.trim() === '') { + flush(`${versionKey}-${idx}`) + } else { + flush(`${versionKey}-${idx}`) + blocks.push( +
+ {renderInline(line, `${versionKey}-${idx}`)} +
+ ) + } + }) + 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 ( + <> +
+ } + title="更新日志" + description="biliup 版本更新记录" + /> +
+ +
+ {isLoading && ( +
+ +
+ )} + {error && ( + + )} + {!isLoading && !error && versions.length > 0 && ( +
+ {result?.stale && ( +
+ 当前展示的是离线缓存版本,联网后将自动更新。 +
+ )} + {runningIsNewer && ( +
+ 当前运行的 v{running} 尚未收录到更新日志,以下为已发布的历史版本。 +
+ )} + {versions.map((v, idx) => ( + +
+ + {v.version} + + {idx === 0 && !runningIsNewer && ( + + 最新 + + )} + {running && v.version === running && ( + + 当前运行 + + )} +
+ {v.compareUrl && ( + +
+ } + > + {renderBody(v.body, v.version)} + + ))} + + )} +
+
+ + ) +} diff --git a/app/(app)/components/BackgroundSetter.module.scss b/app/(app)/components/BackgroundSetter.module.scss new file mode 100644 index 0000000..31b8094 --- /dev/null +++ b/app/(app)/components/BackgroundSetter.module.scss @@ -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; +} diff --git a/app/(app)/components/BackgroundSetter.tsx b/app/(app)/components/BackgroundSetter.tsx new file mode 100644 index 0000000..c347e83 --- /dev/null +++ b/app/(app)/components/BackgroundSetter.tsx @@ -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 { + 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(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) => { + 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 ( +
+ + + {open && ( + <> +
+ +
+ +
+
+ 遮罩透明度 + {Math.round(opacity * 100)}% +
+ `${v}%`} + /> +
+ + )} + + +
+ ) +} diff --git a/app/(app)/components/PageHeader.module.scss b/app/(app)/components/PageHeader.module.scss new file mode 100644 index 0000000..101d6bd --- /dev/null +++ b/app/(app)/components/PageHeader.module.scss @@ -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; +} diff --git a/app/(app)/components/PageHeader.tsx b/app/(app)/components/PageHeader.tsx new file mode 100644 index 0000000..220824e --- /dev/null +++ b/app/(app)/components/PageHeader.tsx @@ -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 + +/** + * 统一的页面头部范式:标题 + 描述 + 右侧操作区。 + * 所有业务页面都应以 开头,保证全局一致。 + */ +export default function PageHeader({ + title, + description, + icon, + actions, +}: { + title: ReactNode + description?: ReactNode + icon?: ReactNode + actions?: ReactNode +}) { + return ( +
+ {icon ?
{icon}
: null} +
+ + {title} + + {description ? ( + + {description} + + ) : null} +
+ {actions ?
{actions}
: null} +
+ ) +} diff --git a/app/(app)/components/SectionTitle.module.scss b/app/(app)/components/SectionTitle.module.scss new file mode 100644 index 0000000..38f991a --- /dev/null +++ b/app/(app)/components/SectionTitle.module.scss @@ -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; +} diff --git a/app/(app)/components/SectionTitle.tsx b/app/(app)/components/SectionTitle.tsx new file mode 100644 index 0000000..1544364 --- /dev/null +++ b/app/(app)/components/SectionTitle.tsx @@ -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 ( +
+ {icon} + {title} +
+ ) +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx index f195e48..3fb99b8 100644 --- a/app/(app)/dashboard/page.tsx +++ b/app/(app)/dashboard/page.tsx @@ -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() // 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 ( <> -
- -
- + } + title="空间配置" + description="全局下载 / 上传参数、各平台录制参数与开发者选项。修改后需点击右上角「保存」才会生效" + actions={ + + } + /> +
@@ -165,43 +141,43 @@ const Dashboard: React.FC = () => { {/* 全局设置 */} - - {/* 各平台下载 */} + + {/* 平台设置:左列平台名 + 右栏选中表单 */}
-
-
-
-
} title="平台设置" /> +
+
-
-
-

各平台下载设置

-
- - {Object.entries(plugins) - .filter(([key]) => key !== 'Cookie') - .map(([key, Plugin]) => ( - + {p.name} + ))} - - + +
+ {/* 所有平台组件保持挂载(keepDOM),仅展开当前平台面板。 + 卸载会注销 Semi Form 字段状态,提交时仅剩挂载字段;后端 PUT /configuration + 整表覆盖保存,会清空其他平台的参数与凭据 */} + + {PlatformPanels.map(p => ( + + ))} + +
+
@@ -215,7 +191,7 @@ const Dashboard: React.FC = () => {
- +
) } diff --git a/app/(app)/history/page.tsx b/app/(app)/history/page.tsx index fe5b020..c047d3e 100644 --- a/app/(app)/history/page.tsx +++ b/app/(app)/history/page.tsx @@ -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('/v1/videos', fetcher) +export default function History() { const { Text } = Typography + const { data: data, error, isLoading } = useSWR('/v1/videos', fetcher) const [fileName, setFileName] = useState() + const [visible, setVisible] = useState(false) + const columns = [ { title: '标题', dataIndex: 'name', - render: (text: any, record: any, index: any) => { - return {text} - }, - // onFilter: (value, record) => record.name.includes(value) + render: (text: any) => {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 ( - showDialog(record.name)} - /> - ) - }, + render: (text: any, record: any) => ( + showDialog(record.name)}> + 播放 + + ), }, ] - 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 ( <> -
- -
- -
- - + } + title="历史记录" + description="已录制的视频文件,可在线回放" + /> +
+
+
+ setVisible(false)} closeOnEsc={true} style={{ width: 'min(600px, 90vw)' }} size="large" bodyStyle={{ height: 500 }} footer={null} > - -
+ +
- +
) } diff --git a/app/(app)/job/Filter.tsx b/app/(app)/job/Filter.tsx index d7f6114..68ac5b9 100644 --- a/app/(app)/job/Filter.tsx +++ b/app/(app)/job/Filter.tsx @@ -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) => { diff --git a/app/(app)/job/page.tsx b/app/(app)/job/page.tsx index 1cef77b..031ce96 100644 --- a/app/(app)/job/page.tsx +++ b/app/(app)/job/page.tsx @@ -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('/v1/streamer-info', fetcher) if (isLoading) { - return + return ( + <> + } + title="直播历史" + description="按主播查看历史直播记录" + /> +
+ +
+ + ) } - 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} - }, + render: (text: any) => ( + + {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 ( <> -
- -
- -
+ } + title="直播历史" + description="按主播查看历史直播记录" + /> +
+
- - + + ) } - -// 创建一个子组件 +// 展开子行:该次直播的录制文件列表 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
加载中...
if (!files || files.length === 0) return
暂无文件
return ( - <> - 文件列表: - {files.map((it: any) => ( -
  文件名:{it.file}
- ))} - +
+ 文件列表: + {files.map((it: any) => ( +
+ {it.file} +
+ ))} +
) } -// 在 expandRowRender 中使用 const expandRowRender = (record: any) => { return } diff --git a/app/(app)/layout.module.scss b/app/(app)/layout.module.scss new file mode 100644 index 0000000..4872f25 --- /dev/null +++ b/app/(app)/layout.module.scss @@ -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; + } +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index ace5950..d5888d0 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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 ( + + ) +} + +const NAV_GROUPS: { title: string; items: { href: string; label: string; icon: ReactNode }[] }[] = [ + { + title: '总览', + items: [ + { + href: '/', + label: '控制台', + icon: , + }, + ], + }, + { + title: '录制', + items: [ + { + href: '/streamers', + label: '直播管理', + icon: , + }, + { + href: '/history', + label: '历史记录', + icon: , + }, + { + href: '/job', + label: '直播历史', + icon: , + }, + ], + }, + { + title: '投稿', + items: [ + { + href: '/upload-manager', + label: '投稿管理', + icon: , + }, + { + href: '/archives', + label: 'B站稿件', + icon: , + }, + ], + }, + { + title: '设置', + items: [ + { + href: '/dashboard', + label: '空间配置', + icon: , + }, + ], + }, + { + title: '系统', + items: [ + { + href: '/logviewer', + label: '实时日志', + icon: , + }, + { + href: '/status', + label: '任务平台', + icon: , + }, + ], + }, +] + +/* 服务状态:布局级轻量轮询,所有页面共享 */ +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('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([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: ( -
- -
- ), - }, - { - itemKey: 'manager', - text: '录播管理', - items: [ - { itemKey: 'streamers', text: '直播管理' }, - { itemKey: 'history', text: '历史记录' }, - ], - icon: ( -
- -
- ), - }, - { - itemKey: 'upload-manager', - text: '投稿管理', - icon: ( -
- -
- ), - }, - { - itemKey: 'archives', - text: 'B站稿件', - icon: ( -
- -
- ), - }, - { - itemKey: 'dashboard', - text: '空间配置', - icon: ( -
- -
- ), - }, - { - itemKey: 'job', - text: '直播历史', - icon: ( -
- -
- ), - }, - { - text: '实时日志', - icon: ( -
- -
- ), - itemKey: 'logViewer', - }, - { - text: '任务平台', - icon: ( -
- -
- ), - itemKey: 'status', - // items: [{itemKey: 'About', text: '任务管理'}, {itemKey: 'Dashboard', text: '用户任务查询'}], - }, - ].map((value: any) => { - value.text = ( -
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} -
+ const isActive = (href: string) => + href === '/' ? pathname === '/' : pathname === href || pathname.startsWith(href + '/') + + return ( +
+ {/* 移动端:汉堡按钮 + 遮罩(抽屉打开时隐藏汉堡,点击其位置即点遮罩关闭) */} + {isMobile && !mobileNavOpen && ( + + )} + {isMobile && mobileNavOpen && ( +
setMobileNavOpen(false)} /> + )} + + -function isSub(key1: string, key2: string | number) { - const routerMap: any = { - manager: ['streamers', 'history'], - } - return routerMap[key2]?.includes(key1) +
{children}
+
+ ) } diff --git a/app/(app)/logviewer/page.tsx b/app/(app)/logviewer/page.tsx index 2c8c5d9..cf1533b 100644 --- a/app/(app)/logviewer/page.tsx +++ b/app/(app)/logviewer/page.tsx @@ -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; - isLoading: boolean; + logs: string[] + logContainerRef: React.RefObject + 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) => ( -
{log}
+
+ {log} +
)) ) : ( -
+
暂无日志内容
)} @@ -82,30 +79,28 @@ const LogContent = ({ logs, logContainerRef, isLoading }: LogContentProps) => { } export default function LogViewer() { - const { Header, Content } = Layout const [logs, setLogs] = useState([]) const [isConnected, setIsConnected] = useState(false) const [isLoading, setIsLoading] = useState(true) const [activeTab, setActiveTab] = useState('ds_update') const wsRef = useRef(null) const logContainerRef = useRef(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 = ( + <> + + + + + {isConnected ? '● 已连接' : '○ 未连接'} + + + ) return ( <> -
- -
- - - - - - - - {isConnected ? '已连接' : '未连接'} - -
- } - > + } + title="实时日志" + description="主程序运行与下载上传日志,WebSocket 实时推送" + actions={actions} + /> +
+
+ - {/**/} - {/* */} - {/**/} - - +
+
) -} \ No newline at end of file +} diff --git a/app/(app)/page.module.css b/app/(app)/page.module.css deleted file mode 100644 index 306fe2e..0000000 --- a/app/(app)/page.module.css +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/app/(app)/page.module.scss b/app/(app)/page.module.scss new file mode 100644 index 0000000..642aead --- /dev/null +++ b/app/(app)/page.module.scss @@ -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; + } +} diff --git a/app/(app)/page.tsx b/app/(app)/page.tsx index 62d9ede..19cb02f 100644 --- a/app/(app)/page.tsx +++ b/app/(app)/page.tsx @@ -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 = () => ( - -) +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 ( +
+ {/* 顶栏:状态 + 文档 */} +
+ + + 文档 + + {versionText ? v{versionText} : null} + + + {d.connectError + ? '服务未连接' + : d.loading + ? '连接中…' + : `运行中 · ${d.recording} 路录制中`} + + +
+ + + + {d.loading ? ( +
+ +
+ ) : d.connectError ? ( +
+ 无法连接 biliup 后端,请确认服务正在运行且当前会话已登录。 +
+ ) : ( + <> + {d.streamersFailed && ( +
+ + 主播列表加载失败,监控数量与卡片暂不可用,请检查后端连接或稍后重试。 + +
+ )} + {d.infosFailed && ( +
+ 直播信息(标题 / 录制时长)加载失败,相关字段可能缺失。 +
+ )} + {d.videosFailed && ( +
+ 录制文件列表加载失败,文件总量与文件事件暂不可用。 +
+ )} + {d.statusFailed && ( +
+ 服务状态接口加载失败,版本号暂不可用。 +
+ )} + + {/* 概览条:全部来自真实聚合(无虚构指标) */} +
+ + {d.streamersFailed ? '—' : d.total} 个监控 + + + + {d.recording} 路录制中 + + + {d.pending} 待上传 + + + {d.uploading} 上传中 + + + 录制文件 {formatSize(d.totalSize)} + + + 今日新增 {formatSize(d.todaySize)} + +
+ + {/* 进行中 */} +
+
+ + 进行中{live.length} + + + 直播管理 → + +
+ {d.streamersFailed ? ( + 主播列表加载失败,无法显示卡片。 + ) : live.length === 0 ? ( + 当前没有正在录制的直播间。 + ) : ( +
+ {live.map((s) => ( + + ))} +
+ )} +
+ + {/* 未开播 */} +
+
+ + 未开播{offline.length} + +
+ {offline.length === 0 ? ( + 全部直播间均在录制中。 + ) : ( +
+ {offline.map((s) => ( + + ))} +
+ )} +
+ + {/* 最近事件:前端合成的 24h 活动流 */} +
+
+ 最近事件 + 最近 24h +
+
+ +
+
+ + )} +
+ ) +} diff --git a/app/(app)/status/page.tsx b/app/(app)/status/page.tsx index dbbe65e..1e4e1d3 100644 --- a/app/(app)/status/page.tsx +++ b/app/(app)/status/page.tsx @@ -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('/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('/v1/status', fetcher) if (isLoading) { - return + return ( + <> + } + title="任务平台" + description="系统运行状态(调试视图)" + /> +
+ +
+ + ) } + + const rooms = data?.rooms?.length ?? 0 + return ( <> -
- -
- -
- -
-
+ } + title="任务平台" + description="系统运行状态(调试视图)" + /> +
+ {/* 概览 */} +
+ + 版本 {data?.version ?? '—'} + + + 监控房间 {rooms} + + + 下载信号量 {data?.download_semaphore ?? '—'} + + + 上传任务 {data?.update_semaphore ?? '—'} + + {error ? ( + 状态获取失败,请检查后端连接 + ) : null} +
+ + {/* 原始状态(调试) */} +
+ + 原始 JSON(调试用) + +
+ +
+
+
) } diff --git a/app/(app)/streamers/page.module.scss b/app/(app)/streamers/page.module.scss new file mode 100644 index 0000000..dc9a658 --- /dev/null +++ b/app/(app)/streamers/page.module.scss @@ -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%; + } +} diff --git a/app/(app)/streamers/page.tsx b/app/(app)/streamers/page.tsx index 8a32cc5..58896c3 100644 --- a/app/(app)/streamers/page.tsx +++ b/app/(app)/streamers/page.tsx @@ -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( + '/v1/streamers', + fetcher, + { refreshInterval: STREAMERS_REFRESH_MS } + ) + const { data: infos } = useSWR('/v1/streamer-info', fetcher, { + refreshInterval: SLOW_REFRESH_MS, + }) + + // url -> 最新一次录制的直播标题 + const infoByUrl = useMemo(() => { + const map = new Map() + 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 = 直播中 - break - case 'Idle': - statusTag = 空闲 - break - case 'Pending': - statusTag = 检测中 - break - case 'OutOfSchedule': - statusTag = 非录播时间 - break - case 'TitleExcluded': - statusTag = 标题已排除 - break - case 'Pause': - statusTag = 暂停中 - 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: {e.message}, - 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: {e.message}, - 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>(new Set()) + + const platforms = useMemo(() => { + const set = new Set() + 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) => ( + <> + + + + } + /> + + {isLoading ? ( +
+
-
- - +
- - - -
- ( - - + {/* 工具栏 */} +
+
-
+ {/* 批量操作条 */} + {selected.size > 0 && ( +
+ + 已选 {selected.size} 项 + + + + + + + +
)} - /> -
+ + {filtered.length === 0 ? ( +
+ 0 ? '没有匹配的直播间' : '还没有监控任何直播间'} + description={ + streamers && streamers.length > 0 + ? '调整搜索或筛选条件试试' + : '点击右上角「新建」开始' + } + /> +
+ ) : layout === 'grid' ? ( +
+ {filtered.map((item) => ( + + ))} +
+ ) : ( +
+
+ + + + + + + + + + + + {filtered.map((item) => { + const info = infoByUrl.get(item.url) + return ( + toggleSel(item.id)} + > + + + + + + + + ) + })} + +
+ 0 && filtered.every((s) => selected.has(s.id))} + onChange={(e) => toggleAll(e.target.checked)} + aria-label="全选" + /> + 状态主播平台最近录制操作
e.stopPropagation()}> + toggleSel(item.id)} + aria-label={`选择 ${item.remark}`} + /> + {streamerStatusTag(item.status)} +
{item.remark || item.url}
+ {info?.title ? ( +
{info.title}
+ ) : null} +
+ + {platformName(item.url)} + + + + {info?.date ? timeAgo(info.date) : '—'} + + e.stopPropagation()}>{renderRowActions(item)}
+ + )} + + )} ) diff --git a/app/(app)/upload-manager/add/page.tsx b/app/(app)/upload-manager/add/page.tsx index 37749ab..f05ebd4 100644 --- a/app/(app)/upload-manager/add/page.tsx +++ b/app/(app)/upload-manager/add/page.tsx @@ -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: {e.message}, + style: { width: 'min-content' }, + }) + throw e + } + } + return ( <> -
- + } + title="新建投稿模板" + description="配置投稿模板,保存后可用于上传录制文件" + actions={ + + } + /> +
+
+
(api.current = formApi)} + labelWidth="180px" + labelPosition={labelPosition} + /> +
-
- { - 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: {e.message}, - // theme: 'light', - // duration: 0, - style: { width: 'min-content' }, - }) - throw e - } - }} - component={TemplateFields} - getFormApi={formApi => (api.current = formApi)} - labelWidth="180px" - labelPosition={labelPosition} - /> -
) } diff --git a/app/(app)/upload-manager/edit/page.tsx b/app/(app)/upload-manager/edit/page.tsx index bef98a7..18fe145 100644 --- a/app/(app)/upload-manager/edit/page.tsx +++ b/app/(app)/upload-manager/edit/page.tsx @@ -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
{error?.message}
+ // 分区树来自 /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 ( + <> + } + title="编辑投稿模板" + description="修改模板信息并保存" + /> +
+
+ {loadError} +
+
+ + ) + } if (isLoading) return
Loading...
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: {e.message}, + style: { width: 'min-content' }, + }) + } + } + return ( <> -
- -
- (api.current = formApi)} - autoScrollToError - component={TemplateFields} - labelWidth="180px" - labelPosition={labelPosition} + } + title="编辑投稿模板" + description="修改模板信息并保存" + actions={ + + } /> +
+
+ (api.current = formApi)} + labelWidth="180px" + labelPosition={labelPosition} + /> +
+
) } @@ -136,7 +173,7 @@ const Edit = () => { const EditTemplate: React.FC = () => { return ( - + ) } diff --git a/app/(app)/upload-manager/page.tsx b/app/(app)/upload-manager/page.tsx index 4ce02ba..b3e55df 100644 --- a/app/(app)/upload-manager/page.tsx +++ b/app/(app)/upload-manager/page.tsx @@ -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('/v1/upload/streamers', fetcher) + const { data: templates, error, isLoading } = useSWR( + '/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('/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 = ( + <> + + + + ) + return ( <> - + setVisibleModal(false)} + bodyStyle={{ overflow: 'auto' }} closeOnEsc={true} > -
- -
- + + } + title="投稿管理" + description="管理上传模板,选择录制文件一键投稿" + actions={actions} + /> +
( + loading={isLoading} + renderItem={(item: StudioEntity) => ( - - {item.template_name} - - } - /> - - + {/* 模板名称:优先完整展示,占满剩余宽度,超出才尾部省略 */} + + {item.template_name} + + + + onClick={() => router.push(`/upload-manager/edit?id=${item.id}`)} + /> await onConfirm(item.id)} - // onCancel={onCancel} > - +
) } diff --git a/app/(auth)/login/page.jsx b/app/(auth)/login/page.jsx index 83d0252..cda89f5 100644 --- a/app/(auth)/login/page.jsx +++ b/app/(auth)/login/page.jsx @@ -194,12 +194,12 @@ const Component = () => { {isRegisterMode ? '注册' : '登录'} {isRegisterMode && ( -
+
注册即表示同意 - 用户协议 + 用户协议 和 - 隐私政策 + 隐私政策
)} diff --git a/app/layout.tsx b/app/layout.tsx index 257dc29..c733a02 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,7 +4,25 @@ import './globals.css' export default function RootLayout({ children }: { children: React.ReactNode }) { return ( + + {/* 首屏前置设置主题,避免水合期从默认主题闪到已保存主题 */} +