完善节目管理与回放播放体验
This commit is contained in:
1 parent
b265518084
commit
a493ef2309
14 files changed
+446
-96
No files matched your search
@@ -13,8 +13,8 @@ const { Text } = Typography
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '直播列表' },
|
||||
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建直播' },
|
||||
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
|
||||
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
|
||||
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
|
||||
{ key: '/admin/settings', icon: <SettingOutlined />, label: '系统设置' },
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@ export default function Dashboard() {
|
||||
useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, [])
|
||||
|
||||
async function loadData() {
|
||||
try { setStreams(await fetchStreams()) } catch { /* */ }
|
||||
try { setStreams(await fetchStreams(true)) } catch { /* */ }
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function Dashboard() {
|
||||
border: '1px solid #EBE5DB',
|
||||
borderTop: '3px solid #5B8C5A',
|
||||
}}>
|
||||
<Statistic title="直播中" value={liveCount} loading={loading}
|
||||
<Statistic title="播出中" value={liveCount} loading={loading}
|
||||
prefix={<SignalFilled style={{ color: '#5B8C5A', fontSize: 20 }} />} />
|
||||
</div>
|
||||
</Col>
|
||||
@@ -71,7 +71,7 @@ export default function Dashboard() {
|
||||
border: '1px solid #EBE5DB',
|
||||
borderTop: '3px solid #C2693A',
|
||||
}}>
|
||||
<Statistic title="直播总数" value={streams.length} loading={loading}
|
||||
<Statistic title="节目总数" value={streams.length} loading={loading}
|
||||
prefix={<PlayCircleOutlined style={{ color: '#C2693A', fontSize: 20 }} />} />
|
||||
</div>
|
||||
</Col>
|
||||
@@ -82,10 +82,10 @@ export default function Dashboard() {
|
||||
border: '1px solid #EBE5DB',
|
||||
padding: '28px 32px',
|
||||
}}>
|
||||
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近直播</Title>
|
||||
<Title level={4} style={{ margin: '0 0 20px', fontWeight: 600 }}>最近节目</Title>
|
||||
<Table dataSource={streams.slice(0, 10)} columns={columns}
|
||||
rowKey="id" loading={loading} pagination={false} size="middle"
|
||||
locale={{ emptyText: '暂无直播数据' }} />
|
||||
locale={{ emptyText: '暂无节目数据' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd'
|
||||
import { Form, Input, Button, DatePicker, Typography, message, Space, Alert, Select, Switch } from 'antd'
|
||||
import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons'
|
||||
import { createStream } from '../../api/streams'
|
||||
import { createStream, fetchMediaFiles, type Chapter } from '../../api/streams'
|
||||
import axios from 'axios'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
@@ -10,13 +10,29 @@ const { TextArea } = Input
|
||||
|
||||
export default function StreamForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [mediaFiles, setMediaFiles] = useState<string[]>([])
|
||||
const [chapters, setChapters] = useState<Chapter[]>([])
|
||||
const [result, setResult] = useState<{ rtmp_url: string; stream_key: string } | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
fetchMediaFiles().then(setMediaFiles).catch(() => message.warning('无法读取服务器回放目录'))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await createStream({ title: values.title, description: values.description, cover_url: values.cover_url, scheduled_at: values.scheduled_at?.toISOString() })
|
||||
const res = await createStream({
|
||||
title: values.title,
|
||||
description: values.description,
|
||||
cover_url: values.cover_url,
|
||||
scheduled_at: values.scheduled_at?.toISOString(),
|
||||
replay_file: values.replay_file,
|
||||
chapters: chapters.filter(chapter => chapter.title.trim() && chapter.time >= 0),
|
||||
transcode_profile: values.transcode_profile,
|
||||
homepage_visible: values.homepage_visible,
|
||||
remote_control_enabled: values.remote_control_enabled,
|
||||
})
|
||||
setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key })
|
||||
message.success('直播已创建')
|
||||
} catch (error) {
|
||||
@@ -75,7 +91,7 @@ export default function StreamForm() {
|
||||
填写直播信息,系统将生成推流码与 RTMP 地址
|
||||
</Text>
|
||||
|
||||
<Form layout="vertical" onFinish={handleSubmit} size="large">
|
||||
<Form layout="vertical" onFinish={handleSubmit} size="large" initialValues={{ transcode_profile: 'standard', homepage_visible: true, remote_control_enabled: false }}>
|
||||
<Form.Item name="title" label="直播标题"
|
||||
rules={[{ required: true, message: '请输入直播标题' }]}>
|
||||
<Input placeholder="输入直播标题" style={{ borderRadius: 8 }} />
|
||||
@@ -89,6 +105,30 @@ export default function StreamForm() {
|
||||
<Form.Item name="scheduled_at" label="预定开播时间">
|
||||
<DatePicker showTime style={{ width: '100%', borderRadius: 8 }} placeholder="选择日期(可选)" />
|
||||
</Form.Item>
|
||||
<Form.Item name="replay_file" label="服务器回放文件" extra="只能选择已配置媒体目录中的文件">
|
||||
<Select allowClear placeholder="不使用回放" options={mediaFiles.map(file => ({ value: file, label: file }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="回放章节与时间点">
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
{chapters.map((chapter, index) => (
|
||||
<Space key={index} style={{ width: '100%' }}>
|
||||
<Input value={chapter.title} placeholder="章节名称" onChange={event => setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, title: event.target.value } : item))} />
|
||||
<Input type="number" min={0} value={chapter.time} addonAfter="秒" onChange={event => setChapters(current => current.map((item, itemIndex) => itemIndex === index ? { ...item, time: Number(event.target.value) } : item))} />
|
||||
<Button danger onClick={() => setChapters(current => current.filter((_, itemIndex) => itemIndex !== index))}>删除</Button>
|
||||
</Space>
|
||||
))}
|
||||
<Button onClick={() => setChapters(current => [...current, { title: '', time: 0 }])}>添加章节</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="transcode_profile" label="转码配置">
|
||||
<Select options={[{ value: 'standard', label: '标准多档' }, { value: 'bandwidth-saver', label: '带宽节省' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="homepage_visible" label="首页公开" valuePropName="checked">
|
||||
<Switch checkedChildren="公开" unCheckedChildren="仅私有链接" />
|
||||
</Form.Item>
|
||||
<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' }} />
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 48, borderRadius: 8 }}>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function StreamList() {
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true)
|
||||
try { setStreams(await fetchStreams()) } catch { message.error('加载失败') }
|
||||
try { setStreams(await fetchStreams(true)) } catch { message.error('加载失败') }
|
||||
setLoading(false)
|
||||
}
|
||||
async function handleDelete(id: number) {
|
||||
@@ -49,7 +49,7 @@ export default function StreamList() {
|
||||
<Space size={4}>
|
||||
<Tooltip title="复制推流码">
|
||||
<Button size="small" icon={<LinkOutlined />}
|
||||
onClick={() => { navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
|
||||
onClick={() => { if (r.stream_key) navigator.clipboard.writeText(r.stream_key); message.success('已复制') }} />
|
||||
</Tooltip>
|
||||
{r.status !== 'live' ? (
|
||||
<Tooltip title="开播"><Button size="small" type="primary" ghost icon={<PlayCircleOutlined />} onClick={() => handleGoLive(r.id)} /></Tooltip>
|
||||
@@ -69,11 +69,11 @@ export default function StreamList() {
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 28 }}>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>直播列表</Title>
|
||||
<Text style={{ color: '#949088', fontSize: 14 }}>管理所有直播流</Text>
|
||||
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28, letterSpacing: '-0.02em' }}>节目列表</Title>
|
||||
<Text style={{ color: '#949088', fontSize: 14 }}>管理直播、排期与回放节目</Text>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} size="large" onClick={() => navigate('/admin/streams/new')}>
|
||||
创建直播
|
||||
创建节目
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{
|
||||
@@ -82,7 +82,7 @@ export default function StreamList() {
|
||||
}}>
|
||||
<Table dataSource={streams} columns={columns} rowKey="id" loading={loading}
|
||||
pagination={{ pageSize: 15, showSizeChanger: false }} size="middle"
|
||||
locale={{ emptyText: '暂无直播,创建第一条直播吧' }} />
|
||||
locale={{ emptyText: '暂无节目,创建第一个节目吧' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user