完善节目平台与直播监控体验
This commit is contained in:
1 parent
229a8d0791
commit
e50b8b480b
11 files changed
+642
-37
No files matched your search
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Layout, Menu, Button, Typography } from 'antd'
|
||||
import {
|
||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined,
|
||||
DashboardOutlined, VideoCameraOutlined, PlusOutlined, MonitorOutlined,
|
||||
CloudServerOutlined, SettingOutlined, LogoutOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { clearToken } from '../api/client'
|
||||
@@ -13,6 +13,7 @@ const { Text } = Typography
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/admin', icon: <DashboardOutlined />, label: '概览' },
|
||||
{ key: '/admin/board', icon: <MonitorOutlined />, label: '直播看板' },
|
||||
{ key: '/admin/streams', icon: <VideoCameraOutlined />, label: '节目列表' },
|
||||
{ key: '/admin/streams/new', icon: <PlusOutlined />, label: '创建节目' },
|
||||
{ key: '/admin/nodes', icon: <CloudServerOutlined />, label: '边缘节点' },
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
||||
import { ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
createRemoteSession,
|
||||
fetchStreams,
|
||||
fetchViewerSessions,
|
||||
sendRemoteCommand,
|
||||
type StreamInfo,
|
||||
type ViewerSessionInfo,
|
||||
} from '../../api/streams'
|
||||
|
||||
const { Title, Text } = Typography
|
||||
|
||||
export default function LiveBoard() {
|
||||
const [streams, setStreams] = useState<StreamInfo[]>([])
|
||||
const [streamId, setStreamId] = useState<number>()
|
||||
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
|
||||
const [selected, setSelected] = useState<React.Key[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [quality, setQuality] = useState('auto')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const programs = await fetchStreams(true)
|
||||
setStreams(programs)
|
||||
const nextId = streamId || programs.find(program => program.status === 'live')?.id
|
||||
if (nextId) {
|
||||
setStreamId(nextId)
|
||||
setSessions(await fetchViewerSessions(nextId))
|
||||
} else {
|
||||
setSessions([])
|
||||
}
|
||||
} catch {
|
||||
message.error('看板数据加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const timer = window.setInterval(load, 5000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [streamId])
|
||||
|
||||
async function controlViewers() {
|
||||
if (!streamId || selected.length === 0) return
|
||||
try {
|
||||
const session = await createRemoteSession(streamId)
|
||||
const action = quality === 'auto' ? 'restore_auto' : 'switch_quality'
|
||||
await sendRemoteCommand(streamId, session.token, action, selected.map(String), quality === 'auto' ? undefined : quality)
|
||||
message.success(`已向 ${selected.length} 个观众下发播放策略`)
|
||||
} catch {
|
||||
message.error('控制命令下发失败,请确认节目已启用审计式播放控制')
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessions = sessions.filter(item => Date.now() - new Date(item.last_heartbeat).getTime() < 30000)
|
||||
const buffering = activeSessions.filter(item => item.playback_state === 'buffering' || Number(item.buffered_seconds || 0) < 1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space direction="vertical" size={24} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<Title level={2} style={{ margin: 0 }}>直播看板</Title>
|
||||
<Text type="secondary">实时检查节目、观众会话、线路与播放健康度</Text>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Select
|
||||
value={streamId}
|
||||
placeholder="选择直播节目"
|
||||
style={{ minWidth: 220 }}
|
||||
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
|
||||
onChange={value => { setStreamId(value); setSelected([]) }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="活跃观众" value={activeSessions.length} prefix={<TeamOutlined />} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="播放正常" value={Math.max(0, activeSessions.length - buffering.length)} prefix={<SignalFilled />} /></Card></Col>
|
||||
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
|
||||
</Row>
|
||||
|
||||
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
|
||||
<Card
|
||||
title="审计式观众会话"
|
||||
extra={
|
||||
<Space wrap>
|
||||
<Select value={quality} onChange={setQuality} style={{ width: 150 }} options={[
|
||||
{ value: 'auto', label: '恢复自动' },
|
||||
{ value: '1080p', label: '1080p' },
|
||||
{ value: '720p', label: '720p' },
|
||||
{ value: '480p', label: '480p' },
|
||||
{ value: '360p', label: '360p' },
|
||||
]} />
|
||||
<Button type="primary" disabled={!selected.length} onClick={controlViewers}>批量切换</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="session_id"
|
||||
loading={loading}
|
||||
dataSource={sessions}
|
||||
scroll={{ x: 850 }}
|
||||
rowSelection={{ selectedRowKeys: selected, onChange: setSelected }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
columns={[
|
||||
{ title: '会话', dataIndex: 'session_id', ellipsis: true, width: 180 },
|
||||
{ title: '节点', dataIndex: 'node_name', render: value => value || '未知' },
|
||||
{ title: '清晰度', dataIndex: 'quality', render: value => value || '自动' },
|
||||
{ title: '状态', dataIndex: 'playback_state', render: value => <Tag color={value === 'playing' ? 'green' : value === 'buffering' ? 'orange' : 'default'}>{value}</Tag> },
|
||||
{ title: '缓冲', dataIndex: 'buffered_seconds', render: value => `${Number(value || 0).toFixed(1)}s` },
|
||||
{ title: '远程调整', dataIndex: 'remote_adjusted', render: value => value ? <Tag color="gold">已调整</Tag> : '否' },
|
||||
{ title: '最后心跳', dataIndex: 'last_heartbeat', render: value => new Date(value).toLocaleTimeString() },
|
||||
{ title: '错误', dataIndex: 'error', ellipsis: true, render: value => value || '-' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -78,15 +78,15 @@ export default function StreamForm() {
|
||||
style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表</Button>
|
||||
|
||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 36 }}>
|
||||
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建直播</Title>
|
||||
<Title level={2} style={{ margin: '0 0 8px', fontWeight: 600 }}>创建节目</Title>
|
||||
<Text style={{ color: '#5E5A52', display: 'block', marginBottom: 36 }}>
|
||||
填写直播信息,系统将生成推流码与 RTMP 地址
|
||||
填写节目信息并预约播出,系统将生成推流码与 RTMP 地址
|
||||
</Text>
|
||||
|
||||
<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 }} />
|
||||
<Form.Item name="title" label="节目标题"
|
||||
rules={[{ required: true, message: '请输入节目标题' }]}>
|
||||
<Input placeholder="输入节目标题" style={{ borderRadius: 8 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="简介">
|
||||
<TextArea rows={3} placeholder="直播内容简介(可选)" style={{ borderRadius: 8 }} />
|
||||
@@ -109,7 +109,7 @@ export default function StreamForm() {
|
||||
<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 }}>
|
||||
创建直播
|
||||
创建节目
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
Reference in new issue
Block a user