feat: improve replay workflow and viewer UI
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
28 files changed
+1496
-227
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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%; }
|
||||
|
||||
@@ -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 }}>
|
||||
创建节目
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user