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'}
- }
- onClick={handleLogout}
- >
- Logout
+ } onClick={handleLogout}>
+ 退出登录
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 }}
/>
- }
- onClick={() => {
- navigator.clipboard.writeText(
- `Stream Key: ${result.stream_key}\nRTMP URL: ${result.rtmp_url}`
- )
- message.success('Copied')
- }}
- >
- Copy All
-
-
+ } onClick={() => {
+ navigator.clipboard.writeText(`推流码: ${result.stream_key}\nRTMP 地址: ${result.rtmp_url}`)
+ message.success('已复制')
+ }}>复制全部
+
)
@@ -89,37 +62,24 @@ export default function StreamForm() {
return (
-
}
- onClick={() => navigate('/admin/streams')}
- style={{ marginBottom: 16 }}
- >
- Back
-
-
- New Stream
+ } onClick={() => navigate('/admin/streams')} style={{ marginBottom: 16 }}>返回
+
+ 创建直播
-
+
+
-
-
+
+
-
+
-
-
+
+
-
+
diff --git a/frontend/src/admin/pages/StreamList.tsx b/frontend/src/admin/pages/StreamList.tsx
index 3a2576d..77e61be 100644
--- a/frontend/src/admin/pages/StreamList.tsx
+++ b/frontend/src/admin/pages/StreamList.tsx
@@ -1,18 +1,16 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
-import {
- Table, Button, Tag, Space, Modal, Typography, message, Tooltip, Popconfirm,
-} from 'antd'
+import { Table, Button, Tag, Space, Typography, message, Tooltip, Popconfirm } from 'antd'
import { PlusOutlined, PlayCircleOutlined, StopOutlined, DeleteOutlined, LinkOutlined } from '@ant-design/icons'
import { fetchStreams, deleteStream, goLive, endStream, type StreamInfo } from '../../api/streams'
-const { Title, Text } = Typography
+const { Title } = Typography
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: '已取消' },
}
export default function StreamList() {
@@ -24,80 +22,40 @@ export default function StreamList() {
async function loadData() {
setLoading(true)
- try {
- const data = await fetchStreams()
- setStreams(data)
- } catch { message.error('Failed to load streams') }
+ try { setStreams(await fetchStreams()) } catch { message.error('加载失败') }
setLoading(false)
}
-
async function handleDelete(id: number) {
- try {
- await deleteStream(id)
- message.success('Deleted')
- loadData()
- } catch { message.error('Failed to delete') }
+ try { await deleteStream(id); message.success('已删除'); loadData() } catch { message.error('删除失败') }
}
-
async function handleGoLive(id: number) {
- try {
- await goLive(id)
- message.success('Stream is now live')
- loadData()
- } catch { message.error('Operation failed') }
+ try { await goLive(id); message.success('已开播'); loadData() } catch { message.error('操作失败') }
}
-
async function handleEnd(id: number) {
- try {
- await endStream(id)
- message.success('Stream ended')
- loadData()
- } catch { message.error('Operation failed') }
+ try { await endStream(id); message.success('已结束'); loadData() } catch { message.error('操作失败') }
}
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
- { title: 'Title', dataIndex: 'title', key: 'title', ellipsis: true },
+ { title: '标题', dataIndex: 'title', key: 'title', ellipsis: true },
+ { 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 },
{
- title: 'Status', dataIndex: 'status', key: 'status', width: 110,
- render: (s: string) => {
- const info = statusMap[s] || { color: 'default', label: s }
- return {info.label}
- },
- },
- {
- title: 'Created', dataIndex: 'created_at', key: 'created_at',
- render: (v: string) => v?.slice(0, 16), width: 170,
- },
- {
- title: 'Actions', key: 'actions', width: 280,
- render: (_: any, record: StreamInfo) => (
+ title: '操作', key: 'actions', width: 280,
+ render: (_: any, r: StreamInfo) => (
- }
- onClick={() => {
- navigator.clipboard.writeText(record.stream_key)
- message.success('Stream key copied')
- }}
- >
- Copy Key
+ }
+ onClick={() => { navigator.clipboard.writeText(r.stream_key); message.success('推流码已复制') }}>
+ 复制推流码
- {record.status !== 'live' ? (
-
- }
- onClick={() => handleGoLive(record.id)} ghost />
-
+ {r.status !== 'live' ? (
+ } onClick={() => handleGoLive(r.id)} ghost />
) : (
-
- }
- onClick={() => handleEnd(record.id)} danger />
-
+ } onClick={() => handleEnd(r.id)} danger />
)}
-
- handleDelete(record.id)}>
+
+ handleDelete(r.id)}>
} danger />
@@ -108,19 +66,10 @@ export default function StreamList() {
return (
-
Streams
- } onClick={() => navigate('/admin/streams/new')}>
- New Stream
-
+ 直播列表
+ } onClick={() => navigate('/admin/streams/new')}>创建直播
-
+
)
}
diff --git a/frontend/src/global.css b/frontend/src/global.css
index d974c43..e3f034c 100644
--- a/frontend/src/global.css
+++ b/frontend/src/global.css
@@ -1,7 +1,8 @@
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
+ font-family: "Source Serif 4", "Noto Serif SC", Georgia, "Times New Roman", serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- background: #F5F6FA;
+ background: #F6F3ED;
}
diff --git a/frontend/src/theme/anthropic.ts b/frontend/src/theme/anthropic.ts
index 1e7e452..d6e3753 100644
--- a/frontend/src/theme/anthropic.ts
+++ b/frontend/src/theme/anthropic.ts
@@ -2,70 +2,104 @@ import type { ThemeConfig } from 'antd'
const theme: ThemeConfig = {
token: {
- fontFamily: `-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", sans-serif`,
- fontSize: 14,
- colorPrimary: '#1E3A5F',
- colorSuccess: '#2E7D32',
- colorWarning: '#E65100',
- colorError: '#C62828',
- colorInfo: '#1565C0',
- colorTextBase: '#1A1A1A',
- colorBgBase: '#FFFFFF',
- colorBgContainer: '#FFFFFF',
- colorBgLayout: '#F5F6FA',
- colorBorder: '#E8ECF1',
- colorBorderSecondary: '#EDF0F4',
- borderRadius: 8,
- borderRadiusLG: 12,
+ fontFamily: `"Source Serif 4", "Noto Serif SC", Georgia, "Times New Roman", serif`,
+ fontSize: 15,
+ fontSizeLG: 16,
+ fontSizeXL: 20,
+ colorPrimary: '#C2693A',
+ colorSuccess: '#5B8C5A',
+ colorWarning: '#C2873A',
+ colorError: '#B54A4A',
+ colorInfo: '#6B8CA8',
+ colorTextBase: '#141410',
+ colorTextSecondary: '#5C5952',
+ colorTextTertiary: '#8B867D',
+ colorBgBase: '#FBF9F6',
+ colorBgContainer: '#FDFCFB',
+ colorBgLayout: '#F6F3ED',
+ colorBorder: '#E8E3D8',
+ colorBorderSecondary: '#F0EBE2',
+ borderRadius: 6,
+ borderRadiusLG: 10,
+ borderRadiusSM: 4,
wireframe: false,
- boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 1px 2px rgba(0,0,0,0.06)',
- boxShadowSecondary: '0 4px 12px rgba(0,0,0,0.06)',
+ lineHeight: 1.6,
+ fontWeightStrong: 600,
+ paddingLG: 32,
+ paddingMD: 24,
+ paddingSM: 16,
+ boxShadow: '0 1px 3px rgba(20,20,16,0.04), 0 1px 2px rgba(20,20,16,0.03)',
+ boxShadowSecondary: '0 6px 16px rgba(20,20,16,0.05), 0 2px 6px rgba(20,20,16,0.03)',
+ boxShadowTertiary: '0 12px 32px rgba(20,20,16,0.08), 0 4px 12px rgba(20,20,16,0.04)',
},
components: {
Layout: {
- headerBg: '#1E3A5F',
- headerHeight: 56,
- headerPadding: '0 32px',
- siderBg: '#FFFFFF',
- bodyBg: '#F5F6FA',
+ headerBg: '#FBF9F6',
+ headerHeight: 60,
+ headerPadding: '0 40px',
+ siderBg: '#FBF9F6',
+ bodyBg: '#F6F3ED',
},
Menu: {
itemBg: 'transparent',
- itemSelectedBg: '#E8F0FE',
- itemSelectedColor: '#1E3A5F',
- itemActiveBg: '#F0F4FA',
- itemColor: '#4A5568',
+ itemSelectedBg: '#EDE8DD',
+ itemSelectedColor: '#141410',
+ itemActiveBg: '#F0EBE2',
+ itemColor: '#5C5952',
+ itemHeight: 42,
+ fontSize: 14,
},
Card: {
- paddingLG: 24,
- borderRadiusLG: 12,
+ paddingLG: 28,
+ borderRadiusLG: 10,
+ colorBgContainer: '#FDFCFB',
},
Button: {
- borderRadius: 8,
+ borderRadius: 6,
fontWeight: 500,
- primaryShadow: '0 2px 4px rgba(30,58,95,0.2)',
+ controlHeight: 38,
+ controlHeightLG: 46,
+ controlHeightSM: 32,
+ primaryShadow: '0 2px 4px rgba(194,105,58,0.12)',
},
Input: {
- borderRadius: 8,
- activeBorderColor: '#1E3A5F',
- hoverBorderColor: '#4A7FA8',
+ borderRadius: 6,
+ activeBorderColor: '#C2693A',
+ hoverBorderColor: '#D4956B',
+ controlHeight: 38,
+ controlHeightLG: 46,
},
Select: {
- borderRadius: 8,
- activeBorderColor: '#1E3A5F',
+ borderRadius: 6,
+ activeBorderColor: '#C2693A',
+ controlHeight: 38,
},
Table: {
- headerBg: '#FAFBFC',
- rowHoverBg: '#F7F9FC',
+ headerBg: '#F6F3ED',
+ headerColor: '#5C5952',
+ rowHoverBg: '#F3EFE7',
+ borderColor: '#E8E3D8',
},
Modal: {
borderRadiusLG: 12,
+ paddingLG: 28,
},
Tag: {
borderRadiusSM: 4,
+ lineHeight: 1.6,
},
Statistic: {
- contentFontSize: 28,
+ contentFontSize: 30,
+ titleFontSize: 14,
+ },
+ Typography: {
+ titleMarginBottom: '0.5em',
+ titleMarginTop: '1.2em',
+ },
+ Tabs: {
+ inkBarColor: '#C2693A',
+ itemActiveColor: '#C2693A',
+ itemHoverColor: '#D4956B',
},
},
}
diff --git a/frontend/src/viewer/pages/HomePage.tsx b/frontend/src/viewer/pages/HomePage.tsx
index 8bc9244..e19f18f 100644
--- a/frontend/src/viewer/pages/HomePage.tsx
+++ b/frontend/src/viewer/pages/HomePage.tsx
@@ -1,18 +1,16 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
-import {
- Row, Col, Card, Typography, Tag, Button, Skeleton, Empty, Space,
-} from 'antd'
+import { Row, Col, Card, Typography, Tag, Skeleton, Empty, Space } from 'antd'
import { FireOutlined, ClockCircleOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams'
const { Title, Text } = Typography
const statusMeta: Record = {
- live: { color: 'red', label: 'LIVE', icon: },
- pending: { color: 'blue', label: 'SCHEDULED', icon: },
- ended: { color: 'default', label: 'ENDED', icon: null },
- cancelled: { color: 'default', label: 'CANCELLED', icon: null },
+ live: { color: 'red', label: '直播中', icon: },
+ pending: { color: 'blue', label: '待开播', icon: },
+ ended: { color: 'default', label: '已结束', icon: null },
+ cancelled: { color: 'default', label: '已取消', icon: null },
}
export default function HomePage() {
@@ -21,10 +19,7 @@ export default function HomePage() {
const navigate = useNavigate()
useEffect(() => {
- fetchStreams()
- .then(setStreams)
- .catch(() => {})
- .finally(() => setLoading(false))
+ fetchStreams().then(setStreams).catch(() => {}).finally(() => setLoading(false))
}, [])
const sorted = [...streams].sort((a, b) => {
@@ -33,60 +28,54 @@ export default function HomePage() {
})
return (
-
+
-
+
StreamCore
-
- Enterprise Streaming Platform
+
+ 企业级直播平台
-
-
Live Channels
-
+
+
直播频道
{loading ? (
- {[1, 2, 3, 4, 5, 6].map((i) => (
+ {[1,2,3,4,5,6].map(i => (
-
+
))}
) : sorted.length === 0 ? (
-
+
) : (
- {sorted.map((s) => {
+ {sorted.map(s => {
const meta = statusMeta[s.status] ?? statusMeta.pending
return (
-
{!s.cover_url && (
-
+
)}
-
+
{meta.icon} {meta.label}
@@ -97,16 +86,8 @@ export default function HomePage() {
onClick={() => navigate(`/watch/${s.id}`)}
>
- {s.title}
-
- }
- description={
-
- {s.description || 'No description'}
-
- }
+ title={{s.title}}
+ description={{s.description || '暂无简介'}}
/>
@@ -115,14 +96,6 @@ export default function HomePage() {
)}
-
-
- StreamCore © {new Date().getFullYear()}
-
-
)
}
diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx
index a1f94cc..ef18fea 100644
--- a/frontend/src/viewer/pages/WatchPage.tsx
+++ b/frontend/src/viewer/pages/WatchPage.tsx
@@ -1,8 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
-import {
- Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message,
-} from 'antd'
+import { Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message } from 'antd'
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
@@ -23,10 +21,7 @@ export default function WatchPage() {
if (!id) return
loadInfo()
const timer = setInterval(loadInfo, 15000)
- return () => {
- clearInterval(timer)
- if (hls) { hls.destroy() }
- }
+ return () => { clearInterval(timer); if (hls) hls.destroy() }
}, [id])
async function loadInfo() {
@@ -37,176 +32,76 @@ export default function WatchPage() {
if (!activeLine && data.lines.length > 0) {
const best = autoSelectLine(data.lines)
setActiveLine(best.node_id)
- if (data.qualities.length > 0) {
- setActiveQuality(data.qualities[0].label)
- }
+ if (data.qualities.length > 0) setActiveQuality(data.qualities[0].label)
}
} catch { }
setLoading(false)
}
function autoSelectLine(lines: LineInfo[]): LineInfo {
- const enabled = lines.filter((l) => l.weight > 0)
+ const enabled = lines.filter(l => l.weight > 0)
if (enabled.length === 0) return lines[0]
const total = enabled.reduce((s, l) => s + l.weight, 0)
let r = Math.random() * total
- for (const line of enabled) {
- r -= line.weight
- if (r <= 0) return line
- }
+ for (const line of enabled) { r -= line.weight; if (r <= 0) return line }
return enabled[enabled.length - 1]
}
useEffect(() => {
if (!info || !activeLine || !videoRef.current) return
- const line = info.lines.find((l) => l.node_id === activeLine)
+ const line = info.lines.find(l => l.node_id === activeLine)
if (!line) return
- const quality = activeQuality
- ? line.qualities.find((q) => q.label === activeQuality)
- : line.qualities[0]
+ const quality = activeQuality ? line.qualities.find(q => q.label === activeQuality) : line.qualities[0]
if (!quality) return
- if (hls) { hls.destroy() }
+ if (hls) hls.destroy()
const url = quality.url
if (Hls.isSupported()) {
- const hlsInstance = new Hls({
- enableWorker: false,
- lowLatencyMode: false,
- })
- hlsInstance.loadSource(url)
- hlsInstance.attachMedia(videoRef.current!)
- hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
- videoRef.current?.play().catch(() => {})
- })
- hlsInstance.on(Hls.Events.ERROR, (_event, data) => {
- if (data.fatal) {
- message.warning('Playback error, try switching line...')
- }
- })
- setHls(hlsInstance)
+ const hi = new Hls({ enableWorker: false, lowLatencyMode: false })
+ hi.loadSource(url); hi.attachMedia(videoRef.current!)
+ hi.on(Hls.Events.MANIFEST_PARSED, () => { videoRef.current?.play().catch(() => {}) })
+ hi.on(Hls.Events.ERROR, (_e, d) => { if (d.fatal) message.warning('播放异常,尝试切换线路...') })
+ setHls(hi)
} else if (videoRef.current!.canPlayType('application/vnd.apple.mpegurl')) {
videoRef.current!.src = url
}
}, [activeLine, activeQuality, info])
- if (loading) {
- return (
-
-
-
- )
- }
+ if (loading) return
+ if (!info) return
- if (!info) {
- return (
-
-
-
- )
- }
-
- const statusLabel = info.status === 'live' ? 'LIVE' : info.status === 'pending' ? 'SCHEDULED' : info.status === 'ended' ? 'ENDED' : info.status.toUpperCase()
+ const statusLabel = info.status === 'live' ? '直播中' : info.status === 'pending' ? '待开播' : info.status === 'ended' ? '已结束' : info.status
return (
-
-
+
+
- } style={{ color: '#A0AEC0' }}
- onClick={() => navigate('/')}>
- Back
-
+ } style={{ color: '#8B867D' }} onClick={() => navigate('/')}>返回
-
- {info.title}
-
-
- {statusLabel}
-
+ {info.title}
+ {statusLabel}
-
-
- {info.multicast_available ? 'Multicast' : 'Unicast'}
+
+
+ {info.multicast_available ? '组播' : '单播'}
-
-
-
-
+
+
+
{info.status !== 'live' && (
-
+
-
- Waiting for streamer...
+
+ 等待主播开播...