品牌真正接入全站渲染 + 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
+158 -41

No files matched your search

+13 -2
View File
@@ -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,9 +30,10 @@ function AdminGuard({ children }: { children: React.ReactNode }) {
return <>{children}</>
}
export default function App() {
function AppRoutes() {
const { brand } = useBrand()
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
return (
<ConfigProvider theme={theme} locale={zhCN}>
<BrowserRouter>
<Routes>
<Route path="/watch/:id" element={<WatchPage />} />
@@ -50,6 +52,15 @@ export default function App() {
<Route path="/" element={<HomePage />} />
</Routes>
</BrowserRouter>
)
}
export default function App() {
return (
<ConfigProvider theme={theme} locale={zhCN}>
<BrandProvider>
<AppRoutes />
</BrandProvider>
</ConfigProvider>
)
}
+5 -2
View File
@@ -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}
</Text>
</div>
<Menu
@@ -60,9 +62,10 @@ export default function AdminLayout() {
<Layout>
<Header style={{
background: '#FBF9F6', borderBottom: '1px solid #E8E3D8',
display: 'flex', justifyContent: 'flex-end', alignItems: 'center',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '0 40px',
}}>
<Text type="secondary" style={{ fontSize: 13 }}>{brand.brand_tagline}</Text>
<Button type="text" icon={<LogoutOutlined />} onClick={handleLogout}>
退出登录
</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 { 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',
}}>
<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%' }}>
<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,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif' }}>
StreamCore
{brand.brand_name}
</Title>
<Text type="secondary">管理控制台</Text>
</div>
+34 -21
View File
@@ -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() {
<Card title="品牌设置" style={{ borderRadius: 10 }}>
<Form layout="vertical">
<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 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 label="Logo 图片地址">
<Input value={brandLogoUrl} onChange={e => setBrandLogoUrl(e.target.value)} placeholder="https://..." />
</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>
</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)
}
+41 -14
View File
@@ -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<string, { color: string; label: string; icon: React.Rea
}
export default function HomePage() {
const { brand } = useBrand()
const [streams, setStreams] = useState<StreamInfo[]>([])
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
@@ -30,23 +32,35 @@ export default function HomePage() {
return (
<div style={{ minHeight: '100vh', background: '#F6F3ED' }}>
<div style={{
background: '#FBF9F6', borderBottom: '1px solid #E8E3D8',
padding: '48px 48px 40px',
background: '#FBF9F6',
borderBottom: '1px solid #E8E3D8',
padding: '56px 48px 48px',
}}>
<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={{
margin: 0, fontWeight: 600, letterSpacing: -1,
margin: 0, fontWeight: 600, letterSpacing: -1, fontSize: 36,
fontFamily: '"Source Serif 4", "Noto Serif SC", Georgia, serif',
}}>
StreamCore
{brand.brand_name}
</Title>
{brand.brand_tagline && (
<Text type="secondary" style={{ fontSize: 17, marginTop: 8, display: 'block', fontWeight: 400 }}>
企业级直播平台
{brand.brand_tagline}
</Text>
)}
</div>
</div>
<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 ? (
<Row gutter={[24, 24]}>
{[1,2,3,4,5,6].map(i => (
@@ -63,7 +77,7 @@ export default function HomePage() {
const meta = statusMeta[s.status] ?? statusMeta.pending
return (
<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={
<div style={{
height: 180, position: 'relative',
@@ -72,17 +86,24 @@ export default function HomePage() {
: 'linear-gradient(135deg, #3D3226 0%, #5C4A3A 50%, #2B241C 100%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{!s.cover_url && (
<VideoCameraOutlined style={{ color: 'rgba(232,227,216,0.2)', fontSize: 48 }} />
)}
<div style={{ position: 'absolute', top: 12, right: 12 }}>
<Tag color={meta.color} style={{ margin: 0, fontWeight: 600, border: 0 }}>
{!s.cover_url && <VideoCameraOutlined style={{ color: 'rgba(232,227,216,0.15)', fontSize: 52 }} />}
{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>
}
bodyStyle={{ padding: '16px 20px' }}
bodyStyle={{ padding: '18px 22px' }}
onClick={() => navigate(`/watch/${s.id}`)}
>
<Card.Meta
@@ -96,6 +117,12 @@ export default function HomePage() {
</Row>
)}
</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>
)
}
+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 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<HTMLVideoElement>(null)