品牌真正接入全站渲染 + document.title 同步 + 设置页实时刷新

- BrandContext 全局上下文: 挂载时拉取 GET /api/settings/brand
- HomePage: 品牌名称/标语/Logo 动态渲染, 封面卡片精调
- AdminLayout: 侧栏品牌名称/顶栏标语动态显示
- LoginPage: 登录页品牌 Logo + 名称
- WatchPage: 播放页接入品牌上下文
- Settings: 保存后调用 refresh() 全站即时刷新
- App.tsx: document.title 跟随 brand_name 变化
- 组件分层: AppRoutes + BrandProvider 避免 import 循环
This commit is contained in:
admin committed 2026-08-10 16:28:00 +08:00
1 parent efde5597bd
commit d4a3742136
7 files changed
+178 -61

No files matched your search

+29 -18
View File
@@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { ConfigProvider } from 'antd' import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN' import zhCN from 'antd/locale/zh_CN'
import theme from './theme/anthropic' import theme from './theme/anthropic'
import { BrandProvider, useBrand } from './brand/BrandContext'
import AdminLayout from './admin/AdminLayout' import AdminLayout from './admin/AdminLayout'
import LoginPage from './admin/LoginPage' import LoginPage from './admin/LoginPage'
import Dashboard from './admin/pages/Dashboard' import Dashboard from './admin/pages/Dashboard'
@@ -29,27 +30,37 @@ function AdminGuard({ children }: { children: React.ReactNode }) {
return <>{children}</> return <>{children}</>
} }
function AppRoutes() {
const { brand } = useBrand()
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
return (
<BrowserRouter>
<Routes>
<Route path="/watch/:id" element={<WatchPage />} />
<Route path="/admin/login" element={<LoginPage />} />
<Route path="/admin" element={
<AdminGuard>
<AdminLayout />
</AdminGuard>
}>
<Route index element={<Dashboard />} />
<Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/" element={<HomePage />} />
</Routes>
</BrowserRouter>
)
}
export default function App() { export default function App() {
return ( return (
<ConfigProvider theme={theme} locale={zhCN}> <ConfigProvider theme={theme} locale={zhCN}>
<BrowserRouter> <BrandProvider>
<Routes> <AppRoutes />
<Route path="/watch/:id" element={<WatchPage />} /> </BrandProvider>
<Route path="/admin/login" element={<LoginPage />} />
<Route path="/admin" element={
<AdminGuard>
<AdminLayout />
</AdminGuard>
}>
<Route index element={<Dashboard />} />
<Route path="streams" element={<StreamList />} />
<Route path="streams/new" element={<StreamForm />} />
<Route path="nodes" element={<NodeManager />} />
<Route path="settings" element={<SettingsPage />} />
</Route>
<Route path="/" element={<HomePage />} />
</Routes>
</BrowserRouter>
</ConfigProvider> </ConfigProvider>
) )
} }
+5 -2
View File
@@ -6,6 +6,7 @@ import {
CloudServerOutlined, SettingOutlined, LogoutOutlined, CloudServerOutlined, SettingOutlined, LogoutOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { clearToken } from '../api/client' import { clearToken } from '../api/client'
import { useBrand } from '../brand/BrandContext'
const { Sider, Content, Header } = Layout const { Sider, Content, Header } = Layout
const { Text } = Typography const { Text } = Typography
@@ -19,6 +20,7 @@ const menuItems = [
] ]
export default function AdminLayout() { export default function AdminLayout() {
const { brand } = useBrand()
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
@@ -48,7 +50,7 @@ export default function AdminLayout() {
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif', fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
color: '#C2693A', color: '#C2693A',
}}> }}>
{collapsed ? 'SC' : 'StreamCore'} {collapsed ? brand.brand_name.slice(0, 2) : brand.brand_name}
</Text> </Text>
</div> </div>
<Menu <Menu
@@ -60,9 +62,10 @@ export default function AdminLayout() {
<Layout> <Layout>
<Header style={{ <Header style={{
background: '#FBF9F6', borderBottom: '1px solid #E8E3D8', background: '#FBF9F6', borderBottom: '1px solid #E8E3D8',
display: 'flex', justifyContent: 'flex-end', alignItems: 'center', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0 40px', padding: '0 40px',
}}> }}>
<Text type="secondary" style={{ fontSize: 13 }}>{brand.brand_tagline}</Text>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}> <Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>
退出登录 退出登录
</Button> </Button>
+8 -2
View File
@@ -3,10 +3,12 @@ import { useNavigate } from 'react-router-dom'
import { Card, Form, Input, Button, Typography, message, Space } from 'antd' import { Card, Form, Input, Button, Typography, message, Space } from 'antd'
import { LockOutlined } from '@ant-design/icons' import { LockOutlined } from '@ant-design/icons'
import { login } from '../api/auth' import { login } from '../api/auth'
import { useBrand } from '../brand/BrandContext'
const { Title, Text } = Typography const { Title, Text } = Typography
export default function LoginPage() { export default function LoginPage() {
const { brand } = useBrand()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const navigate = useNavigate() const navigate = useNavigate()
@@ -31,12 +33,16 @@ export default function LoginPage() {
justifyContent: 'center', justifyContent: 'center',
background: '#F6F3ED', background: '#F6F3ED',
}}> }}>
<Card style={{ width: 400, borderRadius: 12, boxShadow: '0 8px 32px rgba(20,20,16,0.06)' }}> <Card style={{ width: 400, borderRadius: 12, boxShadow: '0 12px 40px rgba(20,20,16,0.06)' }}>
<Space direction="vertical" size="large" style={{ width: '100%' }}> <Space direction="vertical" size="large" style={{ width: '100%' }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
{brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name}
style={{ height: 36, marginBottom: 12 }} />
)}
<Title level={2} style={{ marginBottom: 4, color: '#C2693A', fontWeight: 600, <Title level={2} style={{ marginBottom: 4, color: '#C2693A', fontWeight: 600,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif' }}> fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif' }}>
StreamCore {brand.brand_name}
</Title> </Title>
<Text type="secondary">管理控制台</Text> <Text type="secondary">管理控制台</Text>
</div> </div>
+34 -21
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react' 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 api from '../../api/client'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -12,17 +13,20 @@ const strategies = [
] ]
export default function SettingsPage() { export default function SettingsPage() {
const { brand, refresh } = useBrand()
const [strategy, setStrategy] = useState('subnet') const [strategy, setStrategy] = useState('subnet')
const [multicastEnabled, setMulticastEnabled] = useState(true) const [multicastEnabled, setMulticastEnabled] = useState(true)
const [multicastAvailable, setMulticastAvailable] = useState(false) const [multicastAvailable, setMulticastAvailable] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [brandName, setBrandName] = useState('StreamCore') const [brandName, setBrandName] = useState(brand.brand_name)
const [brandTagline, setBrandTagline] = useState('企业级直播平台') const [brandTagline, setBrandTagline] = useState(brand.brand_tagline)
const [brandLogoUrl, setBrandLogoUrl] = useState('') const [brandLogoUrl, setBrandLogoUrl] = useState(brand.brand_logo_url)
const [brandSaving, setBrandSaving] = useState(false) 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() { async function loadSettings() {
try { try {
@@ -32,30 +36,34 @@ export default function SettingsPage() {
setMulticastAvailable(data.multicast_available) setMulticastAvailable(data.multicast_available)
} catch { /* */ } } 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) { async function saveStrategy(val: string) {
setSaving(true) setSaving(true)
try { await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled }); setStrategy(val); message.success('策略已更新') } try {
catch { message.error('更新失败') } await api.put('/nodes/strategy', { strategy: val, multicast_enabled: multicastEnabled })
setStrategy(val)
message.success('策略已更新')
} catch { message.error('更新失败') }
setSaving(false) setSaving(false)
} }
async function toggleMulticast(val: boolean) { async function toggleMulticast(val: boolean) {
setMulticastEnabled(val) setMulticastEnabled(val)
try { await api.put('/nodes/strategy', { strategy, multicast_enabled: val }); message.success('组播设置已更新') } try {
catch { message.error('更新失败') } await api.put('/nodes/strategy', { strategy, multicast_enabled: val })
message.success('组播设置已更新')
} catch { message.error('更新失败') }
} }
async function saveBrand() { async function saveBrand() {
setBrandSaving(true) setBrandSaving(true)
try { 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('品牌设置已保存') message.success('品牌设置已保存')
} catch { message.error('保存失败') } } catch { message.error('保存失败') }
setBrandSaving(false) setBrandSaving(false)
@@ -82,16 +90,21 @@ export default function SettingsPage() {
<Card title="品牌设置" style={{ borderRadius: 10 }}> <Card title="品牌设置" style={{ borderRadius: 10 }}>
<Form layout="vertical"> <Form layout="vertical">
<Form.Item label="平台名称"> <Form.Item label="平台名称">
<Input value={brandName} onChange={e => setBrandName(e.target.value)} placeholder="StreamCore" /> <Input value={brandName} onChange={e => setBrandName(e.target.value)} placeholder="StreamCore" size="large" />
</Form.Item> </Form.Item>
<Form.Item label="标语"> <Form.Item label="标语">
<Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} placeholder="企业级直播平台" /> <Input value={brandTagline} onChange={e => setBrandTagline(e.target.value)} placeholder="企业级直播平台" size="large" />
</Form.Item> </Form.Item>
<Form.Item label="Logo 图片地址"> <Form.Item label="Logo 图片地址">
<Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} placeholder="https://..." /> <Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} placeholder="https://..." />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" loading={brandSaving} onClick={saveBrand}>保存品牌设置</Button> <Button type="primary" loading={brandSaving} onClick={saveBrand} size="large">
保存品牌设置
</Button>
<Text type="secondary" style={{ marginLeft: 12, fontSize: 13 }}>
保存后全站立即生效
</Text>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>
+55
View File
@@ -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<void>
}
const defaultBrand: BrandData = {
brand_name: 'StreamCore',
brand_tagline: '企业级直播平台',
brand_logo_url: '',
}
const BrandContext = createContext<BrandContextType>({
brand: defaultBrand,
loading: false,
refresh: async () => {},
})
export function BrandProvider({ children }: { children: ReactNode }) {
const [brand, setBrand] = useState<BrandData>(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 (
<BrandContext.Provider value={{ brand, loading, refresh }}>
{children}
</BrandContext.Provider>
)
}
export function useBrand() {
return useContext(BrandContext)
}
+45 -18
View File
@@ -1,8 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom' 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 { FireOutlined, ClockCircleOutlined, VideoCameraOutlined } from '@ant-design/icons'
import { fetchStreams, type StreamInfo } from '../../api/streams' import { fetchStreams, type StreamInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography const { Title, Text } = Typography
@@ -14,6 +15,7 @@ const statusMeta: Record<string, { color: string; label: string; icon: React.Rea
} }
export default function HomePage() { export default function HomePage() {
const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([]) const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const navigate = useNavigate() const navigate = useNavigate()
@@ -30,23 +32,35 @@ export default function HomePage() {
return ( return (
<div style={{ minHeight: '100vh', background: '#F6F3ED' }}> <div style={{ minHeight: '100vh', background: '#F6F3ED' }}>
<div style={{ <div style={{
background: '#FBF9F6', borderBottom: '1px solid #E8E3D8', background: '#FBF9F6',
padding: '48px 48px 40px', borderBottom: '1px solid #E8E3D8',
padding: '56px 48px 48px',
}}> }}>
<div style={{ maxWidth: 1280, margin: '0 auto' }}> <div style={{ maxWidth: 1280, margin: '0 auto' }}>
{brand.brand_logo_url && (
<img src={brand.brand_logo_url} alt={brand.brand_name}
style={{ height: 40, marginBottom: 16, display: 'block' }} />
)}
<Title level={1} style={{ <Title level={1} style={{
margin: 0, fontWeight: 600, letterSpacing: -1, margin: 0, fontWeight: 600, letterSpacing: -1, fontSize: 36,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif', fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
}}> }}>
StreamCore {brand.brand_name}
</Title> </Title>
<Text type="secondary" style={{ fontSize: 17, marginTop: 8, display: 'block', fontWeight: 400 }}> {brand.brand_tagline && (
企业级直播平台 <Text type="secondary" style={{ fontSize: 17, marginTop: 8, display: 'block', fontWeight: 400 }}>
</Text> {brand.brand_tagline}
</Text>
)}
</div> </div>
</div> </div>
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '40px 48px' }}> <div style={{ maxWidth: 1280, margin: '0 auto', padding: '40px 48px' }}>
<Title level={3} style={{ marginBottom: 24, fontWeight: 600 }}>直播频道</Title> <div style={{ display: 'flex', alignItems: 'baseline', gap: 12, marginBottom: 24 }}>
<Title level={3} style={{ margin: 0, fontWeight: 600 }}>直播频道</Title>
<Text type="secondary">{sorted.length} 个直播</Text>
</div>
{loading ? ( {loading ? (
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
{[1,2,3,4,5,6].map(i => ( {[1,2,3,4,5,6].map(i => (
@@ -63,7 +77,7 @@ export default function HomePage() {
const meta = statusMeta[s.status] ?? statusMeta.pending const meta = statusMeta[s.status] ?? statusMeta.pending
return ( return (
<Col key={s.id} xs={24} sm={12} md={8} lg={8}> <Col key={s.id} xs={24} sm={12} md={8} lg={8}>
<Card hoverable style={{ overflow: 'hidden', borderRadius: 10 }} <Card hoverable style={{ overflow: 'hidden', borderRadius: 10, transition: 'box-shadow 0.2s, transform 0.2s' }}
cover={ cover={
<div style={{ <div style={{
height: 180, position: 'relative', height: 180, position: 'relative',
@@ -72,17 +86,24 @@ export default function HomePage() {
: 'linear-gradient(135deg, #3D3226 0%, #5C4A3A 50%, #2B241C 100%)', : 'linear-gradient(135deg, #3D3226 0%, #5C4A3A 50%, #2B241C 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}> }}>
{!s.cover_url && ( {!s.cover_url && <VideoCameraOutlined style={{ color: 'rgba(232,227,216,0.15)', fontSize: 52 }} />}
<VideoCameraOutlined style={{ color: 'rgba(232,227,216,0.2)', fontSize: 48 }} /> {s.status === 'live' && (
<div style={{ position: 'absolute', top: 12, left: 12 }}>
<Tag color="red" style={{ margin: 0, fontWeight: 600, border: 0, fontSize: 12 }}>
{meta.icon} {meta.label}
</Tag>
</div>
)}
{s.status !== 'live' && (
<div style={{ position: 'absolute', bottom: 12, left: 12 }}>
<Tag color={meta.color} style={{ margin: 0, fontWeight: 600, border: 0, fontSize: 12 }}>
{meta.icon} {meta.label}
</Tag>
</div>
)} )}
<div style={{ position: 'absolute', top: 12, right: 12 }}>
<Tag color={meta.color} style={{ margin: 0, fontWeight: 600, border: 0 }}>
{meta.icon} {meta.label}
</Tag>
</div>
</div> </div>
} }
bodyStyle={{ padding: '16px 20px' }} bodyStyle={{ padding: '18px 22px' }}
onClick={() => navigate(`/watch/${s.id}`)} onClick={() => navigate(`/watch/${s.id}`)}
> >
<Card.Meta <Card.Meta
@@ -96,6 +117,12 @@ export default function HomePage() {
</Row> </Row>
)} )}
</div> </div>
<div style={{ maxWidth: 1280, margin: '0 auto', padding: '0 48px 48px', textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{brand.brand_name} &copy; {new Date().getFullYear()}
</Text>
</div>
</div> </div>
) )
} }
+2
View File
@@ -4,10 +4,12 @@ import { Select, Button, Typography, Space, Tag, Spin, Skeleton, Empty, message
import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons' import { ArrowLeftOutlined, WifiOutlined, SignalFilled } from '@ant-design/icons'
import Hls from 'hls.js' import Hls from 'hls.js'
import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams' import { getWatchUrls, type WatchInfo, type LineInfo } from '../../api/streams'
import { useBrand } from '../../brand/BrandContext'
const { Title, Text } = Typography const { Title, Text } = Typography
export default function WatchPage() { export default function WatchPage() {
const { brand } = useBrand()
const navigate = useNavigate() const navigate = useNavigate()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)