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>
)
}