From efde5597bdb15eef4a727e07bd2c8985e5fbe843 Mon Sep 17 00:00:00 2001 From: admin Date: Mon, 10 Aug 2026 16:24:45 +0800 Subject: [PATCH] =?UTF-8?q?=E5=93=81=E7=89=8C=E5=8C=96=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=20+=20Claude=E7=B1=B3=E8=89=B2=E8=A1=AC=E7=BA=BF=E4=BD=93?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=20+=20=E5=85=A8=E7=AB=99=E4=B8=AD=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 品牌设置API: GET/PUT /api/settings/brand (名称/标语/Logo) - 管理页系统设置: 品牌编辑器表单(名称/标语/Logo URL) - 恢复Anthropic风格: 米色(#FBF9F6) 底色, Source Serif 4 + Noto Serif SC 衬线体 - 暖棕(#C2693A) 主色调, 更充裕的间距与留白, 庄重大气 - 全站中文: antd zhCN locale, 所有页面文本汉化 - 数据存储在 Config singleton, 运行时更新 --- backend/config.py | 4 + backend/main.py | 2 + backend/routes/settings_route.py | 33 ++++ frontend/index.html | 3 + frontend/src/App.tsx | 3 +- frontend/src/admin/AdminLayout.tsx | 65 +++----- frontend/src/admin/LoginPage.tsx | 21 +-- frontend/src/admin/pages/Dashboard.tsx | 74 ++++----- frontend/src/admin/pages/NodeManager.tsx | 195 ++++++----------------- frontend/src/admin/pages/Settings.tsx | 107 +++++++------ frontend/src/admin/pages/StreamForm.tsx | 90 +++-------- frontend/src/admin/pages/StreamList.tsx | 105 ++++-------- frontend/src/global.css | 3 +- frontend/src/theme/anthropic.ts | 110 ++++++++----- frontend/src/viewer/pages/HomePage.tsx | 85 ++++------ frontend/src/viewer/pages/WatchPage.tsx | 175 ++++---------------- 16 files changed, 403 insertions(+), 672 deletions(-) create mode 100644 backend/routes/settings_route.py diff --git a/backend/config.py b/backend/config.py index 23657fe..13b010c 100644 --- a/backend/config.py +++ b/backend/config.py @@ -38,6 +38,10 @@ class Settings(BaseSettings): multicast_address_base: str = "239.0.0.1" multicast_port_base: int = 5000 + brand_name: str = "StreamCore" + brand_tagline: str = "Enterprise Streaming Platform" + brand_logo_url: str = "" + model_config = {"env_prefix": "LS_"} diff --git a/backend/main.py b/backend/main.py index 99eaea5..b19c21f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -9,6 +9,7 @@ import httpx from database import init_db from services.transcoder import transcode_mgr from routes import streams_router, nodes_router, auth_router +from routes.settings_route import router as settings_router from config import settings @@ -32,6 +33,7 @@ app.add_middleware( app.include_router(streams_router) app.include_router(nodes_router) app.include_router(auth_router) +app.include_router(settings_router) @app.get("/api/health") diff --git a/backend/routes/settings_route.py b/backend/routes/settings_route.py new file mode 100644 index 0000000..2067e6d --- /dev/null +++ b/backend/routes/settings_route.py @@ -0,0 +1,33 @@ +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from typing import Optional +from config import settings +from routes.auth import require_auth + +router = APIRouter(prefix="/api/settings", tags=["settings"]) + + +class BrandUpdate(BaseModel): + brand_name: Optional[str] = None + brand_tagline: Optional[str] = None + brand_logo_url: Optional[str] = None + + +@router.get("/brand") +async def get_brand(): + return { + "brand_name": settings.brand_name, + "brand_tagline": settings.brand_tagline, + "brand_logo_url": settings.brand_logo_url, + } + + +@router.put("/brand") +async def update_brand(body: BrandUpdate, _: bool = Depends(require_auth)): + if body.brand_name is not None: + settings.brand_name = body.brand_name + if body.brand_tagline is not None: + settings.brand_tagline = body.brand_tagline + if body.brand_logo_url is not None: + settings.brand_logo_url = body.brand_logo_url + return {"ok": True} diff --git a/frontend/index.html b/frontend/index.html index 77805a7..9f1447a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,6 +4,9 @@ StreamCore + + +
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ea8dbb..63854ff 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' import theme from './theme/anthropic' import AdminLayout from './admin/AdminLayout' import LoginPage from './admin/LoginPage' @@ -30,7 +31,7 @@ function AdminGuard({ children }: { children: React.ReactNode }) { export default function App() { return ( - + } /> diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index 5a6b73a..a81fce7 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -1,13 +1,9 @@ import { useState } from 'react' -import { Outlet, useNavigate, useLocation, Link } from 'react-router-dom' +import { Outlet, useNavigate, useLocation } from 'react-router-dom' import { Layout, Menu, Button, Typography } from 'antd' import { - DashboardOutlined, - VideoCameraOutlined, - PlusOutlined, - CloudServerOutlined, - SettingOutlined, - LogoutOutlined, + DashboardOutlined, VideoCameraOutlined, PlusOutlined, + CloudServerOutlined, SettingOutlined, LogoutOutlined, } from '@ant-design/icons' import { clearToken } from '../api/client' @@ -15,11 +11,11 @@ const { Sider, Content, Header } = Layout const { Text } = Typography const menuItems = [ - { key: '/admin', icon: , label: 'Dashboard' }, - { key: '/admin/streams', icon: , label: 'Streams' }, - { key: '/admin/streams/new', icon: , label: 'New Stream' }, - { key: '/admin/nodes', icon: , label: 'Nodes' }, - { key: '/admin/settings', icon: , label: 'Settings' }, + { key: '/admin', icon: , label: '概览' }, + { key: '/admin/streams', icon: , label: '直播列表' }, + { key: '/admin/streams/new', icon: , label: '创建直播' }, + { key: '/admin/nodes', icon: , label: '边缘节点' }, + { key: '/admin/settings', icon: , label: '系统设置' }, ] export default function AdminLayout() { @@ -39,55 +35,36 @@ export default function AdminLayout() { return (
{collapsed ? 'SC' : 'StreamCore'}
navigate(key)} style={{ border: 'none', marginTop: 8 }} />
-
diff --git a/frontend/src/admin/LoginPage.tsx b/frontend/src/admin/LoginPage.tsx index f3d1750..e0039fc 100644 --- a/frontend/src/admin/LoginPage.tsx +++ b/frontend/src/admin/LoginPage.tsx @@ -14,10 +14,10 @@ export default function LoginPage() { setLoading(true) try { await login(values.password) - message.success('Login successful') + message.success('登录成功') navigate('/admin') } catch { - message.error('Invalid password') + message.error('密码错误') } finally { setLoading(false) } @@ -29,28 +29,31 @@ export default function LoginPage() { display: 'flex', alignItems: 'center', justifyContent: 'center', - background: 'linear-gradient(135deg, #1E3A5F 0%, #2D5F8A 50%, #F5F6FA 50%)', + background: '#F6F3ED', }}> - +
- StreamCore - Admin Console + + StreamCore + + 管理控制台
} - placeholder="Password" + placeholder="管理员密码" size="large" />
diff --git a/frontend/src/admin/pages/Dashboard.tsx b/frontend/src/admin/pages/Dashboard.tsx index 03b5d23..6a45886 100644 --- a/frontend/src/admin/pages/Dashboard.tsx +++ b/frontend/src/admin/pages/Dashboard.tsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react' -import { Row, Col, Card, Statistic, Typography, Table, Tag, Space } from 'antd' +import { Row, Col, Card, Statistic, Typography, Table, Tag } from 'antd' import { VideoCameraOutlined, SignalFilled, PlayCircleOutlined } from '@ant-design/icons' import { fetchStreams, type StreamInfo } from '../../api/streams' -const { Title, Text } = Typography +const { Title } = Typography export default function Dashboard() { const [streams, setStreams] = useState([]) @@ -27,73 +27,53 @@ export default function Dashboard() { const pendingCount = streams.filter((s) => s.status === 'pending').length const statusMap: Record = { - pending: { color: 'blue', label: 'Scheduled' }, - live: { color: 'green', label: 'Live' }, - ended: { color: 'default', label: 'Ended' }, - cancelled: { color: 'red', label: 'Cancelled' }, + pending: { color: 'blue', label: '待开播' }, + live: { color: 'green', label: '直播中' }, + ended: { color: 'default', label: '已结束' }, + cancelled: { color: 'red', label: '已取消' }, } - const recentColumns = [ - { title: 'Title', dataIndex: 'title', key: 'title', ellipsis: true }, + const columns = [ + { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true }, { - title: 'Status', dataIndex: 'status', key: 'status', - render: (status: string) => { - const s = statusMap[status] || { color: 'default', label: status } - return {s.label} + title: '状态', dataIndex: 'status', key: 'status', width: 100, + render: (s: string) => { + const m = statusMap[s] || { color: 'default', label: s } + return {m.label} }, - width: 110, }, { - title: 'Created', dataIndex: 'created_at', key: 'created_at', - render: (v: string) => v?.slice(0, 16), - width: 170, + title: '创建时间', dataIndex: 'created_at', key: 'created_at', + render: (v: string) => v?.slice(0, 16), width: 170, }, ] return (
- Overview + 概览 - - } - loading={loading} - /> + + } loading={loading} /> - - } - loading={loading} - /> + + } loading={loading} /> - - } - loading={loading} - /> + + } loading={loading} /> - - + +
) diff --git a/frontend/src/admin/pages/NodeManager.tsx b/frontend/src/admin/pages/NodeManager.tsx index 7b31003..d490e02 100644 --- a/frontend/src/admin/pages/NodeManager.tsx +++ b/frontend/src/admin/pages/NodeManager.tsx @@ -1,8 +1,6 @@ import { useState, useEffect } from 'react' -import { - Card, Table, InputNumber, Switch, Typography, message, Space, Tag, Statistic, Row, Col, Button, Tooltip, Progress, -} from 'antd' -import { CheckCircleOutlined, CloseCircleOutlined, ReloadOutlined, DashboardOutlined } from '@ant-design/icons' +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 api from '../../api/client' const { Title, Text } = Typography @@ -14,32 +12,15 @@ interface NodeMetrics { 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 + 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': 'Subnet-Aware', - 'round-robin': 'Round-Robin', - 'weighted': 'Weighted', - 'least-conn': 'Least-Conn', + 'subnet': '子网优先', 'round-robin': '轮询', 'weighted': '权重', 'least-conn': '最少连接', } export default function NodeManager() { @@ -52,146 +33,66 @@ export default function NodeManager() { async function loadData() { setLoading(true) try { - const [nodesRes, strategyRes] = await Promise.all([ - api.get('/nodes'), - api.get('/nodes/strategy'), - ]) - setNodes(nodesRes.data) - setStrategy(strategyRes.data) - } catch { message.error('Failed to load') } + 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(nodeId: string, weight: number) { - try { - await api.put(`/nodes/${nodeId}`, { weight }) - message.success('Updated') - } catch { message.error('Failed') } + async function updateWeight(nid: string, w: number) { + try { await api.put(`/nodes/${nid}`, { weight: w }); message.success('已更新') } catch { message.error('更新失败') } + } + 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('更新失败') } } - async function toggleNode(nodeId: string, enabled: boolean) { - const node = nodes.find((n) => n.id === nodeId) - if (!node) return - try { - await api.put(`/nodes/${nodeId}`, { weight: node.weight, enabled }) - setNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, enabled } : n))) - message.success('Updated') - } catch { message.error('Failed') } - } - - const onlineNodes = nodes.filter((n) => n.online).length + const onlineCount = nodes.filter(n => n.online).length const columns = [ - { - title: 'Status', dataIndex: 'online', key: 'online', width: 80, - render: (online: boolean) => ( - - : } - color={online ? 'success' : 'error'} /> - - ), - }, - { title: 'Name', dataIndex: 'name', key: 'name' }, - { title: 'Host', dataIndex: 'host', key: 'host', width: 140 }, - { - title: 'Subnet', dataIndex: 'subnet', key: 'subnet', width: 140, - render: (v: string) => {v}, - }, - { - title: 'CPU', key: 'cpu', width: 80, + { title: '状态', dataIndex: 'online', key: 'online', width: 70, + render: (v: boolean) => : } color={v ? 'success' : 'error'} /> }, + { title: '名称', dataIndex: 'name', key: 'name' }, + { title: '地址', dataIndex: 'host', key: 'host', width: 130 }, + { title: '子网', dataIndex: 'subnet', key: 'subnet', width: 130, render: (v: string) => {v} }, + { title: 'CPU', key: 'cpu', width: 70, render: (_: any, r: NodeInfo) => r.metrics?.cpu_percent != null - ? - : --, - }, - { - title: 'RAM', key: 'ram', width: 80, + ? + : -- }, + { title: '内存', key: 'mem', width: 70, render: (_: any, r: NodeInfo) => r.metrics?.memory_percent != null - ? - : --, - }, - { - title: 'BW', key: 'bw', width: 70, + ? + : -- }, + { title: '带宽', key: 'bw', width: 80, render: (_: any, r: NodeInfo) => r.metrics?.bandwidth_mbps != null ? {r.metrics.bandwidth_mbps.toFixed(1)} Mbps - : --, - }, - { - title: 'Conn', dataIndex: 'connections', key: 'conn', width: 60, + : -- }, + { title: '连接', key: 'conn', width: 65, render: (_: any, r: NodeInfo) => r.metrics?.active_connections != null - ? r.metrics.active_connections - : --, - }, - { - title: 'Weight', dataIndex: 'weight', key: 'weight', width: 140, - render: (_: number, record: NodeInfo) => ( - { - const v = val || 0 - setNodes((prev) => prev.map((n) => (n.id === record.id ? { ...n, weight: v } : n))) - updateWeight(record.id, v) - }} - size="small" style={{ width: 80 }} - disabled={record.is_origin} - /> - ), - }, - { - title: 'Enabled', dataIndex: 'enabled', key: 'enabled', width: 70, - render: (_: any, record: NodeInfo) => ( - toggleNode(record.id, val)} - size="small" disabled={record.is_origin} - /> - ), - }, + ? 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: 65, + render: (_: any, r: NodeInfo) => toggleNode(r.id, v)} /> }, ] return (
- Edge Nodes - + 边缘节点 {strategy && ( -
- - - - - - - - - - - - - - - - - 0 ? '#2E7D32' : '#C62828' }} /> - - + + + + 0 ? '#5B8C5A' : '#B54A4A' }} /> )} - - } onClick={loadData}>Refresh} - style={{ borderRadius: 12 }} - > -
+ } onClick={loadData}>刷新} style={{ borderRadius: 10 }}> +
) diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index bad1953..f5cb10c 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -1,27 +1,28 @@ import { useState, useEffect } from 'react' -import { - Card, Select, Switch, Typography, message, Space, Descriptions, Button, -} from 'antd' +import { Card, Select, Switch, Form, Input, Button, Typography, message, Divider } from 'antd' import api from '../../api/client' -const { Title } = Typography +const { Title, Text } = Typography const strategies = [ - { value: 'subnet', label: 'Subnet-Aware (Recommended)' }, - { value: 'weighted', label: 'Weighted' }, - { value: 'round-robin', label: 'Round-Robin' }, - { value: 'least-conn', label: 'Least Connections' }, + { value: 'subnet', label: '子网优先(推荐)' }, + { value: 'weighted', label: '权重均衡' }, + { value: 'round-robin', label: '轮询' }, + { value: 'least-conn', label: '最少连接' }, ] export default function SettingsPage() { - const [strategy, setStrategy] = useState('weighted') + const [strategy, setStrategy] = useState('subnet') const [multicastEnabled, setMulticastEnabled] = useState(true) const [multicastAvailable, setMulticastAvailable] = useState(false) const [saving, setSaving] = useState(false) - useEffect(() => { - loadSettings() - }, []) + const [brandName, setBrandName] = useState('StreamCore') + const [brandTagline, setBrandTagline] = useState('企业级直播平台') + const [brandLogoUrl, setBrandLogoUrl] = useState('') + const [brandSaving, setBrandSaving] = useState(false) + + useEffect(() => { loadSettings(); loadBrand() }, []) async function loadSettings() { try { @@ -29,56 +30,70 @@ export default function SettingsPage() { setStrategy(data.strategy) setMulticastEnabled(data.multicast_enabled) setMulticastAvailable(data.multicast_available) - } catch { /* ignore */ } + } catch { /* */ } + } + async function loadBrand() { + try { + const { data } = await api.get('/settings/brand') + setBrandName(data.brand_name || 'StreamCore') + setBrandTagline(data.brand_tagline || '') + setBrandLogoUrl(data.brand_logo_url || '') + } catch { /* */ } } async function saveStrategy(val: string) { setSaving(true) - try { - await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }) - setStrategy(val) - message.success('Strategy updated') - } catch { message.error('Update failed') } + 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('/nodes/strategy', { strategy, multicast_enabled: val }) - message.success('Multicast settings updated') - } catch { message.error('Update failed') } + await api.put('/settings/brand', { brand_name: brandName, brand_tagline: brandTagline, brand_logo_url: brandLogoUrl }) + message.success('品牌设置已保存') + } catch { message.error('保存失败') } + setBrandSaving(false) } return (
- System Settings + 系统设置 - - - - + + + + + {multicastAvailable ? '可用 — 优先使用组播分发' : '不可用 — 回退至单播'} + + + - - - - - - - - {multicastAvailable ? 'Available - using multicast' : 'Unavailable - fallback to unicast'} - - - + +
+ + setBrandName(e.target.value)} placeholder="StreamCore" /> + + + setBrandTagline(e.target.value)} placeholder="企业级直播平台" /> + + + setBrandLogoUrl(e.target.value)} placeholder="https://..." /> + + + + +
) diff --git a/frontend/src/admin/pages/StreamForm.tsx b/frontend/src/admin/pages/StreamForm.tsx index 19a78ee..1fa5452 100644 --- a/frontend/src/admin/pages/StreamForm.tsx +++ b/frontend/src/admin/pages/StreamForm.tsx @@ -1,8 +1,6 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { - Card, Form, Input, Button, DatePicker, Typography, message, Space, Alert, -} from 'antd' +import { Card, Form, Input, Button, DatePicker, Typography, message, Space, Alert } from 'antd' import { CopyOutlined, ArrowLeftOutlined } from '@ant-design/icons' import { createStream } from '../../api/streams' @@ -24,64 +22,39 @@ export default function StreamForm() { scheduled_at: values.scheduled_at?.toISOString(), }) setResult({ rtmp_url: res.rtmp_url, stream_key: res.stream_key }) - message.success('Stream created') - } catch { - message.error('Failed to create') - } + message.success('直播已创建') + } catch { message.error('创建失败') } setLoading(false) } if (result) { return (
- Stream Created - 直播已创建 +
- Stream Key (UUID): -
+ 推流码: +
{result.stream_key}
- RTMP Push URL: -
+ RTMP 推流地址: +
{result.rtmp_url}
} - showIcon - style={{ marginBottom: 24 }} /> - - + +
) @@ -89,37 +62,24 @@ export default function StreamForm() { return (
- - - New Stream + + + 创建直播
- - + + - -