完善节目管理与回放播放体验

This commit is contained in:
admin committed 2026-08-17 09:59:04 +08:00
1 parent b265518084
commit a493ef2309
14 files changed
+446 -96

No files matched your search

+2 -2
View File
@@ -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: '系统设置' },
]
+5 -5
View File
@@ -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>
)
+45 -5
View File
@@ -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 }}>
+6 -6
View File
@@ -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>
)
+28 -3
View File
@@ -5,13 +5,23 @@ export interface StreamInfo {
title: string
description?: string
cover_url?: string
stream_key: string
stream_key?: string
status: string
scheduled_at?: string
started_at?: string
ended_at?: string
multicast_enabled: boolean
created_at: string
replay_file?: string
chapters: Chapter[]
transcode_profile: string
homepage_visible: boolean
remote_control_enabled: boolean
}
export interface Chapter {
title: string
time: number
}
export interface StreamCreateReq {
@@ -19,6 +29,11 @@ export interface StreamCreateReq {
description?: string
cover_url?: string
scheduled_at?: string
replay_file?: string
chapters?: Chapter[]
transcode_profile?: string
homepage_visible?: boolean
remote_control_enabled?: boolean
}
export interface WatchInfo {
@@ -29,6 +44,11 @@ export interface WatchInfo {
selected_node?: { id: string; name: string } | null
lines: LineInfo[]
qualities: QualityInfo[]
description?: string
scheduled_at?: string
replay_url?: string
chapters: Chapter[]
remote_control_enabled: boolean
}
export interface LineInfo {
@@ -54,8 +74,13 @@ export interface QualityInfo {
height: number
}
export async function fetchStreams(): Promise<StreamInfo[]> {
const { data } = await api.get('/streams')
export async function fetchStreams(admin = false): Promise<StreamInfo[]> {
const { data } = await api.get('/streams', { params: admin ? { admin: true } : undefined })
return data
}
export async function fetchMediaFiles(): Promise<string[]> {
const { data } = await api.get('/streams/media-files')
return data
}
+49 -30
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Select, Button, Typography, Space, Tag, Spin, Empty, message } from 'antd'
import { Select, Button, Typography, Tag, Spin, Empty, message } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
@@ -52,7 +52,14 @@ export default function WatchPage() {
}
useEffect(() => {
if (!info || !activeLine || !videoRef.current) return
if (!info || !videoRef.current) return
if (info.replay_url && info.status !== 'live') {
hlsRef.current?.destroy()
hlsRef.current = null
videoRef.current.src = info.replay_url
return
}
if (!activeLine) return
const line = info.lines.find(l => l.node_id === activeLine)
if (!line) return
const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0]
@@ -74,6 +81,12 @@ export default function WatchPage() {
}
}, [activeLine, activeQuality, info])
function jumpToChapter(time: number) {
if (!videoRef.current) return
videoRef.current.currentTime = time
videoRef.current.play().catch(() => {})
}
useEffect(() => { document.title = info ? `${info.title} - ${brand.brand_name}` : brand.brand_name }, [info, brand])
if (loading) return (
@@ -91,53 +104,59 @@ export default function WatchPage() {
return (
<div className="watch-shell">
{/* Top bar */}
<header className="watch-header">
<Space size={16}>
<div className="watch-heading">
<Button type="text" icon={<ArrowLeftOutlined />} style={{ color: '#949088' }}
onClick={() => navigate('/')}>{brand.brand_name}</Button>
<div style={{ width: 1, height: 18, background: 'rgba(235,229,219,0.1)' }} />
<Text style={{ color: '#EBE5DB', fontSize: 14, fontWeight: 500 }}>{info.title}</Text>
<span className="watch-divider" />
<Text className="watch-title">{info.title}</Text>
<Tag color={info.status === 'live' ? 'green' : 'default'} style={{ fontWeight: 600, fontSize: 11 }}>
{statusLabel}
</Tag>
</Space>
<Space size={16}>
</div>
<div className="watch-options">
<Tag style={{ margin: 0 }}>节点:{currentLine?.node_name || info.selected_node?.name || '不可用'}</Tag>
<Tag style={{ margin: 0 }}>清晰度:{activeQuality || '不可用'}</Tag>
<Text style={{ color: '#949088', fontSize: 13, display: 'flex', alignItems: 'center', gap: 6 }}>
<SignalFilled style={{ color: info.multicast_available ? '#5B8C5A' : '#5E5A52', fontSize: 12 }} />
{info.multicast_available ? '组播' : '单播'}
</Text>
<Select value={activeQuality} onChange={setActiveQuality} style={{ width: 100 }} size="small"
<Select value={activeQuality || undefined} onChange={setActiveQuality} className="watch-quality" size="small"
options={info.qualities.map(q => ({ value: q.label, label: q.label }))} placeholder="清晰度" />
<Select value={activeLine} onChange={setActiveLine} style={{ width: 150 }} size="small"
<Select value={activeLine || undefined} onChange={setActiveLine} className="watch-line" size="small"
options={info.lines.map(l => ({ value: l.node_id, label: l.node_name }))}
prefix={<WifiOutlined />} placeholder="线路" />
</Space>
</div>
</header>
{/* Video */}
<main className="watch-stage">
<div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline
style={{ width: '100%', height: '100%', objectFit: 'contain', display: 'block' }} />
{info.status !== 'live' && (
<div style={{
position: 'absolute', inset: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(10,10,8,0.92)',
}}>
<Space direction="vertical" align="center" size={16}>
<div className="watch-content">
<div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
{info.status !== 'live' && !info.replay_url && (
<div className="watch-waiting">
<Spin size="large" />
<Text style={{
color: '#949088', fontSize: 17,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
}}>
等待主播开播...
</Text>
</Space>
</div>
<Text>等待主播开播...</Text>
</div>
)}
</div>
{(info.description || info.chapters.length > 0) && (
<aside className="watch-details">
{info.description && <p>{info.description}</p>}
{info.chapters.length > 0 && (
<section>
<h2>节目章节</h2>
<div className="chapter-list">
{info.chapters.map((chapter, index) => (
<button key={`${chapter.time}-${index}`} type="button" onClick={() => jumpToChapter(chapter.time)}>
<span>{chapter.title}</span>
<time>{Math.floor(chapter.time / 60)}:{String(chapter.time % 60).padStart(2, '0')}</time>
</button>
))}
</div>
</section>
)}
</aside>
)}
</div>
</main>
+99
View File
@@ -219,6 +219,28 @@ button.stream-card:focus-visible {
background: #1d1a16;
}
.watch-heading,
.watch-options {
display: flex;
align-items: center;
gap: 16px;
}
.watch-divider {
width: 1px;
height: 18px;
background: rgba(235, 229, 219, 0.1);
}
.watch-title {
color: #ebe5db;
font-size: 14px;
font-weight: 500;
}
.watch-quality { width: 100px; }
.watch-line { width: 150px; }
.watch-stage {
flex: 1;
display: flex;
@@ -227,6 +249,11 @@ button.stream-card:focus-visible {
padding: 36px;
}
.watch-content {
width: 100%;
max-width: 1280px;
}
.watch-frame {
position: relative;
width: 100%;
@@ -239,6 +266,52 @@ button.stream-card:focus-visible {
background: #080706;
}
.watch-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.watch-waiting {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(10, 10, 8, 0.92);
}
.watch-waiting .ant-typography { color: #949088; font-size: 17px; }
.watch-details {
margin-top: 24px;
padding: 24px;
border: 1px solid #3d3730;
border-radius: 8px;
background: #1d1a16;
}
.watch-details p { margin: 0 0 20px; color: #b7aea2; line-height: 1.7; }
.watch-details h2 { margin: 0 0 12px; font-size: 16px; }
.chapter-list { display: grid; gap: 8px; }
.chapter-list button {
display: flex;
justify-content: space-between;
width: 100%;
padding: 12px 14px;
border: 1px solid #3d3730;
border-radius: 6px;
background: #24201b;
color: #eee7dc;
cursor: pointer;
text-align: left;
}
.chapter-list button:hover { border-color: #806d56; background: #2b261f; }
.chapter-list time { color: #949088; font-variant-numeric: tabular-nums; }
@media (max-width: 720px) {
.watch-header {
align-items: flex-start;
@@ -246,6 +319,27 @@ button.stream-card:focus-visible {
padding: 14px 16px;
}
.watch-heading,
.watch-options {
width: 100%;
flex-wrap: wrap;
gap: 10px;
}
.watch-title {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.watch-options .ant-tag,
.watch-options .ant-typography { display: none; }
.watch-quality,
.watch-line { flex: 1; width: auto; min-width: 120px; }
.watch-stage {
align-items: flex-start;
padding: 20px 0;
@@ -256,6 +350,11 @@ button.stream-card:focus-visible {
border-left: 0;
border-radius: 0;
}
.watch-details {
margin: 16px;
padding: 18px;
}
}
@media (max-width: 900px) {