diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 2a8e2cd..05c8254 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -26,51 +26,47 @@ export default function AdminLayout() { const location = useLocation() const selectedKey = menuItems.find( - (item) => location.pathname === item.key || (location.pathname === '/' && item.key === '/admin') + item => location.pathname === item.key || (location.pathname === '/' && item.key === '/admin') )?.key || '/admin' - const handleLogout = () => { - clearToken() - navigate('/admin/login') - } + const handleLogout = () => { clearToken(); navigate('/admin/login') } return (
{collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name}
- navigate(key)} - style={{ border: 'none', marginTop: 8 }} - /> + style={{ border: 'none', marginTop: 8 }} />
- {brand.brand_tagline} - + {brand.brand_tagline} +
- +
diff --git a/frontend/src/admin/LoginPage.tsx b/frontend/src/admin/LoginPage.tsx index 116621e..041d540 100644 --- a/frontend/src/admin/LoginPage.tsx +++ b/frontend/src/admin/LoginPage.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Input, Button, Typography, message, Space, Divider } from 'antd' +import { Form, Input, Button, Typography, message } from 'antd' import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons' import { login } from '../api/auth' import { useBrand } from '../brand/BrandContext' @@ -14,89 +14,62 @@ export default function LoginPage() { const handleSubmit = async (values: { password: string }) => { setLoading(true) - try { - await login(values.password) - message.success('登录成功') - navigate('/admin') - } catch { - message.error('密码错误') - } finally { - setLoading(false) - } + try { await login(values.password); message.success('登录成功'); navigate('/admin') } + catch { message.error('密码错误') } + finally { setLoading(false) } } return (
- +
- -
+ border: '1px solid #EBE5DB', + padding: 56, + }}> +
{brand.brand_logo_url && ( - {brand.brand_name} + {brand.brand_name} )} - + <Title style={{ margin: '0 0 8px', fontSize: 28, fontWeight: 600, letterSpacing: '-0.02em' }}> {brand.brand_name} - + {brand.brand_tagline || '管理控制台'}
- + } + prefix={} placeholder="管理员密码" - style={{ height: 48 }} + style={{ height: 48, borderRadius: 8 }} /> - -
-
- - +
+
) } diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 04ff582..703e5ab 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Row, Col, Card, Statistic, Typography, Table, Tag, Skeleton } from 'antd' +import { Row, Col, Statistic, Table, Tag, Typography } from 'antd' import { SignalFilled, ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons' import { fetchStreams, type StreamInfo } from '../../api/streams' import { useBrand } from '../../brand/BrandContext' @@ -11,11 +11,7 @@ export default function Dashboard() { const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(true) - useEffect(() => { - loadData() - const timer = setInterval(loadData, 10000) - return () => clearInterval(timer) - }, []) + useEffect(() => { loadData(); const t = setInterval(loadData, 10000); return () => clearInterval(t) }, []) async function loadData() { try { setStreams(await fetchStreams()) } catch { /* */ } @@ -34,47 +30,63 @@ export default function Dashboard() { const columns = [ { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true }, - { title: '状态', dataIndex: 'status', key: 'status', width: 100, + { title: '状态', dataIndex: 'status', key: 'status', width: 90, render: (s: string) => {(statusMap[s] || { label: s }).label} }, { title: '创建时间', dataIndex: 'created_at', key: 'created_at', render: (v: string) => v?.slice(0, 16), width: 170 }, ] return (
-
- {brand.brand_name} - 管理控制台 +
+ + {brand.brand_name} + + 管理控制台
- + - +
} /> - + prefix={} /> +
- +
} /> - + prefix={} /> +
- +
} /> - + prefix={} /> +
- 最近直播} - style={{ borderRadius: 12 }} - > +
+ 最近直播 - + ) } diff --git a/frontend/src/admin/pages/NodeManager.tsx b/frontend/src/admin/pages/NodeManager.tsx index db2fab6..5786949 100644 --- a/frontend/src/admin/pages/NodeManager.tsx +++ b/frontend/src/admin/pages/NodeManager.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Card, Table, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd' +import { Table, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Progress } from 'antd' import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons' import api from '../../api/client' @@ -9,9 +9,7 @@ interface NodeMetrics { cpu_percent: number | null; memory_percent: number | nul interface NodeInfo { id: string; name: string; host: string; hls_port: number; api_port: number; weight: number; enabled: boolean; subnet: string; is_origin: boolean; online: boolean; metrics: NodeMetrics | null } interface StrategyInfo { strategy: string; multicast_enabled: boolean; multicast_available: boolean } -const strategyLabels: Record = { - 'subnet': '子网优先', 'round-robin': '轮询', 'weighted': '权重', 'least-conn': '最少连接', -} +const sL: Record = { 'subnet': '子网优先', 'round-robin': '轮询', 'weighted': '权重', 'least-conn': '最少连接' } export default function NodeManager() { const [nodes, setNodes] = useState([]) @@ -22,10 +20,8 @@ export default function NodeManager() { async function loadData() { setLoading(true) - try { - const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')]) - setNodes(nr.data); setStrategy(sr.data) - } catch { message.error('加载失败') } + try { const [nr, sr] = await Promise.all([api.get('/nodes'), api.get('/nodes/strategy')]); setNodes(nr.data); setStrategy(sr.data) } + catch { message.error('加载失败') } setLoading(false) } async function updateWeight(nid: string, w: number) { @@ -33,110 +29,89 @@ export default function NodeManager() { } async function toggleNode(nid: string, en: boolean) { const node = nodes.find(n => n.id === nid); if (!node) return - try { await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }); setNodes(p => p.map(n => n.id === nid ? { ...n, enabled: en } : n)); message.success('已更新') } catch { message.error('更新失败') } + try { await api.put(`/nodes/${nid}`, { weight: node.weight, enabled: en }); setNodes(p => p.map(n => n.id === nid ? { ...n, enabled: en } : n)) } catch { message.error('更新失败') } } const onlineCount = nodes.filter(n => n.online).length - const offlineCount = nodes.length - onlineCount const columns = [ { - title: '', dataIndex: 'online', key: 'online', width: 48, - render: (v: boolean, r: NodeInfo) => ( -
+ title: '', dataIndex: 'online', key: 'online', width: 40, + render: (v: boolean) => ( +
), }, { title: '名称', dataIndex: 'name', key: 'name', - render: (v: string, r: NodeInfo) => {v}{r.is_origin ? Origin : null} }, + render: (v: string, r: NodeInfo) => {v}{r.is_origin && Origin} }, { title: '地址', dataIndex: 'host', key: 'host', width: 130 }, - { title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, - render: (v: string) => {v} }, - { - title: 'CPU', key: 'cpu', width: 80, + { title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => {v} }, + { title: 'CPU', key: 'cpu', width: 80, render: (_: any, r: NodeInfo) => r.metrics?.cpu_percent != null - ? 80 ? '#B54A4A' : r.metrics.cpu_percent > 60 ? '#C2873A' : '#C2693A' - } showInfo={false} /> - : --, - }, - { - title: '内存', key: 'mem', width: 80, - render: (_: any, r: NodeInfo) => r.metrics?.memory_percent != null - ? 80 ? '#B54A4A' : r.metrics.memory_percent > 60 ? '#C2873A' : '#C2693A' - } showInfo={false} /> - : --, - }, - { title: '带宽', key: 'bw', width: 85, - render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null - ? {r.metrics.bandwidth_mbps.toFixed(1)} Mbps + ? 80 ? '#B54A4A' : r.metrics.cpu_percent > 60 ? '#C28B3A' : '#C2693A'} /> : -- }, + { title: '内存', key: 'mem', width: 80, + render: (_: any, r: NodeInfo) => r.metrics?.memory_percent != null + ? 80 ? '#B54A4A' : r.metrics.memory_percent > 60 ? '#C28B3A' : '#C2693A'} /> + : -- }, + { title: '带宽', key: 'bw', width: 85, + render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null ? {r.metrics.bandwidth_mbps.toFixed(1)} Mbps : -- }, { title: '连接', key: 'conn', width: 60, - render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null - ? r.metrics.active_connections : -- }, - { - title: '权重', dataIndex: 'weight', key: 'weight', width: 130, - render: (_: number, r: NodeInfo) => ( - { const val = v || 0; setNodes(p => p.map(n => n.id === r.id ? { ...n, weight: val } : n)); updateWeight(r.id, val) }} /> - ), - }, - { - title: '启用', dataIndex: 'enabled', key: 'enabled', width: 60, - render: (_: any, r: NodeInfo) => toggleNode(r.id, v)} />, - }, + render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null ? r.metrics.active_connections : -- }, + { title: '权重', dataIndex: 'weight', key: 'weight', width: 120, + render: (_: number, r: NodeInfo) => { const val = v || 0; setNodes(p => p.map(n => n.id === r.id ? { ...n, weight: val } : n)); updateWeight(r.id, val) }} /> }, + { title: '启用', dataIndex: 'enabled', key: 'enabled', width: 60, + render: (_: any, r: NodeInfo) => toggleNode(r.id, v)} /> }, ] return (
-
- 边缘节点 - 管理 CDN 边缘节点集群,监控实时状态 +
+ 边缘节点 + 管理 CDN 边缘节点集群,监控实时状态
{strategy && ( - +
- - - +
+ +
- +
- + valueStyle={{ color: strategy.multicast_available ? '#5B8C5A' : '#949088', fontSize: 20 }} /> +
- - 0 ? '#5B8C5A' : '#B54A4A', fontSize: 22 }} - suffix={/ {nodes.length}} /> - +
+ / {nodes.length}} + valueStyle={{ color: onlineCount > 0 ? '#5B8C5A' : '#B54A4A', fontSize: 20 }} /> +
- - 0 ? '#B54A4A' : '#B0ABA3', fontSize: 22 }} - prefix={offlineCount > 0 ? : undefined} /> - +
+ 0 ? '#B54A4A' : '#949088', fontSize: 20 }} + prefix={(nodes.length - onlineCount) > 0 ? : undefined} /> +
)} - 节点列表} - extra={} - style={{ borderRadius: 12 }} - > +
+
+ 节点列表 + +
- + ) } diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index 53e602d..ce6f506 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { Card, Select, Switch, Form, Input, Button, Typography, message } from 'antd' +import { Select, Switch, Form, Input, Button, Typography, message } from 'antd' import api from '../../api/client' import { useBrand } from '../../brand/BrandContext' @@ -14,12 +14,10 @@ const strategies = [ export default function SettingsPage() { const { brand, refresh } = useBrand() - 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) @@ -29,54 +27,37 @@ export default function SettingsPage() { useEffect(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand]) async function loadSettings() { - try { - const { data } = await api.get('/nodes/strategy') - setStrategy(data.strategy) - setMulticastEnabled(data.multicast_enabled) - setMulticastAvailable(data.multicast_available) - } catch { /* */ } + try { const { data } = await api.get('/nodes/strategy'); setStrategy(data.strategy); setMulticastEnabled(data.multicast_enabled); setMulticastAvailable(data.multicast_available) } catch { /* */ } } - async function saveStrategy(val: string) { setSaving(true) - try { - await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }) - setStrategy(val) - message.success('策略已更新') - } catch { message.error('更新失败') } + 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('更新失败') } + 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('保存失败') } + 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) } return (
- 系统设置 +
+ 系统设置 + 配置负载均衡策略与品牌信息 +
- +
+ 负载均衡
- - @@ -85,29 +66,28 @@ export default function SettingsPage() { - +
- +
+ 品牌设置
- setBrandName(e.target.value)} placeholder="StreamCore" size="large" /> + setBrandName(e.target.value)} size="large" style={{ borderRadius: 8 }} /> - setBrandTagline(e.target.value)} placeholder="企业级直播平台" size="large" /> + setBrandTagline(e.target.value)} size="large" style={{ borderRadius: 8 }} /> - setBrandLogoUrl(e.target.value)} placeholder="https://..." /> + setBrandLogoUrl(e.target.value)} style={{ borderRadius: 8 }} placeholder="https://..." /> - - - 保存后全站立即生效 - + 保存后全站立即生效 - +
) } diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx index 6b747ab..51541d5 100644 --- a/frontend/src/admin/pages/StreamForm.tsx +++ b/frontend/src/admin/pages/StreamForm.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Input, Button, DatePicker, Typography, message, Space, Alert, Divider } from 'antd' +import { Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd' import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons' import { createStream } from '../../api/streams' @@ -15,12 +15,7 @@ export default function StreamForm() { const handleSubmit = async (values: any) => { setLoading(true) try { - const res = await createStream({ - title: values.title, - description: values.description, - cover_url: values.cover_url, - scheduled_at: values.scheduled_at?.toISOString(), - }) + const res = await createStream({ title: values.title, description: values.description, cover_url: values.cover_url, scheduled_at: values.scheduled_at?.toISOString() }) setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key }) message.success('直播已创建') } catch { message.error('创建失败') } @@ -30,45 +25,33 @@ export default function StreamForm() { if (result) { return (
-
- - 直播已创建 - 将以下信息提供给推流端即可开始直播 +
+ + 直播已创建 + 将以下信息提供给推流端即可开始直播
- - +
+
- 推流码 -
+ 推流码 +
{result.stream_key}
- RTMP 推流地址 -
+ RTMP 推流地址 +
{result.rtmp_url}
- +
@@ -79,34 +62,36 @@ export default function StreamForm() { return (
+ style={{ marginBottom: 20, color: '#5E5A52' }}>返回列表 - - 创建直播 - 填写直播信息,系统将生成推流码与 RTMP 地址 +
+ 创建直播 + + 填写直播信息,系统将生成推流码与 RTMP 地址 +
- + -