feat: complete program platform experience
This commit is contained in:
1 parent
e50b8b480b
commit
383c066d86
9 files changed
+419
-67
No files matched your search
@@ -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="审计式观众会话"
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user