feat: improve replay workflow and viewer UI

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
adminandClaude Fable 5 committed 2026-08-30 15:35:28 +08:00
1 parent 57b00affbd
commit 186e32caf1
28 files changed
+1496 -227

No files matched your search

+12 -21
View File
@@ -47,25 +47,20 @@ export default function AdminLayout() {
const handleLogout = () => { clearToken(); navigate('/admin/login') }
return (
<Layout style={{ minHeight: '100vh' }}>
<Layout className="admin-shell apple-material-shell">
<Sider
collapsible collapsed={collapsed} onCollapse={setCollapsed}
width={220}
style={{
borderRight: '1px solid #EBE5DB',
background: '#fff',
}}
className="admin-sider"
theme="dark"
>
<div style={{
height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderBottom: '1px solid #EBE5DB',
}}>
<div className="admin-brand">
<span className="admin-brand-mark">{brand.brand_name.slice(0, 1)}</span>
<Text strong style={{
fontSize: collapsed ? 14 : 17, letterSpacing: '-0.02em',
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
color: '#C2693A',
display: collapsed ? 'none' : 'block', fontSize: 16, letterSpacing: '-0.02em',
color: '#F4F7FB', whiteSpace: 'nowrap',
}}>
{collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name}
{brand.brand_name}
</Text>
</div>
<Menu mode="inline" selectedKeys={[selectedKey]} items={menuItems}
@@ -73,15 +68,11 @@ export default function AdminLayout() {
style={{ border: 'none', marginTop: 8 }} />
</Sider>
<Layout>
<Header style={{
background: '#fff', borderBottom: '1px solid #EBE5DB',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0 48px',
}}>
<Text style={{ color: '#949088', fontSize: 13 }}>{brand.brand_tagline}</Text>
<Header className="admin-header">
<div><Text strong style={{ color: '#F4F7FB' }}>直播控制中心</Text><Text style={{ color: '#748096', marginLeft: 12 }}>{brand.brand_tagline}</Text></div>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>退出登录</Button>
</Header>
<Content style={{ padding: 40, overflow: 'auto' }}>
<Content className="admin-content">
<div className="admin-page-transition" key={location.pathname}>
<Outlet />
</div>
@@ -89,4 +80,4 @@ export default function AdminLayout() {
</Layout>
</Layout>
)
}
}
+2 -2
View File
@@ -27,9 +27,9 @@ export default function LoginPage() {
}}>
<div style={{ width: '100%', maxWidth: 420, padding: 24 }}>
<div style={{
background: '#fff',
background: 'rgba(18, 24, 36, 0.88)',
borderRadius: 14,
border: '1px solid #EBE5DB',
border: '1px solid rgba(255, 255, 255, 0.1)',
padding: 56,
}}>
<div style={{ textAlign: 'center', marginBottom: 40 }}>
+16 -12
View File
@@ -36,19 +36,21 @@ export default function Dashboard() {
]
return (
<div>
<div style={{ marginBottom: 36 }}>
<div className="admin-page admin-dashboard">
<div className="admin-page-heading">
<Title level={1} style={{ margin: '0 0 8px', fontWeight: 600, fontSize: 32, letterSpacing: '-0.03em' }}>
{brand.brand_name}
</Title>
<Text style={{ color: '#5E5A52', fontSize: 15 }}>管理控制台</Text>
<Text className="admin-page-kicker">管理控制台 · 实时刷新</Text>
</div>
<Row gutter={[24, 24]} style={{ marginBottom: 36 }}>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #5B8C5A',
}}>
<Statistic title="播出中" value={liveCount} loading={loading}
@@ -57,8 +59,10 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #6B8CA8',
}}>
<Statistic title="待开播" value={pendingCount} loading={loading}
@@ -67,8 +71,10 @@ export default function Dashboard() {
</Col>
<Col xs={24} sm={8}>
<div style={{
background: '#fff', borderRadius: 12, padding: '28px 32px',
border: '1px solid #EBE5DB',
background: 'linear-gradient(145deg, rgba(26, 33, 47, 0.78), rgba(12, 17, 27, 0.72))', borderRadius: 18, padding: '28px 32px',
border: '1px solid rgba(255, 255, 255, 0.095)',
boxShadow: '0 18px 48px rgba(0, 0, 0, 0.26), inset 0 1px 0 rgba(255, 255, 255, 0.045)',
backdropFilter: 'blur(24px) saturate(135%)',
borderTop: '3px solid #C2693A',
}}>
<Statistic title="节目总数" value={streams.length} loading={loading}
@@ -77,9 +83,7 @@ export default function Dashboard() {
</Col>
</Row>
<div style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB',
<div className="admin-glass-panel admin-table-panel" style={{
padding: '28px 32px',
}}>
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
+5 -5
View File
@@ -89,24 +89,24 @@ export default function NodeManager() {
{strategy && (
<Row gutter={[24, 24]} style={{ marginBottom: 32 }}>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="均衡策略" value={sL[strategy.strategy] || strategy.strategy} valueStyle={{ fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="组播状态" value={strategy.multicast_available ? '可用' : '不可用'}
valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="在线节点" value={onlineCount} suffix={<Text style={{ fontSize: 14, color: '#949088' }}>/ {nodes.length}</Text>}
valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} />
</div>
</Col>
<Col xs={24} sm={6}>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '24px 28px' }}>
<div className="admin-glass-panel admin-stat-panel">
<Statistic title="离线节点" value={nodes.length - onlineCount}
valueStyle={{ color: (nodes.length - onlineCount) > 0 ? '#B54A4A' : '#949088', fontSize: 20 }}
prefix={(nodes.length - onlineCount) > 0 ? <WarningOutlined style={{ fontSize: 18 }} /> : undefined} />
@@ -115,7 +115,7 @@ export default function NodeManager() {
</Row>
)}
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: '8px 8px 0' }}>
<div className="admin-glass-panel admin-table-panel">
<div style={{ padding: '16px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text strong style={{ fontSize: 16 }}>节点列表</Text>
<Button icon={<ReloadOutlined />} onClick={loadData} type="text">刷新</Button>
+64 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { Alert, Button, Input, InputNumber, Select, Space, Spin, Typography, message } from 'antd'
import { Alert, Button, Input, InputNumber, Segmented, Select, Space, Spin, Typography, message } from 'antd'
import { ArrowDownOutlined, ArrowLeftOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons'
import { fetchMediaFiles, getStream, updateStream, type Chapter, type StreamInfo } from '../../api/streams'
@@ -12,7 +12,13 @@ export default function ReplayEditor() {
const videoRef = useRef<HTMLVideoElement>(null)
const [stream, setStream] = useState<StreamInfo | null>(null)
const [mediaFiles, setMediaFiles] = useState<string[]>([])
const [replaySource, setReplaySource] = useState<'local' | 'remote'>('local')
const [replayFile, setReplayFile] = useState<string>()
const [replayUrl, setReplayUrl] = useState('')
const [previewUrl, setPreviewUrl] = useState<string>()
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState('')
const previewObjectUrlRef = useRef<string>()
const [chapters, setChapters] = useState<Chapter[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -23,7 +29,9 @@ export default function ReplayEditor() {
Promise.all([getStream(streamId), fetchMediaFiles()])
.then(([program, files]) => {
setStream(program)
setReplayFile(program.replay_file)
setReplaySource(program.replay_url ? 'remote' : 'local')
setReplayFile(program.replay_file || undefined)
setReplayUrl(program.replay_url || '')
setChapters(program.chapters || [])
setMediaFiles(files)
})
@@ -31,6 +39,60 @@ export default function ReplayEditor() {
.finally(() => setLoading(false))
}, [id])
useEffect(() => {
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
setPreviewUrl(undefined)
setPreviewError('')
if (replaySource === 'local') {
setPreviewLoading(false)
if (stream && replayFile) {
setPreviewUrl(`/api/streams/${stream.id}/replay?preview=${encodeURIComponent(replayFile)}`)
}
return
}
const remoteUrl = replayUrl.trim()
if (!remoteUrl) {
setPreviewLoading(false)
return
}
const controller = new AbortController()
let disposed = false
setPreviewLoading(true)
void fetch(remoteUrl, { signal: controller.signal })
.then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.blob()
})
.then(blob => {
if (disposed) return
const objectUrl = URL.createObjectURL(blob)
previewObjectUrlRef.current = objectUrl
setPreviewUrl(objectUrl)
})
.catch(error => {
if (error instanceof DOMException && error.name === 'AbortError') return
if (!disposed) setPreviewError('远端回放拉取失败,请检查地址及其 CORS 配置')
})
.finally(() => {
if (!disposed) setPreviewLoading(false)
})
return () => {
disposed = true
controller.abort()
if (previewObjectUrlRef.current) {
URL.revokeObjectURL(previewObjectUrlRef.current)
previewObjectUrlRef.current = undefined
}
}
}, [replaySource, replayFile, replayUrl, stream])
const move = (index: number, offset: number) => {
setChapters(current => {
const target = index + offset
+1 -1
View File
@@ -1,5 +1,5 @@
.settings-page { width: 100%; max-width: 960px; min-width: 0; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: #fff; border: 1px solid #ebe5db; border-radius: 12px; }
.settings-card { margin-top: 20px; padding: 24px; overflow: hidden; background: linear-gradient(145deg, rgba(26, 33, 47, .78), rgba(12, 17, 27, .72)); border: 1px solid rgba(255, 255, 255, .095); border-radius: 18px; box-shadow: 0 18px 48px rgba(0, 0, 0, .26); backdrop-filter: blur(24px) saturate(135%); }
.profile-block { display: grid; gap: 12px; margin: 20px 0; min-width: 0; }
.quality-row { display: grid; grid-template-columns: minmax(130px, 1.4fr) repeat(2, minmax(115px, .8fr)) minmax(120px, 1fr) auto; gap: 10px; align-items: center; min-width: 0; }
.quality-row > * { min-width: 0; max-width: 100%; }
+3 -3
View File
@@ -45,7 +45,7 @@ export default function StreamForm() {
<Text style={{ color: '#5E5A52' }}>将以下信息提供给推流端即可开始直播</Text>
</div>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}>
<div className="admin-glass-panel admin-form-panel" style={{ marginBottom: 24 }}>
<Space direction="vertical" size={24} style={{ width: '100%' }}>
<div>
<Text style={{ color: '#949088', fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em', fontWeight: 600 }}>推流码</Text>
@@ -78,7 +78,7 @@ export default function StreamForm() {
<Button type="text" icon={<ArrowLeftOutlined />} onClick={() => navigate('/admin/streams')}
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
<div className="admin-glass-panel admin-form-panel">
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
@@ -138,7 +138,7 @@ export default function StreamForm() {
<Form.Item name="remote_control_enabled" label="允许审计式观众播放控制" valuePropName="checked" extra="默认关闭,仅在明确启用后允许创建限时控制会话">
<Switch checkedChildren="启用" unCheckedChildren="关闭" />
</Form.Item>
<div style={{ borderTop: '1px solid #EBE5DB', margin: '8px 0 24px' }} />
<div className="admin-section-divider" />
<Form.Item style={{ marginBottom: 0 }}>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
创建节目
+5 -5
View File
@@ -67,19 +67,19 @@ export default function StreamList() {
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
<div className="admin-page admin-stream-list">
<div className="admin-page-heading admin-page-heading--actions">
<div>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
<Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text>
<Text className="admin-page-kicker">管理直播、排期与回放节目</Text>
</div>
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
创建节目
</Button>
</div>
<div style={{
background: '#fff', borderRadius: 12,
border: '1px solid #EBE5DB', padding: '8px 8px 0',
background: 'rgba(18, 24, 36, 0.72)', borderRadius: 16,
border: '1px solid rgba(255, 255, 255, 0.095)', padding: '8px 8px 0',
}}>
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"