品牌真正接入全站渲染 + 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:
1 parent
efde5597bd
commit
d4a3742136
7 files changed
+158
-41
No files matched your search
+13
-2
@@ -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,9 +30,10 @@ function AdminGuard({ children }: { children: React.ReactNode }) {
|
|||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function AppRoutes() {
|
||||||
|
const { brand } = useBrand()
|
||||||
|
useEffect(() => { document.title = brand.brand_name }, [brand.brand_name])
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={theme} locale={zhCN}>
|
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/watch/:id" element={<WatchPage />} />
|
<Route path="/watch/:id" element={<WatchPage />} />
|
||||||
@@ -50,6 +52,15 @@ export default function App() {
|
|||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={theme} locale={zhCN}>
|
||||||
|
<BrandProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrandProvider>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
{brand.brand_tagline && (
|
||||||
<Text type="secondary" style={{ fontSize: 17, marginTop: 8, display: 'block', fontWeight: 400 }}>
|
<Text type="secondary" style={{ fontSize: 17, marginTop: 8, display: 'block', fontWeight: 400 }}>
|
||||||
企业级直播平台
|
{brand.brand_tagline}
|
||||||
</Text>
|
</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 }}>
|
||||||
<div style={{ position: 'absolute', top: 12, right: 12 }}>
|
<Tag color="red" style={{ margin: 0, fontWeight: 600, border: 0, fontSize: 12 }}>
|
||||||
<Tag color={meta.color} style={{ margin: 0, fontWeight: 600, border: 0 }}>
|
|
||||||
{meta.icon} {meta.label}
|
{meta.icon} {meta.label}
|
||||||
</Tag>
|
</Tag>
|
||||||
</div>
|
</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>
|
</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} © {new Date().getFullYear()}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user