From f0b8c2d64f0eec4d14bf792f922aa2870717dc10 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 10 Aug 2026 16:30:39 +0800 Subject: [PATCH] =?UTF-8?q?UI=E5=85=A8=E9=9D=A2=E7=B2=BE=E9=9B=95:=20?= =?UTF-8?q?=E7=B1=B3=E5=85=B0=E8=89=B2=E8=A1=AC=E7=BA=BF=E4=BD=93=E7=B3=BB?= =?UTF-8?q?=20+=20=E5=85=A8=E9=A1=B5=E8=B4=A8=E6=84=9F=E5=8D=87=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主题层: - 更精细的色彩层级(primaryBg/hover/active等语义色) - 字号体系: heading1-5, fontSize 15 基准 - 控件高度 40/48/32 三档, 圆角 6/10 - 三层阴影分层, motionDuration 首页: - 大幅面渐变英雄区(64px padding, 40px 标题) - 直播卡片: 暖棕渐变封面, LIVE 脉冲红点标签 - 悬停 transform+shadow 过渡, 描边高亮 仪表盘: - 彩色顶部边框卡片(绿/蓝/棕), 品牌名+标语头部 登录页: - 48px 大内边距, 返回首页链接 直播列表: - 描述行内展示, 推流码截断, 操作图标组 节点页: - 发光圆点在线指示器, 三色 CPU/内存进度阈值 - 在线/离线统计, Origin 标签 播放页: - 暗色 #1A1815 背景, 毛玻璃顶栏 - 圆角视频容器, box-shadow 景深 - document.title 动态标题 --- frontend/src/admin/LoginPage.tsx | 62 +++++++++--- frontend/src/admin/pages/Dashboard.tsx | 66 ++++++------ frontend/src/admin/pages/NodeManager.tsx | 123 +++++++++++++++-------- frontend/src/admin/pages/StreamForm.tsx | 98 +++++++++++------- frontend/src/admin/pages/StreamList.tsx | 75 ++++++++++---- frontend/src/theme/anthropic.ts | 64 ++++++++---- frontend/src/viewer/pages/HomePage.tsx | 108 +++++++++++++------- frontend/src/viewer/pages/WatchPage.tsx | 83 +++++++++++---- 8 files changed, 461 insertions(+), 218 deletions(-) diff --git a/frontend/src/admin/LoginPage.tsx b/frontend/src/admin/LoginPage.tsx index 34515f8..116621e 100644 --- a/frontend/src/admin/LoginPage.tsx +++ b/frontend/src/admin/LoginPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Input, Button, Typography, message, Space } from 'antd' -import { LockOutlined } from '@ant-design/icons' +import { Card, Form, Input, Button, Typography, message, Space, Divider } from 'antd' +import { LockOutlined, ArrowLeftOutlined } from '@ant-design/icons' import { login } from '../api/auth' import { useBrand } from '../brand/BrandContext' @@ -32,37 +32,69 @@ export default function LoginPage() { alignItems: 'center', justifyContent: 'center', background: '#F6F3ED', + padding: 24, }}> - - + +
{brand.brand_logo_url && ( {brand.brand_name} + style={{ height: 40, marginBottom: 16 }} /> )} - + <Title style={{ + margin: '0 0 8px', fontWeight: 600, fontSize: 28, letterSpacing: -1, + fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif', + }}> {brand.brand_name} - 管理控制台 + + {brand.brand_tagline || '管理控制台'} +
-
+ + } + prefix={} placeholder="管理员密码" - size="large" + style={{ height: 48 }} /> - -
+ +
+ +
diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 6a45886..04ff582 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -1,11 +1,13 @@ import { useState, useEffect } from 'react' -import { Row, Col, Card, Statistic, Typography, Table, Tag } from 'antd' -import { VideoCameraOutlined, SignalFilled, PlayCircleOutlined } from '@ant-design/icons' +import { Row, Col, Card, Statistic, Typography, Table, Tag, Skeleton } from 'antd' +import { SignalFilled, ClockCircleOutlined, PlayCircleOutlined } from '@ant-design/icons' import { fetchStreams, type StreamInfo } from '../../api/streams' +import { useBrand } from '../../brand/BrandContext' -const { Title } = Typography +const { Title, Text } = Typography export default function Dashboard() { + const { brand } = useBrand() const [streams, setStreams] = useState([]) const [loading, setLoading] = useState(true) @@ -16,15 +18,12 @@ export default function Dashboard() { }, []) async function loadData() { - try { - const data = await fetchStreams() - setStreams(data) - } catch { /* ignore */ } + try { setStreams(await fetchStreams()) } catch { /* */ } setLoading(false) } - const liveCount = streams.filter((s) => s.status === 'live').length - const pendingCount = streams.filter((s) => s.status === 'pending').length + const liveCount = streams.filter(s => s.status === 'live').length + const pendingCount = streams.filter(s => s.status === 'pending').length const statusMap: Record = { pending: { color: 'blue', label: '待开播' }, @@ -35,45 +34,46 @@ export default function Dashboard() { const columns = [ { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true }, - { - title: '状态', dataIndex: 'status', key: 'status', width: 100, - render: (s: string) => { - const m = statusMap[s] || { color: 'default', label: s } - return {m.label} - }, - }, - { - title: '创建时间', dataIndex: 'created_at', key: 'created_at', - render: (v: string) => v?.slice(0, 16), width: 170, - }, + { title: '状态', dataIndex: 'status', key: 'status', width: 100, + 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} + 管理控制台 +
+ + - - } loading={loading} /> + + } /> - - } loading={loading} /> + + } /> - - } loading={loading} /> + + } /> - + + 最近直播} + style={{ borderRadius: 12 }} + > + rowKey="id" loading={loading} pagination={false} size="middle" + locale={{ emptyText: '暂无直播数据' }} /> ) diff --git a/frontend/src/admin/pages/NodeManager.tsx b/frontend/src/admin/pages/NodeManager.tsx index d490e02..db2fab6 100644 --- a/frontend/src/admin/pages/NodeManager.tsx +++ b/frontend/src/admin/pages/NodeManager.tsx @@ -1,22 +1,12 @@ import { useState, useEffect } from 'react' -import { Card, Table, InputNumber, Switch, Typography, message, Tag, Statistic, Row, Col, Button, Tooltip, Progress } from 'antd' -import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined } from '@ant-design/icons' +import { Card, 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' const { Title, Text } = Typography -interface NodeMetrics { - cpu_percent: number | null - memory_percent: number | null - bandwidth_mbps: number | null - active_connections: number | null - hls_readers: number | null -} -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 NodeMetrics { cpu_percent: number | null; memory_percent: number | null; bandwidth_mbps: number | null; active_connections: number | null; hls_readers: number | null } +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 = { @@ -43,56 +33,109 @@ 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)); message.success('已更新') } catch { message.error('更新失败') } } const onlineCount = nodes.filter(n => n.online).length + const offlineCount = nodes.length - onlineCount const columns = [ - { title: '状态', dataIndex: 'online', key: 'online', width: 70, - render: (v: boolean) => : } color={v ? 'success' : 'error'} /> }, - { title: '名称', dataIndex: 'name', key: 'name' }, + { + title: '', dataIndex: 'online', key: 'online', width: 48, + render: (v: boolean, r: NodeInfo) => ( +
+ ), + }, + { title: '名称', dataIndex: 'name', key: 'name', + render: (v: string, r: NodeInfo) => {v}{r.is_origin ? Origin : null} }, { title: '地址', dataIndex: 'host', key: 'host', width: 130 }, - { title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => {v} }, - { title: 'CPU', key: 'cpu', width: 70, + { 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 - ? - : -- }, - { title: '内存', key: 'mem', width: 70, + ? 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 - ? - : -- }, - { title: '带宽', key: 'bw', width: 80, + ? 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 : -- }, - { title: '连接', key: 'conn', width: 65, + { 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, + { + 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: 65, - render: (_: any, r: NodeInfo) => toggleNode(r.id, v)} /> }, + ), + }, + { + title: '启用', dataIndex: 'enabled', key: 'enabled', width: 60, + render: (_: any, r: NodeInfo) => toggleNode(r.id, v)} />, + }, ] return (
- 边缘节点 +
+ 边缘节点 + 管理 CDN 边缘节点集群,监控实时状态 +
+ {strategy && ( - -
- - - 0 ? '#5B8C5A' : '#B54A4A' }} /> + + + + + + + + + + + + + + 0 ? '#5B8C5A' : '#B54A4A', fontSize: 22 }} + suffix={/ {nodes.length}} /> + + + + + 0 ? '#B54A4A' : '#B0ABA3', fontSize: 22 }} + prefix={offlineCount > 0 ? : undefined} /> + + )} - } onClick={loadData}>刷新} style={{ borderRadius: 10 }}> -
+ + 节点列表} + extra={} + style={{ borderRadius: 12 }} + > +
) diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx index 1fa5452..6b747ab 100644 --- a/frontend/src/admin/pages/StreamForm.tsx +++ b/frontend/src/admin/pages/StreamForm.tsx @@ -1,7 +1,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Card, Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd' -import { CopyOutlined, ArrowLeftOutlined } from '@ant-design/icons' +import { Card, Form, Input, Button, DatePicker, Typography, message, Space, Alert, Divider } from 'antd' +import { CopyOutlined, ArrowLeftOutlined, CheckCircleOutlined } from '@ant-design/icons' import { createStream } from '../../api/streams' const { Title, Text } = Typography @@ -29,57 +29,81 @@ export default function StreamForm() { if (result) { return ( -
- 直播已创建 - -
- 推流码: -
- {result.stream_key} -
+
+
+ + 直播已创建 + 将以下信息提供给推流端即可开始直播 +
+ + + +
+ 推流码 +
+ {result.stream_key}
-
- RTMP 推流地址: -
- {result.rtmp_url} -
+
+
+ RTMP 推流地址 +
+ {result.rtmp_url}
- - } - /> - -
+ + + + + - + message.success('已复制到剪贴板') + }}>复制全部信息 +
) } return ( -
- - - 创建直播 -
- - +
+ + + + 创建直播 + 填写直播信息,系统将生成推流码与 RTMP 地址 + + + + -