chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
interface HasPermissionProps {
|
||||
/** 需要的权限码,如 device:control */
|
||||
permission: string;
|
||||
children: ReactNode;
|
||||
/** 无权限时渲染的替代内容,默认不渲染 */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按钮级权限控制组件
|
||||
* 当当前用户拥有指定权限码时渲染 children,否则渲染 fallback
|
||||
*/
|
||||
export const HasPermission = ({ permission, children, fallback = null }: HasPermissionProps) => {
|
||||
if (authService.hasPermission(permission)) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return <>{fallback}</>;
|
||||
};
|
||||
|
||||
export default HasPermission;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Card, Col, Row, Statistic } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
items: {
|
||||
title: string;
|
||||
value: number | string;
|
||||
suffix?: string;
|
||||
color?: string;
|
||||
icon?: ReactNode;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const StatCard = ({ items }: Props) => (
|
||||
<Row gutter={[16, 16]}>
|
||||
{items.map((it) => (
|
||||
<Col key={it.title} xs={24} sm={12} md={8} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={it.title}
|
||||
value={it.value}
|
||||
suffix={it.suffix}
|
||||
valueStyle={it.color ? { color: it.color } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
);
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import Hls from 'hls.js';
|
||||
import flvjs from 'flv.js';
|
||||
|
||||
export interface VideoPlayerProps {
|
||||
/** Video stream URL */
|
||||
url: string;
|
||||
/** Stream format: 'hls' uses hls.js, 'mp4' uses native video */
|
||||
format?: 'hls' | 'mp4' | 'flv' | 'webrtc';
|
||||
/** Auto play on load */
|
||||
autoPlay?: boolean;
|
||||
/** Muted by default (needed for autoplay in most browsers) */
|
||||
muted?: boolean;
|
||||
/** Callback when error occurs */
|
||||
onError?: (error: string) => void;
|
||||
/** Custom style */
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export default function VideoPlayer({
|
||||
url,
|
||||
format = 'hls',
|
||||
autoPlay = true,
|
||||
muted = true,
|
||||
onError,
|
||||
style,
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hlsRef = useRef<Hls | null>(null);
|
||||
const flvRef = useRef<flvjs.Player | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errorMsg, setErrorMsg] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || !url) return;
|
||||
|
||||
setLoading(true);
|
||||
setErrorMsg('');
|
||||
|
||||
// Clean up previous HLS/FLV instance
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
|
||||
const handleError = (msg: string) => {
|
||||
setErrorMsg(msg);
|
||||
setLoading(false);
|
||||
onError?.(msg);
|
||||
};
|
||||
|
||||
if (format === 'hls') {
|
||||
if (Hls.isSupported()) {
|
||||
// Use hls.js for browsers that don't natively support HLS (Chrome, Edge, Firefox)
|
||||
const hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 30,
|
||||
});
|
||||
hlsRef.current = hls;
|
||||
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
handleError(`视频播放失败: ${data.details || data.type}`);
|
||||
hls.destroy();
|
||||
hlsRef.current = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Safari natively supports HLS
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
video.addEventListener('error', () => {
|
||||
handleError('视频播放失败');
|
||||
});
|
||||
} else {
|
||||
handleError('当前浏览器不支持 HLS 播放');
|
||||
}
|
||||
} else if (format === 'flv') {
|
||||
// FLV format - use flv.js (supports H265 if browser MSE supports it)
|
||||
if (flvjs.isSupported()) {
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const createFlvPlayer = () => {
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
const flvPlayer = flvjs.createPlayer({
|
||||
type: 'flv',
|
||||
url,
|
||||
isLive: true,
|
||||
}, {
|
||||
enableWorker: false,
|
||||
enableStashBuffer: true,
|
||||
stashInitialSize: 512,
|
||||
fixAudioTimestampGap: false,
|
||||
liveBufferLatencyChasing: true,
|
||||
liveBufferLatencyMaxLatency: 3,
|
||||
liveBufferLatencyMinRemain: 1,
|
||||
} as any);
|
||||
flvRef.current = flvPlayer;
|
||||
flvPlayer.attachMediaElement(video);
|
||||
flvPlayer.load();
|
||||
flvPlayer.on(flvjs.Events.ERROR, (errorType, errorDetail) => {
|
||||
// MediaMSEError / MediaError: auto-retry for live streams
|
||||
if (errorType === flvjs.ErrorTypes.MEDIA_ERROR && retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.warn(`FLV 播放错误,第 ${retryCount} 次重连: ${errorType} - ${errorDetail}`);
|
||||
retryTimer = setTimeout(() => {
|
||||
createFlvPlayer();
|
||||
if (autoPlay) video.play().catch(() => undefined);
|
||||
}, 1000);
|
||||
} else if (errorType === flvjs.ErrorTypes.NETWORK_ERROR && retryCount < maxRetries) {
|
||||
retryCount++;
|
||||
console.warn(`FLV 网络错误,第 ${retryCount} 次重连: ${errorType} - ${errorDetail}`);
|
||||
retryTimer = setTimeout(() => {
|
||||
createFlvPlayer();
|
||||
if (autoPlay) video.play().catch(() => undefined);
|
||||
}, 2000);
|
||||
} else {
|
||||
handleError(`FLV播放失败: ${errorType} - ${errorDetail}`);
|
||||
flvPlayer.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
});
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
createFlvPlayer();
|
||||
|
||||
// Cleanup retry timer on unmount
|
||||
return () => {
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
} else {
|
||||
handleError('当前浏览器不支持 FLV 播放');
|
||||
}
|
||||
} else {
|
||||
// MP4 and other native formats
|
||||
video.src = url;
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
setLoading(false);
|
||||
if (autoPlay) {
|
||||
video.play().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
video.addEventListener('error', () => {
|
||||
handleError('视频播放失败');
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (hlsRef.current) {
|
||||
hlsRef.current.destroy();
|
||||
hlsRef.current = null;
|
||||
}
|
||||
if (flvRef.current) {
|
||||
flvRef.current.destroy();
|
||||
flvRef.current = null;
|
||||
}
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
};
|
||||
}, [url, format, autoPlay, onError]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
background: '#000',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted={muted}
|
||||
playsInline
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: errorMsg ? 'none' : 'block',
|
||||
}}
|
||||
/>
|
||||
{loading && !errorMsg && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
<Spin tip="加载中..." />
|
||||
</div>
|
||||
)}
|
||||
{errorMsg && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ff4d4f',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user