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,5 +1,9 @@
|
|||||||
from fastapi import APIRouter, Depends
|
import os
|
||||||
from pydantic import BaseModel
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from config import settings
|
from config import settings
|
||||||
from routes.auth import require_auth
|
from routes.auth import require_auth
|
||||||
@@ -13,6 +17,17 @@ class BrandUpdate(BaseModel):
|
|||||||
brand_logo_url: Optional[str] = None
|
brand_logo_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TranscodeQuality(BaseModel):
|
||||||
|
label: str = Field(min_length=1, max_length=32)
|
||||||
|
width: int = Field(ge=160, le=7680)
|
||||||
|
height: int = Field(ge=90, le=4320)
|
||||||
|
bitrate: str = Field(pattern=r"^[1-9][0-9]*(?:k|M)$")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscodeProfilesUpdate(BaseModel):
|
||||||
|
profiles: dict[str, list[TranscodeQuality]]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/brand")
|
@router.get("/brand")
|
||||||
async def get_brand():
|
async def get_brand():
|
||||||
return {
|
return {
|
||||||
@@ -31,3 +46,81 @@ async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)):
|
|||||||
if body.brand_logo_url is not None:
|
if body.brand_logo_url is not None:
|
||||||
settings.brand_logo_url = body.brand_logo_url
|
settings.brand_logo_url = body.brand_logo_url
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/transcode-profiles")
|
||||||
|
async def get_transcode_profiles(_: bool = Depends(require_auth)):
|
||||||
|
return settings.transcode_profiles
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/transcode-profiles")
|
||||||
|
async def update_transcode_profiles(
|
||||||
|
body: TranscodeProfilesUpdate,
|
||||||
|
_: bool = Depends(require_auth),
|
||||||
|
):
|
||||||
|
if not body.profiles:
|
||||||
|
raise HTTPException(status_code=422, detail="至少需要一个转码配置")
|
||||||
|
|
||||||
|
normalized: dict[str, list[dict]] = {}
|
||||||
|
for raw_name, qualities in body.profiles.items():
|
||||||
|
name = raw_name.strip()
|
||||||
|
if not name or len(name) > 64:
|
||||||
|
raise HTTPException(status_code=422, detail="转码配置名称无效")
|
||||||
|
if not qualities:
|
||||||
|
raise HTTPException(status_code=422, detail=f"转码配置 {name} 至少需要一个清晰度")
|
||||||
|
labels = [quality.label.strip() for quality in qualities]
|
||||||
|
if any(not label for label in labels) or len(labels) != len(set(labels)):
|
||||||
|
raise HTTPException(status_code=422, detail=f"转码配置 {name} 的清晰度名称无效或重复")
|
||||||
|
normalized[name] = [
|
||||||
|
{
|
||||||
|
"label": quality.label.strip(),
|
||||||
|
"width": quality.width,
|
||||||
|
"height": quality.height,
|
||||||
|
"bitrate": quality.bitrate,
|
||||||
|
}
|
||||||
|
for quality in sorted(qualities, key=lambda item: item.height, reverse=True)
|
||||||
|
]
|
||||||
|
|
||||||
|
settings.transcode_profiles = normalized
|
||||||
|
settings.qualities = next(iter(normalized.values()))
|
||||||
|
return {"ok": True, "profiles": normalized}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system-metrics")
|
||||||
|
async def get_system_metrics(_: bool = Depends(require_auth)):
|
||||||
|
load_1m, load_5m, load_15m = os.getloadavg()
|
||||||
|
memory_total = 0
|
||||||
|
memory_available = 0
|
||||||
|
meminfo = Path("/proc/meminfo")
|
||||||
|
if meminfo.exists():
|
||||||
|
values = {}
|
||||||
|
for line in meminfo.read_text(encoding="ascii").splitlines():
|
||||||
|
key, _, value = line.partition(":")
|
||||||
|
if value:
|
||||||
|
values[key] = int(value.strip().split()[0]) * 1024
|
||||||
|
memory_total = values.get("MemTotal", 0)
|
||||||
|
memory_available = values.get("MemAvailable", 0)
|
||||||
|
|
||||||
|
metrics_path = Path(settings.replay_media_dir).resolve()
|
||||||
|
disk_path = metrics_path if metrics_path.exists() else Path.cwd()
|
||||||
|
disk = shutil.disk_usage(disk_path)
|
||||||
|
cpu_count = os.cpu_count() or 1
|
||||||
|
return {
|
||||||
|
"cpu": {
|
||||||
|
"count": cpu_count,
|
||||||
|
"load_1m": load_1m,
|
||||||
|
"load_5m": load_5m,
|
||||||
|
"load_15m": load_15m,
|
||||||
|
"load_percent": round(min(100, load_1m / cpu_count * 100), 1),
|
||||||
|
},
|
||||||
|
"memory": {
|
||||||
|
"total": memory_total,
|
||||||
|
"available": memory_available,
|
||||||
|
"used_percent": round((1 - memory_available / memory_total) * 100, 1) if memory_total else 0,
|
||||||
|
},
|
||||||
|
"disk": {
|
||||||
|
"total": disk.total,
|
||||||
|
"free": disk.free,
|
||||||
|
"used_percent": round(disk.used / disk.total * 100, 1) if disk.total else 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from routes.streams import _serialize
|
||||||
|
|
||||||
|
|
||||||
|
class StreamAccessSerializationTest(unittest.TestCase):
|
||||||
|
def make_stream(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"id": 1,
|
||||||
|
"title": "Hidden program",
|
||||||
|
"description": None,
|
||||||
|
"cover_url": None,
|
||||||
|
"status": SimpleNamespace(value="pending"),
|
||||||
|
"scheduled_at": datetime(2026, 8, 20, 12, 0),
|
||||||
|
"started_at": None,
|
||||||
|
"ended_at": None,
|
||||||
|
"multicast_enabled": False,
|
||||||
|
"replay_file": None,
|
||||||
|
"chapters_json": "[]",
|
||||||
|
"transcode_profile": "default",
|
||||||
|
"homepage_visible": False,
|
||||||
|
"remote_control_enabled": True,
|
||||||
|
"stream_key": "private-key",
|
||||||
|
"created_at": datetime(2026, 8, 19, 12, 0),
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return SimpleNamespace(**values)
|
||||||
|
|
||||||
|
def test_public_serialization_excludes_private_stream_key(self):
|
||||||
|
payload = _serialize(self.make_stream(), include_private=False)
|
||||||
|
self.assertNotIn("stream_key", payload)
|
||||||
|
self.assertFalse(payload["homepage_visible"])
|
||||||
|
self.assertTrue(payload["remote_control_enabled"])
|
||||||
|
|
||||||
|
def test_admin_serialization_includes_private_stream_key(self):
|
||||||
|
payload = _serialize(self.make_stream(), include_private=True)
|
||||||
|
self.assertEqual(payload["stream_key"], "private-key")
|
||||||
|
|
||||||
|
def test_program_statuses_are_preserved(self):
|
||||||
|
for status in ("pending", "live", "ended", "cancelled"):
|
||||||
|
with self.subTest(status=status):
|
||||||
|
payload = _serialize(self.make_stream(status=SimpleNamespace(value=status)), include_private=False)
|
||||||
|
self.assertEqual(payload["status"], status)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessContractTest(unittest.TestCase):
|
||||||
|
def test_hidden_program_uses_private_direct_url_contract(self):
|
||||||
|
stream = StreamAccessSerializationTest().make_stream(homepage_visible=False)
|
||||||
|
public_payload = _serialize(stream, include_private=False)
|
||||||
|
admin_payload = _serialize(stream, include_private=True)
|
||||||
|
self.assertFalse(public_payload["homepage_visible"])
|
||||||
|
self.assertNotIn("stream_key", public_payload)
|
||||||
|
self.assertEqual(admin_payload["stream_key"], "private-key")
|
||||||
|
|
||||||
|
def test_remote_control_flag_is_enforced_by_contract(self):
|
||||||
|
enabled = _serialize(
|
||||||
|
StreamAccessSerializationTest().make_stream(remote_control_enabled=True),
|
||||||
|
include_private=False,
|
||||||
|
)
|
||||||
|
disabled = _serialize(
|
||||||
|
StreamAccessSerializationTest().make_stream(remote_control_enabled=False),
|
||||||
|
include_private=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(enabled["remote_control_enabled"])
|
||||||
|
self.assertFalse(disabled["remote_control_enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import asyncio
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from routes.settings_route import TranscodeQuality, get_system_metrics
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsRouteTest(unittest.TestCase):
|
||||||
|
def test_transcode_quality_accepts_supported_values(self):
|
||||||
|
quality = TranscodeQuality(label="高清", width=1920, height=1080, bitrate="5000k")
|
||||||
|
self.assertEqual(quality.width, 1920)
|
||||||
|
self.assertEqual(quality.bitrate, "5000k")
|
||||||
|
|
||||||
|
def test_transcode_quality_rejects_invalid_bitrate_and_dimensions(self):
|
||||||
|
invalid_values = [
|
||||||
|
dict(label="高清", width=100, height=1080, bitrate="5000k"),
|
||||||
|
dict(label="高清", width=1920, height=1080, bitrate="5000"),
|
||||||
|
dict(label="", width=1920, height=1080, bitrate="5M"),
|
||||||
|
]
|
||||||
|
for value in invalid_values:
|
||||||
|
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||||
|
TranscodeQuality(**value)
|
||||||
|
|
||||||
|
def test_system_metrics_contains_bounded_resource_values(self):
|
||||||
|
metrics = asyncio.run(get_system_metrics(True))
|
||||||
|
self.assertGreaterEqual(metrics["cpu"]["count"], 1)
|
||||||
|
self.assertGreaterEqual(metrics["cpu"]["load_percent"], 0)
|
||||||
|
self.assertLessEqual(metrics["cpu"]["load_percent"], 100)
|
||||||
|
self.assertGreaterEqual(metrics["memory"]["used_percent"], 0)
|
||||||
|
self.assertLessEqual(metrics["memory"]["used_percent"], 100)
|
||||||
|
self.assertGreater(metrics["disk"]["total"], 0)
|
||||||
|
self.assertGreaterEqual(metrics["disk"]["free"], 0)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button, Card, Col, Empty, Row, Select, Space, Statistic, Table, Tag, Typography, message } from 'antd'
|
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 {
|
import {
|
||||||
createRemoteSession,
|
createRemoteSession,
|
||||||
fetchStreams,
|
fetchStreams,
|
||||||
@@ -18,7 +19,9 @@ export default function LiveBoard() {
|
|||||||
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
|
const [sessions, setSessions] = useState<ViewerSessionInfo[]>([])
|
||||||
const [selected, setSelected] = useState<React.Key[]>([])
|
const [selected, setSelected] = useState<React.Key[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null)
|
||||||
const [quality, setQuality] = useState('auto')
|
const [quality, setQuality] = useState('auto')
|
||||||
|
const [wallMode, setWallMode] = useState(false)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -34,6 +37,11 @@ export default function LiveBoard() {
|
|||||||
} catch {
|
} catch {
|
||||||
message.error('看板数据加载失败')
|
message.error('看板数据加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
try {
|
||||||
|
setSystemMetrics(await getSystemMetrics())
|
||||||
|
} catch {
|
||||||
|
setSystemMetrics(null)
|
||||||
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +83,7 @@ export default function LiveBoard() {
|
|||||||
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
|
options={streams.map(program => ({ value: program.id, label: `${program.title} · ${program.status}` }))}
|
||||||
onChange={value => { setStreamId(value); setSelected([]) }}
|
onChange={value => { setStreamId(value); setSelected([]) }}
|
||||||
/>
|
/>
|
||||||
|
<Button icon={wallMode ? <FullscreenExitOutlined /> : <FullscreenOutlined />} onClick={() => setWallMode(value => !value)}>{wallMode ? '退出大屏' : '大屏模式'}</Button>
|
||||||
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</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>
|
<Col xs={24} sm={8}><Card><Statistic title="疑似卡顿" value={buffering.length} prefix={<WarningOutlined />} valueStyle={{ color: buffering.length ? '#cf5c36' : undefined }} /></Card></Col>
|
||||||
</Row>
|
</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="当前没有可查看的直播节目" /> : (
|
{!streamId ? <Empty description="当前没有可查看的直播节目" /> : (
|
||||||
<Card
|
<Card
|
||||||
title="审计式观众会话"
|
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 { useEffect, useState } from 'react'
|
||||||
import { Select, Switch, Form, Input, Button, Typography, message } from 'antd'
|
import { Button, Form, Input, InputNumber, Select, Switch, Typography, message } from 'antd'
|
||||||
import api from '../../api/client'
|
import api from '../../api/client'
|
||||||
|
import './Settings.css'
|
||||||
import { useBrand } from '../../brand/BrandContext'
|
import { useBrand } from '../../brand/BrandContext'
|
||||||
|
|
||||||
const { Title, Text } = Typography
|
const { Title, Text } = Typography
|
||||||
|
|
||||||
|
type Quality = { label: string; width: number; height: number; bitrate: string }
|
||||||
|
type Profiles = Record<string, Quality[]>
|
||||||
|
|
||||||
const strategies = [
|
const strategies = [
|
||||||
{ value: 'subnet', label: '子网优先(推荐)' },
|
{ value: 'subnet', label: '子网优先(推荐)' },
|
||||||
{ value: 'weighted', label: '权重均衡' },
|
{ value: 'weighted', label: '权重均衡' },
|
||||||
@@ -17,77 +21,107 @@ export default function SettingsPage() {
|
|||||||
const [strategy, setStrategy] = useState('subnet')
|
const [strategy, setStrategy] = useState('subnet')
|
||||||
const [multicastEnabled, setMulticastEnabled] = useState(true)
|
const [multicastEnabled, setMulticastEnabled] = useState(true)
|
||||||
const [multicastAvailable, setMulticastAvailable] = useState(false)
|
const [multicastAvailable, setMulticastAvailable] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [brandName, setBrandName] = useState(brand.brand_name)
|
const [brandName, setBrandName] = useState(brand.brand_name)
|
||||||
const [brandTagline, setBrandTagline] = useState(brand.brand_tagline)
|
const [brandTagline, setBrandTagline] = useState(brand.brand_tagline)
|
||||||
const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url)
|
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(() => {
|
||||||
useEffect(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand])
|
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() {
|
useEffect(() => {
|
||||||
try { const { data } = await api.get('/nodes/strategy'); setStrategy(data.strategy); setMulticastEnabled(data.multicast_enabled); setMulticastAvailable(data.multicast_available) } catch { /* */ }
|
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)
|
setSaving(true)
|
||||||
try { await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }); setStrategy(val); message.success('策略已更新') }
|
try {
|
||||||
catch { message.error('更新失败') }
|
await Promise.all([
|
||||||
setSaving(false)
|
api.put('/nodes/strategy', { strategy, multicast_enabled: multicastEnabled }),
|
||||||
}
|
api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }),
|
||||||
async function toggleMulticast(val: boolean) {
|
api.put('/settings/transcode-profiles', { profiles }),
|
||||||
setMulticastEnabled(val)
|
])
|
||||||
try { await api.put('/nodes/strategy', { strategy, multicast_enabled: val }); message.success('已更新') } catch { message.error('更新失败') }
|
await refresh()
|
||||||
}
|
message.success('设置已保存')
|
||||||
async function saveBrand() {
|
} catch {
|
||||||
setBrandSaving(true)
|
message.error('保存失败,请检查转码参数')
|
||||||
try { await api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }); await refresh(); message.success('品牌设置已保存') }
|
} finally {
|
||||||
catch { message.error('保存失败') }
|
setSaving(false)
|
||||||
setBrandSaving(false)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 720 }}>
|
<div className="settings-page">
|
||||||
<div style={{ marginBottom: 32 }}>
|
<Title level={2}>系统设置</Title>
|
||||||
<Title level={2} style={{ margin: '0 0 4px', fontWeight: 600, fontSize: 28 }}>系统设置</Title>
|
<Text type="secondary">配置分发策略、转码清晰度与平台品牌</Text>
|
||||||
<Text style={{ color: '#949088' }}>配置负载均衡策略与品牌信息</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32, marginBottom: 24 }}>
|
<section className="settings-card">
|
||||||
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>负载均衡</Title>
|
<Title level={4}>负载均衡</Title>
|
||||||
<Form layout="vertical">
|
<Form layout="vertical">
|
||||||
<Form.Item label="均衡策略" help="根据客户端 IP 所属子网自动选择最近的边缘节点">
|
<Form.Item label="均衡策略"><Select value={strategy} onChange={setStrategy} options={strategies} /></Form.Item>
|
||||||
<Select value={strategy} onChange={saveStrategy} options={strategies} loading={saving} style={{ width: 280, borderRadius: 8 }} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="组播">
|
<Form.Item label="组播">
|
||||||
<Switch checked={multicastEnabled} onChange={toggleMulticast} />
|
<Switch checked={multicastEnabled} onChange={setMulticastEnabled} />
|
||||||
<Text type="secondary" style={{ marginLeft: 12 }}>
|
<Text type="secondary"> {multicastAvailable ? '可用' : '不可用,将回退至单播'}</Text>
|
||||||
{multicastAvailable ? '可用 — 优先使用组播分发' : '不可用 — 回退至单播'}
|
|
||||||
</Text>
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div style={{ background: '#fff', borderRadius: 12, border: '1px solid #EBE5DB', padding: 32 }}>
|
<section className="settings-card">
|
||||||
<Title level={4} style={{ margin: '0 0 24px', fontWeight: 600 }}>品牌设置</Title>
|
<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 layout="vertical">
|
||||||
<Form.Item label="平台名称">
|
<Form.Item label="平台名称"><Input value={brandName} onChange={e => setBrandName(e.target.value)} /></Form.Item>
|
||||||
<Input value={brandName} onChange={e => setBrandName(e.target.value)} size="large" style={{ borderRadius: 8 }} />
|
<Form.Item label="标语"><Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} /></Form.Item>
|
||||||
</Form.Item>
|
<Form.Item label="Logo 图片地址"><Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} /></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>
|
</Form>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
|
<Button type="primary" size="large" loading={saving} onClick={saveAll}>保存全部设置</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -29,10 +29,12 @@ export default function WatchPage() {
|
|||||||
const [activeLine, setActiveLine] = useState<string>('')
|
const [activeLine, setActiveLine] = useState<string>('')
|
||||||
const [activeQuality, setActiveQuality] = useState<string>('auto')
|
const [activeQuality, setActiveQuality] = useState<string>('auto')
|
||||||
const [autoQuality, setAutoQuality] = useState<string>('')
|
const [autoQuality, setAutoQuality] = useState<string>('')
|
||||||
const bufferEventsRef = useRef<number[]>([])
|
const bufferEventsRef = useRef<Array<{ at: number; duration: number }>>([])
|
||||||
const bufferStartedRef = useRef<number | null>(null)
|
const bufferStartedRef = useRef<number | null>(null)
|
||||||
const playbackStartedRef = useRef<number>(0)
|
const playbackStartedRef = useRef<number>(0)
|
||||||
const [remoteAdjusted, setRemoteAdjusted] = useState(false)
|
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 hlsRef = useRef<Hls | null>(null)
|
||||||
const viewerSessionRef = useRef<string>('')
|
const viewerSessionRef = useRef<string>('')
|
||||||
const currentLine = info?.lines.find(line => line.node_id === activeLine)
|
const currentLine = info?.lines.find(line => line.node_id === activeLine)
|
||||||
@@ -182,6 +184,14 @@ export default function WatchPage() {
|
|||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!info || !video) return
|
if (!info || !video) return
|
||||||
setPlaybackError('')
|
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?.destroy()
|
||||||
hlsRef.current = null
|
hlsRef.current = null
|
||||||
video.removeAttribute('src')
|
video.removeAttribute('src')
|
||||||
@@ -208,13 +218,14 @@ export default function WatchPage() {
|
|||||||
const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
|
const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
|
||||||
hi.loadSource(source)
|
hi.loadSource(source)
|
||||||
hi.attachMedia(video)
|
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) => {
|
hi.on(Hls.Events.ERROR, (_e, detail) => {
|
||||||
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
|
if (detail.fatal) setPlaybackError('播放流加载失败,请尝试切换线路或稍后重试')
|
||||||
})
|
})
|
||||||
hlsRef.current = hi
|
hlsRef.current = hi
|
||||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
video.src = source
|
video.src = source
|
||||||
|
video.addEventListener('loadedmetadata', restorePlaybackState, { once: true })
|
||||||
video.load()
|
video.load()
|
||||||
} else {
|
} else {
|
||||||
setPlaybackError('当前浏览器不支持此播放格式')
|
setPlaybackError('当前浏览器不支持此播放格式')
|
||||||
@@ -250,10 +261,12 @@ export default function WatchPage() {
|
|||||||
const onPlaying = () => {
|
const onPlaying = () => {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (!playbackStartedRef.current) playbackStartedRef.current = 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
|
const duration = now - bufferStartedRef.current
|
||||||
bufferEventsRef.current = bufferEventsRef.current.filter(at => now - at <= 10000)
|
bufferEventsRef.current.push({ at: now, duration })
|
||||||
if (duration >= 2000 || bufferEventsRef.current.length >= 3) {
|
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()
|
lowerQuality()
|
||||||
bufferEventsRef.current = []
|
bufferEventsRef.current = []
|
||||||
}
|
}
|
||||||
@@ -261,9 +274,9 @@ export default function WatchPage() {
|
|||||||
bufferStartedRef.current = null
|
bufferStartedRef.current = null
|
||||||
}
|
}
|
||||||
const onWaiting = () => {
|
const onWaiting = () => {
|
||||||
if (Date.now() - playbackStartedRef.current <= 3000) return
|
const now = Date.now()
|
||||||
bufferStartedRef.current = Date.now()
|
if (!playbackStartedRef.current || now - playbackStartedRef.current <= 10000 || bufferStartedRef.current) return
|
||||||
bufferEventsRef.current.push(Date.now())
|
bufferStartedRef.current = now
|
||||||
}
|
}
|
||||||
const recoveryTimer = window.setInterval(() => {
|
const recoveryTimer = window.setInterval(() => {
|
||||||
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
|
if (!video.paused && !video.seeking && video.readyState >= 3) higherQuality()
|
||||||
@@ -278,6 +291,26 @@ export default function WatchPage() {
|
|||||||
}
|
}
|
||||||
}, [activeQuality, info])
|
}, [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) {
|
function jumpToChapter(time: number) {
|
||||||
if (!videoRef.current) return
|
if (!videoRef.current) return
|
||||||
videoRef.current.currentTime = time
|
videoRef.current.currentTime = time
|
||||||
@@ -335,7 +368,21 @@ export default function WatchPage() {
|
|||||||
<main className="watch-stage">
|
<main className="watch-stage">
|
||||||
<div className="watch-content">
|
<div className="watch-content">
|
||||||
<div className="watch-frame">
|
<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 && (
|
{playbackError && (
|
||||||
<div className="watch-waiting">
|
<div className="watch-waiting">
|
||||||
<Alert type="error" showIcon message={playbackError} />
|
<Alert type="error" showIcon message={playbackError} />
|
||||||
|
|||||||
@@ -365,6 +365,40 @@ button.stream-card:focus-visible {
|
|||||||
background: #080706;
|
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 {
|
.watch-video {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
Reference in new issue
Block a user