feat: complete program platform experience

This commit is contained in:
admin committed 2026-08-19 17:25:00 +08:00
1 parent e50b8b480b
commit 383c066d86
9 files changed
+419 -67

No files matched your search

+16 -1
View File
@@ -1,6 +1,7 @@
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 { DashboardOutlined, DatabaseOutlined, FullscreenExitOutlined, FullscreenOutlined, ReloadOutlined, SignalFilled, TeamOutlined, WarningOutlined } from '@ant-design/icons'
import { getSystemMetrics, type SystemMetrics } from '../../api/system'
import {
createRemoteSession,
fetchStreams,
@@ -18,7 +19,9 @@ export default function LiveBoard() {
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
const [selected, setSelected] = useState<React.Key[]>([])
const [loading, setLoading] = useState(true)
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
const [quality, setQuality] = useState('auto')
const [wallMode, setWallMode] = useState(false)
async function load() {
try {
@@ -34,6 +37,11 @@ export default function LiveBoard() {
} catch {
message.error('看板数据加载失败')
} finally {
try {
setSystemMetrics(await getSystemMetrics())
} catch {
setSystemMetrics(null)
}
setLoading(false)
}
}
@@ -75,6 +83,7 @@ export default function LiveBoard() {
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
onChange={value => { setStreamId(value); setSelected([]) }}
/>
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
</Space>
</div>
@@ -85,6 +94,12 @@ export default function LiveBoard() {
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={8}><Card><Statistic title="CPU 使用率" value={systemMetrics?.cpu_percent ?? 0} precision={1} suffix="%" prefix={<DashboardOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="内存使用率" value={systemMetrics?.memory_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col>
<Col xs={24} sm={8}><Card><Statistic title="存储使用率" value={systemMetrics?.disk_percent ?? 0} precision={1} suffix="%" prefix={<DatabaseOutlined />} /></Card></Col>
</Row>
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
<Card
title="审计式观众会话"
+11
View File
@@ -0,0 +1,11 @@
.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; }
.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%; }
@media (max-width: 720px) {
.settings-card { padding: 16px; }
.quality-row { grid-template-columns: 1fr 1fr; }
.quality-row > :first-child, .quality-row > :nth-child(4), .quality-row > :last-child { grid-column: 1 / -1; width: 100%; }
.settings-page .ant-select, .settings-page .ant-input-number { width: 100% !important; }
}
+89 -55
View File
@@ -1,10 +1,14 @@
import { useState, useEffect } from 'react'
import { Select, Switch, Form, Input, Button, Typography, message } from 'antd'
import { useEffect, useState } from 'react'
import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd'
import api from '../../api/client'
import './Settings.css'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography
type Quality = { label: string; width: number; height: number; bitrate: string }
type Profiles = Record<string, Quality[]>
const strategies = [
{ value: 'subnet', label: '子网优先(推荐)' },
{ value: 'weighted', label: '权重均衡' },
@@ -17,77 +21,107 @@ export default function SettingsPage() {
const [strategy, setStrategy] = useState('subnet')
const [multicastEnabled, setMulticastEnabled] = useState(true)
const [multicastAvailable, setMulticastAvailable] = useState(false)
const [saving, setSaving] = useState(false)
const [brandName, setBrandName] = useState(brand.brand_name)
const [brandTagline, setBrandTagline] = useState(brand.brand_tagline)
const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url)
const [brandSaving, setBrandSaving] = useState(false)
const [profiles, setProfiles] = useState<Profiles>({})
const [saving, setSaving] = useState(false)
useEffect(() => { loadSettings() }, [])
useEffect(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand])
useEffect(() => {
void Promise.all([api.get('/nodes/strategy'), api.get('/settings/transcode-profiles')])
.then(([nodes, transcode]) => {
setStrategy(nodes.data.strategy)
setMulticastEnabled(nodes.data.multicast_enabled)
setMulticastAvailable(nodes.data.multicast_available)
setProfiles(transcode.data.profiles || {})
})
.catch(() => message.error('系统设置加载失败'))
}, [])
async function loadSettings() {
try { const { data } = await api.get('/nodes/strategy'); setStrategy(data.strategy); setMulticastEnabled(data.multicast_enabled); setMulticastAvailable(data.multicast_available) } catch { /* */ }
useEffect(() => {
setBrandName(brand.brand_name)
setBrandTagline(brand.brand_tagline)
setBrandLogoUrl(brand.brand_logo_url)
}, [brand])
function replaceProfile(profile: string, qualities: Quality[]) {
setProfiles(current => Object.assign({}, current, Object.fromEntries([[profile, qualities]])))
}
async function saveStrategy(val: string) {
function updateQuality(profile: string, index: number, patch: Partial<Quality>) {
replaceProfile(profile, profiles[profile].map((quality, i) => i === index ? { ...quality, ...patch } : quality))
}
function addQuality(profile: string) {
replaceProfile(profile, [...profiles[profile], { label: '新清晰度', width: 1280, height: 720, bitrate: '2000k' }])
}
function removeQuality(profile: string, index: number) {
replaceProfile(profile, profiles[profile].filter((_, i) => i !== index))
}
async function saveAll() {
setSaving(true)
try { await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }); setStrategy(val); message.success('策略已更新') }
catch { message.error('更新失败') }
setSaving(false)
}
async function toggleMulticast(val: boolean) {
setMulticastEnabled(val)
try { await api.put('/nodes/strategy', { strategy, multicast_enabled: val }); message.success('已更新') } catch { message.error('更新失败') }
}
async function saveBrand() {
setBrandSaving(true)
try { await api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }); await refresh(); message.success('品牌设置已保存') }
catch { message.error('保存失败') }
setBrandSaving(false)
try {
await Promise.all([
api.put('/nodes/strategy', { strategy, multicast_enabled: multicastEnabled }),
api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }),
api.put('/settings/transcode-profiles', { profiles }),
])
await refresh()
message.success('设置已保存')
} catch {
message.error('保存失败,请检查转码参数')
} finally {
setSaving(false)
}
}
return (
<div style={{ maxWidth: 720 }}>
<div style={{ marginBottom: 32 }}>
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28 }}>系统设置</Title>
<Text style={{ color: '#949088' }}>配置负载均衡策略与品牌信息</Text>
</div>
<div className="settings-page">
<Title level={2}>系统设置</Title>
<Text type="secondary">配置分发策略、转码清晰度与平台品牌</Text>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}>
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>负载均衡</Title>
<section className="settings-card">
<Title level={4}>负载均衡</Title>
<Form layout="vertical">
<Form.Item label="均衡策略" help="根据客户端 IP 所属子网自动选择最近的边缘节点">
<Select value={strategy} onChange={saveStrategy} options={strategies} loading={saving} style={{ width: 280, borderRadius: 8 }} />
</Form.Item>
<Form.Item label="均衡策略"><Select value={strategy} onChange={setStrategy} options={strategies} /></Form.Item>
<Form.Item label="组播">
<Switch checked={multicastEnabled} onChange={toggleMulticast} />
<Text type="secondary" style={{ marginLeft: 12 }}>
{multicastAvailable ? '可用 — 优先使用组播分发' : '不可用 — 回退至单播'}
</Text>
<Switch checked={multicastEnabled} onChange={setMulticastEnabled} />
<Text type="secondary"> {multicastAvailable ? '可用' : '不可用,将回退至单播'}</Text>
</Form.Item>
</Form>
</div>
</section>
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32 }}>
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>品牌设置</Title>
<section className="settings-card">
<Title level={4}>自定义转码配置</Title>
{Object.entries(profiles).map(([profile, qualities]) => (
<div className="profile-block" key={profile}>
<Text strong>{profile}</Text>
{qualities.map((quality, index) => (
<div className="quality-row" key={profile + '-' + index}>
<Input value={quality.label} onChange={e => updateQuality(profile, index, { label: e.target.value })} />
<InputNumber value={quality.width} min={160} max={7680} addonAfter="宽" onChange={v => updateQuality(profile, index, { width: v || 160 })} />
<InputNumber value={quality.height} min={90} max={4320} addonAfter="高" onChange={v => updateQuality(profile, index, { height: v || 90 })} />
<Input value={quality.bitrate} placeholder="2000k" onChange={e => updateQuality(profile, index, { bitrate: e.target.value })} />
<Button danger disabled={qualities.length <= 1} onClick={() => removeQuality(profile, index)}>删除</Button>
</div>
))}
<Button onClick={() => addQuality(profile)}>添加清晰度</Button>
</div>
))}
</section>
<section className="settings-card">
<Title level={4}>品牌设置</Title>
<Form layout="vertical">
<Form.Item label="平台名称">
<Input value={brandName} onChange={e => setBrandName(e.target.value)} size="large" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item label="标语">
<Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} size="large" style={{ borderRadius: 8 }} />
</Form.Item>
<Form.Item label="Logo 图片地址">
<Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} style={{ borderRadius: 8 }} placeholder="https://..." />
</Form.Item>
<Form.Item>
<Button type="primary" loading={brandSaving} onClick={saveBrand} size="large" style={{ borderRadius: 8 }}>
保存品牌设置
</Button>
<Text type="secondary" style={{ marginLeft: 12, fontSize: 13 }}>保存后全站立即生效</Text>
</Form.Item>
<Form.Item label="平台名称"><Input value={brandName} onChange={e => setBrandName(e.target.value)} /></Form.Item>
<Form.Item label="标语"><Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} /></Form.Item>
<Form.Item label="Logo 图片地址"><Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} /></Form.Item>
</Form>
</div>
</section>
<Button type="primary" size="large" loading={saving} onClick={saveAll}>保存全部设置</Button>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import api from './client'
export interface SystemMetrics {
cpu_percent: number
memory_percent: number
disk_percent: number
disk_total_bytes: number
disk_used_bytes: number
}
export async function getSystemMetrics(): Promise<SystemMetrics> {
const { data } = await api.get<SystemMetrics>('/settings/system-metrics')
return data
}
+56 -9
View File
@@ -29,10 +29,12 @@ export default function WatchPage() {
const [activeLine, setActiveLine] = useState<string>('')
const [activeQuality, setActiveQuality] = useState<string>('auto')
const [autoQuality, setAutoQuality] = useState<string>('')
const bufferEventsRef = useRef<number[]>([])
const bufferEventsRef = useRef<Array<{ at: number; duration: number }>>([])
const bufferStartedRef = useRef<number | null>(null)
const playbackStartedRef = useRef<number>(0)
const [remoteAdjusted, setRemoteAdjusted] = useState(false)
const [showPlaybackStats, setShowPlaybackStats] = useState(false)
const [playbackStats, setPlaybackStats] = useState({ buffered: 0, currentTime: 0, droppedFrames: 0, resolution: '未知' })
const hlsRef = useRef<Hls | null>(null)
const viewerSessionRef = useRef<string>('')
const currentLine = info?.lines.find(line => line.node_id === activeLine)
@@ -182,6 +184,14 @@ export default function WatchPage() {
const video = videoRef.current
if (!info || !video) return
setPlaybackError('')
const previousTime = Number.isFinite(video.currentTime) ? video.currentTime : 0
const wasPaused = video.paused
const restorePlaybackState = () => {
if (previousTime > 0 && Number.isFinite(video.duration)) {
video.currentTime = Math.min(previousTime, Math.max(0, video.duration - 0.1))
}
if (!wasPaused) video.play().catch(() => {})
}
hlsRef.current?.destroy()
hlsRef.current = null
video.removeAttribute('src')
@@ -208,13 +218,14 @@ export default function WatchPage() {
const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
hi.loadSource(source)
hi.attachMedia(video)
hi.on(Hls.Events.MANIFEST_PARSED, () => { video.play().catch(() => {}) })
hi.on(Hls.Events.MANIFEST_PARSED, restorePlaybackState)
hi.on(Hls.Events.ERROR, (_e, detail) => {
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
})
hlsRef.current = hi
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = source
video.addEventListener('loadedmetadata', restorePlaybackState, { once: true })
video.load()
} else {
setPlaybackError('当前浏览器不支持此播放格式')
@@ -250,10 +261,12 @@ export default function WatchPage() {
const onPlaying = () => {
const now = Date.now()
if (!playbackStartedRef.current) playbackStartedRef.current = now
if (bufferStartedRef.current && now - playbackStartedRef.current > 3000) {
if (bufferStartedRef.current && now - playbackStartedRef.current > 10000) {
const duration = now - bufferStartedRef.current
bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000)
if (duration >= 2000 || bufferEventsRef.current.length >= 3) {
bufferEventsRef.current.push({ at: now, duration })
bufferEventsRef.current = bufferEventsRef.current.filter(event => now - event.at <= 10000)
const accumulatedBuffering = bufferEventsRef.current.reduce((total, event) => total + event.duration, 0)
if (accumulatedBuffering >= 2000 || bufferEventsRef.current.length >= 3) {
lowerQuality()
bufferEventsRef.current = []
}
@@ -261,9 +274,9 @@ export default function WatchPage() {
bufferStartedRef.current = null
}
const onWaiting = () => {
if (Date.now() - playbackStartedRef.current <= 3000) return
bufferStartedRef.current = Date.now()
bufferEventsRef.current.push(Date.now())
const now = Date.now()
if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return
bufferStartedRef.current = now
}
const recoveryTimer = window.setInterval(() => {
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
@@ -278,6 +291,26 @@ export default function WatchPage() {
}
}, [activeQuality, info])
useEffect(() => {
const video = videoRef.current
if (!video) return
const updateStats = () => {
const buffered = video.buffered.length
? Math.max(0, video.buffered.end(video.buffered.length - 1) - video.currentTime)
: 0
const quality = video.getVideoPlaybackQuality?.()
setPlaybackStats({
buffered,
currentTime: video.currentTime || 0,
droppedFrames: quality?.droppedVideoFrames || 0,
resolution: video.videoWidth && video.videoHeight ? `${video.videoWidth}×${video.videoHeight}` : '未知',
})
}
updateStats()
const timer = window.setInterval(updateStats, 1000)
return () => window.clearInterval(timer)
}, [activeLine, activeQuality, autoQuality])
function jumpToChapter(time: number) {
if (!videoRef.current) return
videoRef.current.currentTime = time
@@ -335,7 +368,21 @@ export default function WatchPage() {
<main className="watch-stage">
<div className="watch-content">
<div className="watch-frame">
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
<div className="watch-player" onContextMenu={event => { event.preventDefault(); setShowPlaybackStats(value => !value) }}>
<video ref={videoRef} controls autoPlay playsInline className="watch-video" />
{showPlaybackStats && (
<section className="playback-stats" aria-label="播放统计信息">
<div><span>播放状态</span><strong>{videoRef.current?.paused ? '暂停' : '播放中'}</strong></div>
<div><span>线路</span><strong>{currentLine?.node_name || '不可用'}</strong></div>
<div><span>清晰度</span><strong>{activeQuality === 'auto' ? `自动(${autoQuality || '检测中'})` : activeQuality}</strong></div>
<div><span>分辨率</span><strong>{playbackStats.resolution}</strong></div>
<div><span>时间码</span><strong>{playbackStats.currentTime.toFixed(1)} 秒</strong></div>
<div><span>缓冲</span><strong>{playbackStats.buffered.toFixed(1)} 秒</strong></div>
<div><span>丢帧</span><strong>{playbackStats.droppedFrames}</strong></div>
<div className="playback-stats-url"><span>播放地址</span><strong>{videoRef.current?.currentSrc || '加载中'}</strong></div>
</section>
)}
</div>
{playbackError && (
<div className="watch-waiting">
<Alert type="error" showIcon message={playbackError} />
+34
View File
@@ -365,6 +365,40 @@ button.stream-card:focus-visible {
background: #080706;
}
.watch-player {
position: relative;
width: 100%;
height: 100%;
min-width: 0;
}
.playback-stats {
position: absolute;
top: 16px;
right: 16px;
z-index: 3;
width: min(360px, calc(100% - 32px));
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 10px;
background: rgba(18, 16, 13, 0.94);
color: #eee7dc;
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
}
.playback-stats div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 10px;
padding: 4px 0;
font-size: 12px;
}
.playback-stats span { color: #a99f92; }
.playback-stats strong { min-width: 0; overflow-wrap: anywhere; font-weight: 500; }
.playback-stats-url { border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 6px; padding-top: 10px !important; }
.watch-video {
width: 100%;
height: 100%;