diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63854ff..eba56f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ 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 { BrandProvider, useBrand } from './brand/BrandContext' import AdminLayout from './admin/AdminLayout' import LoginPage from './admin/LoginPage' import Dashboard from './admin/pages/Dashboard' @@ -29,27 +30,37 @@ function AdminGuard({ children }: { children: React.ReactNode }) { return <>{children} } +function AppRoutes() { + const { brand } = useBrand() + useEffect(() => { document.title = brand.brand_name }, [brand.brand_name]) + return ( + + + } /> + } /> + + + + }> + } /> + } /> + } /> + } /> + } /> + + } /> + + + ) +} + export default function App() { return ( - - - } /> - } /> - - - - }> - } /> - } /> - } /> - } /> - } /> - - } /> - - + + + ) } diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx index a81fce7..2a8e2cd 100644 --- a/frontend/src/admin/AdminLayout.tsx +++ b/frontend/src/admin/AdminLayout.tsx @@ -6,6 +6,7 @@ import { CloudServerOutlined, SettingOutlined, LogoutOutlined, } from '@ant-design/icons' import { clearToken } from '../api/client' +import { useBrand } from '../brand/BrandContext' const { Sider, Content, Header } = Layout const { Text } = Typography @@ -19,6 +20,7 @@ const menuItems = [ ] export default function AdminLayout() { + const { brand } = useBrand() const [collapsed, setCollapsed] = useState(false) const navigate = useNavigate() const location = useLocation() @@ -48,7 +50,7 @@ export default function AdminLayout() { fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif', color: '#C2693A', }}> - {collapsed ? 'SC' : 'StreamCore'} + {collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name}
+ {brand.brand_tagline} diff --git a/frontend/src/admin/LoginPage.tsx b/frontend/src/admin/LoginPage.tsx index e0039fc..34515f8 100644 --- a/frontend/src/admin/LoginPage.tsx +++ b/frontend/src/admin/LoginPage.tsx @@ -3,10 +3,12 @@ import { useNavigate } from 'react-router-dom' import { Card, Form, Input, Button, Typography, message, Space } from 'antd' import { LockOutlined } from '@ant-design/icons' import { login } from '../api/auth' +import { useBrand } from '../brand/BrandContext' const { Title, Text } = Typography export default function LoginPage() { + const { brand } = useBrand() const [loading, setLoading] = useState(false) const navigate = useNavigate() @@ -31,12 +33,16 @@ export default function LoginPage() { justifyContent: 'center', background: '#F6F3ED', }}> - +
+ {brand.brand_logo_url && ( + {brand.brand_name} + )} - StreamCore + {brand.brand_name} 管理控制台
diff --git a/frontend/src/admin/pages/Settings.tsx b/frontend/src/admin/pages/Settings.tsx index f5cb10c..53e602d 100644 --- a/frontend/src/admin/pages/Settings.tsx +++ b/frontend/src/admin/pages/Settings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' -import { Card, Select, Switch, Form, Input, Button, Typography, message, Divider } from 'antd' +import { Card, Select, Switch, Form, Input, Button, Typography, message } from 'antd' import api from '../../api/client' +import { useBrand } from '../../brand/BrandContext' const { Title, Text } = Typography @@ -12,17 +13,20 @@ 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('StreamCore') - const [brandTagline, setBrandTagline] = useState('企业级直播平台') - const [brandLogoUrl, setBrandLogoUrl] = useState('') + const [brandName, setBrandName] = useState(brand.brand_name) + const [brandTagline, setBrandTagline] = useState(brand.brand_tagline) + const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url) const [brandSaving, setBrandSaving] = useState(false) - useEffect(() => { loadSettings(); loadBrand() }, []) + useEffect(() => { loadSettings() }, []) + useEffect(() => { setBrandName(brand.brand_name); setBrandTagline(brand.brand_tagline); setBrandLogoUrl(brand.brand_logo_url) }, [brand]) async function loadSettings() { try { @@ -32,30 +36,34 @@ export default function SettingsPage() { setMulticastAvailable(data.multicast_available) } 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('策略已更新') } - 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 api.put('/settings/brand', { + brand_name: brandName, + brand_tagline: brandTagline, + brand_logo_url: brandLogoUrl, + }) + await refresh() message.success('品牌设置已保存') } catch { message.error('保存失败') } setBrandSaving(false) @@ -82,16 +90,21 @@ export default function SettingsPage() {
- setBrandName(e.target.value)} placeholder="StreamCore" /> + setBrandName(e.target.value)} placeholder="StreamCore" size="large" /> - setBrandTagline(e.target.value)} placeholder="企业级直播平台" /> + setBrandTagline(e.target.value)} placeholder="企业级直播平台" size="large" /> setBrandLogoUrl(e.target.value)} placeholder="https://..." /> - + + + 保存后全站立即生效 +
diff --git a/frontend/src/brand/BrandContext.tsx b/frontend/src/brand/BrandContext.tsx new file mode 100644 index 0000000..a08dcd8 --- /dev/null +++ b/frontend/src/brand/BrandContext.tsx @@ -0,0 +1,55 @@ +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react' +import api from '../api/client' + +interface BrandData { + brand_name: string + brand_tagline: string + brand_logo_url: string +} + +interface BrandContextType { + brand: BrandData + loading: boolean + refresh: () => Promise +} + +const defaultBrand: BrandData = { + brand_name: 'StreamCore', + brand_tagline: '企业级直播平台', + brand_logo_url: '', +} + +const BrandContext = createContext({ + brand: defaultBrand, + loading: false, + refresh: async () => {}, +}) + +export function BrandProvider({ children }: { children: ReactNode }) { + const [brand, setBrand] = useState(defaultBrand) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + try { + const { data } = await api.get('/settings/brand') + setBrand({ + brand_name: data.brand_name || defaultBrand.brand_name, + brand_tagline: data.brand_tagline || defaultBrand.brand_tagline, + brand_logo_url: data.brand_logo_url || '', + }) + } catch { /* use default */ } + setLoading(false) + }, []) + + useEffect(() => { refresh() }, [refresh]) + + return ( + + {children} + + ) +} + +export function useBrand() { + return useContext(BrandContext) +} diff --git a/frontend/src/viewer/pages/HomePage.tsx b/frontend/src/viewer/pages/HomePage.tsx index e19f18f..e1d7bfb 100644 --- a/frontend/src/viewer/pages/HomePage.tsx +++ b/frontend/src/viewer/pages/HomePage.tsx @@ -1,8 +1,9 @@ import { useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { Row, Col, Card, Typography, Tag, Skeleton, Empty, Space } from 'antd' +import { Row, Col, Card, Typography, Tag, Skeleton, Empty } from 'antd' import { FireOutlined, ClockCircleOutlined, VideoCameraOutlined } from '@ant-design/icons' import { fetchStreams, type StreamInfo } from '../../api/streams' +import { useBrand } from '../../brand/BrandContext' const { Title, Text } = Typography @@ -14,6 +15,7 @@ const statusMeta: Record([]) const [loading, setLoading] = useState(true) const navigate = useNavigate() @@ -30,23 +32,35 @@ export default function HomePage() { return (
+ {brand.brand_logo_url && ( + {brand.brand_name} + )} - StreamCore + {brand.brand_name} - - 企业级直播平台 - + {brand.brand_tagline && ( + + {brand.brand_tagline} + + )}
+
- 直播频道 +
+ 直播频道 + {sorted.length} 个直播 +
+ {loading ? ( {[1,2,3,4,5,6].map(i => ( @@ -63,7 +77,7 @@ export default function HomePage() { const meta = statusMeta[s.status] ?? statusMeta.pending return ( - - {!s.cover_url && ( - + {!s.cover_url && } + {s.status === 'live' && ( +
+ + {meta.icon} {meta.label} + +
+ )} + {s.status !== 'live' && ( +
+ + {meta.icon} {meta.label} + +
)} -
- - {meta.icon} {meta.label} - -
} - bodyStyle={{ padding: '16px 20px' }} + bodyStyle={{ padding: '18px 22px' }} onClick={() => navigate(`/watch/${s.id}`)} > )}
+ +
+ + {brand.brand_name} © {new Date().getFullYear()} + +
) } diff --git a/frontend/src/viewer/pages/WatchPage.tsx b/frontend/src/viewer/pages/WatchPage.tsx index ef18fea..cf14a4c 100644 --- a/frontend/src/viewer/pages/WatchPage.tsx +++ b/frontend/src/viewer/pages/WatchPage.tsx @@ -4,10 +4,12 @@ import { Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import Hls from 'hls.js' import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams' +import { useBrand } from '../../brand/BrandContext' const { Title, Text } = Typography export default function WatchPage() { + const { brand } = useBrand() const navigate = useNavigate() const { id } = useParams<{ id: string }>() const videoRef = useRef(null)