chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)

This commit is contained in:
weijuesen
2026-08-10 22:30:53 +08:00
commit 84abf4454c
358 changed files with 75993 additions and 0 deletions
+23
View File
@@ -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;
+29
View File
@@ -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>
);
+274
View File
@@ -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>
);
}