chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '视频监控',
|
||||
enablePullDownRefresh: true,
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
@use '@/styles/variables.scss' as *;
|
||||
|
||||
.videoPage {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
padding-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.cameraGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.cameraCard {
|
||||
width: 100%;
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
box-shadow: $shadow-card;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
.cameraThumb {
|
||||
width: 100%;
|
||||
height: 320rpx;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cameraPlaceholder {
|
||||
font-size: 64rpx;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.cameraOverlay {
|
||||
position: absolute;
|
||||
top: $spacing-sm;
|
||||
left: $spacing-sm;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-round;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: $radius-round;
|
||||
background: $color-error;
|
||||
}
|
||||
|
||||
.liveDotGray {
|
||||
background: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.cameraResolution {
|
||||
position: absolute;
|
||||
bottom: $spacing-sm;
|
||||
right: $spacing-sm;
|
||||
padding: 4rpx $spacing-sm;
|
||||
border-radius: $radius-xs;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.cameraInfo {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
}
|
||||
|
||||
.cameraName {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-primary;
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.cameraMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
.metaItem {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.playBtn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: $radius-round;
|
||||
background: rgba(16, 185, 129, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 48rpx;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.clipsSection {
|
||||
margin-top: $spacing-lg;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-primary;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.clipList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.clipItem {
|
||||
background: $color-bg-card;
|
||||
border-radius: $radius-md;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
box-shadow: $shadow-card;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.clipLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clipIcon {
|
||||
font-size: 36rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clipInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.clipName {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-primary;
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.clipTime {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-tertiary;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.clipDuration {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-primary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.clipTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.clipTag {
|
||||
font-size: $font-size-xs;
|
||||
padding: 2rpx $spacing-sm;
|
||||
border-radius: $radius-xs;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clipTagAlarm {
|
||||
background: rgba(245, 63, 63, 0.1);
|
||||
color: $color-error;
|
||||
}
|
||||
|
||||
.clipTagManual {
|
||||
background: rgba(22, 93, 255, 0.1);
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.clipTagSchedule {
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.clipMetaRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4rpx;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.clipMetaText {
|
||||
font-size: $font-size-xs;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.loadingWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.errorWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
font-size: 56rpx;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
|
||||
.retryBtn {
|
||||
margin-top: $spacing-md;
|
||||
padding: $spacing-xs $spacing-lg;
|
||||
border-radius: $radius-button;
|
||||
background: $color-primary;
|
||||
color: $color-text-white;
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 0;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-tertiary;
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import styles from './index.module.scss';
|
||||
import { getCameras, getVideoClips } from '@/api/video';
|
||||
import { resolveUrl } from '@/api/config';
|
||||
import { formatDateTime, formatDuration, formatFileSize } from '@/utils/format';
|
||||
import type { Camera, VideoClip } from '@/types';
|
||||
|
||||
const VideoPage: React.FC = () => {
|
||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||
const [clips, setClips] = useState<VideoClip[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
setError('');
|
||||
const [camerasRes, clipsRes] = await Promise.all([
|
||||
getCameras().catch(() => [] as Camera[]),
|
||||
getVideoClips({ limit: 10 }).catch(() => [] as VideoClip[]),
|
||||
]);
|
||||
setCameras(camerasRes);
|
||||
setClips(clipsRes);
|
||||
} catch (err) {
|
||||
console.error('[Video] 数据加载失败:', err);
|
||||
setError(err instanceof Error ? err.message : '数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useDidShow(() => {
|
||||
if (cameras.length > 0) fetchData();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
fetchData().then(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const handlePlayCamera = (camera: Camera) => {
|
||||
if (!camera.isOnline) {
|
||||
Taro.showToast({ title: '摄像头离线', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/video/player/index?id=${camera.id}&name=${encodeURIComponent(camera.name)}`,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlayClip = (clip: VideoClip) => {
|
||||
const playbackUrl = clip.playbackUrl || clip.url;
|
||||
if (!playbackUrl) {
|
||||
Taro.showToast({ title: '录像暂不可用', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/video/player/index?url=${encodeURIComponent(resolveUrl(playbackUrl))}&name=${encodeURIComponent('录像回放')}`,
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className={styles.videoPage}>
|
||||
<View className={styles.loadingWrap}>
|
||||
<Text className={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View className={styles.videoPage}>
|
||||
<View className={styles.errorWrap}>
|
||||
<Text className={styles.errorIcon}>⚠️</Text>
|
||||
<Text className={styles.errorText}>{error}</Text>
|
||||
<View className={styles.retryBtn} onClick={fetchData}>
|
||||
<Text style={{ color: '#fff' }}>重试</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.videoPage}>
|
||||
<ScrollView scrollY style={{ height: 'calc(100vh - 120rpx)' }}>
|
||||
{cameras.length === 0 && clips.length === 0 ? (
|
||||
<View className={styles.emptyWrap}>
|
||||
<Text className={styles.emptyIcon}>📹</Text>
|
||||
<Text className={styles.emptyText}>暂无视频设备</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
{/* 摄像头列表 */}
|
||||
{cameras.length > 0 && (
|
||||
<View className={styles.cameraGrid}>
|
||||
{cameras.map((camera) => (
|
||||
<View
|
||||
key={camera.id}
|
||||
className={styles.cameraCard}
|
||||
onClick={() => handlePlayCamera(camera)}
|
||||
>
|
||||
<View className={styles.cameraThumb}>
|
||||
<Text className={styles.cameraPlaceholder}>📹</Text>
|
||||
<View className={styles.cameraOverlay}>
|
||||
<View
|
||||
className={`${styles.liveDot} ${!camera.isOnline ? styles.liveDotGray : ''}`}
|
||||
/>
|
||||
<Text>{camera.isOnline ? 'LIVE' : '离线'}</Text>
|
||||
</View>
|
||||
{camera.resolution && (
|
||||
<View className={styles.cameraResolution}>{camera.resolution}</View>
|
||||
)}
|
||||
{camera.isOnline && (
|
||||
<View className={styles.playBtn}>
|
||||
<Text>▶</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View className={styles.cameraInfo}>
|
||||
<Text className={styles.cameraName}>{camera.name}</Text>
|
||||
<View className={styles.cameraMeta}>
|
||||
<Text className={styles.metaItem}>编码: {camera.code}</Text>
|
||||
{camera.position && (
|
||||
<Text className={styles.metaItem}>📍 {camera.position}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 录像片段 */}
|
||||
{clips.length > 0 && (
|
||||
<View className={styles.clipsSection}>
|
||||
<Text className={styles.sectionTitle}>录像回放</Text>
|
||||
<View className={styles.clipList}>
|
||||
{clips.map((clip) => {
|
||||
const triggerLabel =
|
||||
clip.trigger === 'alarm' ? '告警' :
|
||||
clip.trigger === 'manual' ? '手动' :
|
||||
clip.trigger === 'schedule' ? '定时' : '';
|
||||
const triggerClass =
|
||||
clip.trigger === 'alarm' ? styles.clipTagAlarm :
|
||||
clip.trigger === 'manual' ? styles.clipTagManual :
|
||||
styles.clipTagSchedule;
|
||||
return (
|
||||
<View
|
||||
key={clip.id}
|
||||
className={styles.clipItem}
|
||||
onClick={() => handlePlayClip(clip)}
|
||||
>
|
||||
<View className={styles.clipLeft}>
|
||||
<Text className={styles.clipIcon}>🎞️</Text>
|
||||
<View className={styles.clipInfo}>
|
||||
<View className={styles.clipTitleRow}>
|
||||
<Text className={styles.clipName}>
|
||||
摄像头 #{clip.cameraId}
|
||||
</Text>
|
||||
{triggerLabel && (
|
||||
<View className={`${styles.clipTag} ${triggerClass}`}>
|
||||
<Text>{triggerLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className={styles.clipTime}>
|
||||
{formatDateTime(clip.startTime || clip.startAt)}
|
||||
</Text>
|
||||
<View className={styles.clipMetaRow}>
|
||||
{clip.resolution && (
|
||||
<Text className={styles.clipMetaText}>{clip.resolution}</Text>
|
||||
)}
|
||||
{clip.sizeBytes && (
|
||||
<Text className={styles.clipMetaText}>
|
||||
· {formatFileSize(clip.sizeBytes)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{(clip.durationSec || clip.duration) && (
|
||||
<Text className={styles.clipDuration}>
|
||||
{formatDuration(clip.durationSec || clip.duration || 0)}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPage;
|
||||
@@ -0,0 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '视频播放',
|
||||
navigationBarBackgroundColor: '#000000',
|
||||
navigationBarTextStyle: 'white',
|
||||
backgroundColor: '#000000',
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
@use '@/styles/variables.scss' as *;
|
||||
|
||||
.playerPage {
|
||||
min-height: 100vh;
|
||||
background: #000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
width: 100%;
|
||||
height: 422rpx;
|
||||
background: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.videoPlaceholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.placeholderIcon {
|
||||
font-size: 80rpx;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.placeholderText {
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
|
||||
.loadingOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.loadingText {
|
||||
font-size: $font-size-md;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.controls {
|
||||
padding: $spacing-lg;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
|
||||
.cameraTitle {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $color-text-white;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.formatSelector {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.formatChip {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: $spacing-sm 0;
|
||||
border-radius: $radius-sm;
|
||||
background: #333;
|
||||
font-size: $font-size-sm;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
transition: all $transition-base;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.formatChipActive {
|
||||
background: $color-primary;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.playBtn {
|
||||
width: 100%;
|
||||
height: $button-height-lg;
|
||||
border-radius: $radius-button;
|
||||
background: linear-gradient(135deg, $color-primary 0%, $color-primary-light 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all $transition-base;
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.playBtnText {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.infoSection {
|
||||
padding: $spacing-lg;
|
||||
background: #1a1a1a;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
font-size: $font-size-md;
|
||||
font-weight: $font-weight-medium;
|
||||
color: $color-text-white;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 2rpx solid #333;
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
font-size: $font-size-sm;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
font-size: $font-size-sm;
|
||||
color: $color-text-white;
|
||||
}
|
||||
|
||||
.errorWrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-xl 0;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
font-size: 56rpx;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.errorText {
|
||||
font-size: $font-size-sm;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Video } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import styles from './index.module.scss';
|
||||
import { getCameras, playCamera } from '@/api/video';
|
||||
import { resolveUrl } from '@/api/config';
|
||||
import type { Camera, VideoPlayResponse } from '@/types';
|
||||
|
||||
const VideoPlayerPage: React.FC = () => {
|
||||
const router = useRouter();
|
||||
const cameraId = router.params.id ? Number(router.params.id) : 0;
|
||||
const cameraName = router.params.name ? decodeURIComponent(router.params.name) : '视频播放';
|
||||
const directUrl = router.params.url ? decodeURIComponent(router.params.url) : '';
|
||||
|
||||
const [camera, setCamera] = useState<Camera | null>(null);
|
||||
const [videoUrl, setVideoUrl] = useState<string>(directUrl);
|
||||
const [format, setFormat] = useState<'hls' | 'flv' | 'webrtc'>('hls');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (directUrl) {
|
||||
setVideoUrl(resolveUrl(directUrl));
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchCamera = async () => {
|
||||
try {
|
||||
const cameras = await getCameras().catch(() => [] as Camera[]);
|
||||
const found = cameras.find((c) => c.id === cameraId);
|
||||
if (found) {
|
||||
setCamera(found);
|
||||
const url = found.hlsUrl || found.streamUrl || found.flvUrl;
|
||||
if (url) {
|
||||
setVideoUrl(resolveUrl(url));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[VideoPlayer] 获取摄像头信息失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCamera();
|
||||
}, [cameraId, directUrl]);
|
||||
|
||||
const handlePlay = async () => {
|
||||
if (!cameraId && !camera) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const streamUrl = resolveUrl(`/api/v1/video/cameras/${cameraId || camera!.id}/live/stream`);
|
||||
console.log('[VideoPlayer] 获取播放地址成功:', streamUrl);
|
||||
setVideoUrl(streamUrl);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const formats: { key: 'hls' | 'flv' | 'webrtc'; label: string }[] = [
|
||||
{ key: 'hls', label: 'HLS' },
|
||||
{ key: 'flv', label: 'FLV' },
|
||||
{ key: 'webrtc', label: 'WebRTC' },
|
||||
];
|
||||
|
||||
const isLive = !directUrl;
|
||||
|
||||
return (
|
||||
<View className={styles.playerPage}>
|
||||
{/* 视频播放区域 */}
|
||||
<View className={styles.videoContainer}>
|
||||
{videoUrl ? (
|
||||
<Video
|
||||
className={styles.video}
|
||||
src={videoUrl}
|
||||
autoplay
|
||||
controls
|
||||
loop={!isLive}
|
||||
muted={false}
|
||||
showFullscreenBtn
|
||||
showPlayBtn
|
||||
showCenterPlayBtn
|
||||
objectFit="contain"
|
||||
onError={(e) => {
|
||||
console.error('[VideoPlayer] 视频播放错误:', e);
|
||||
setError('视频播放失败');
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View className={styles.videoPlaceholder}>
|
||||
<Text className={styles.placeholderIcon}>📹</Text>
|
||||
<Text className={styles.placeholderText}>
|
||||
{loading ? '正在获取视频流...' : '点击下方按钮开始播放'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{loading && videoUrl && (
|
||||
<View className={styles.loadingOverlay}>
|
||||
<Text className={styles.loadingText}>加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 控制区域 */}
|
||||
{isLive && (
|
||||
<View className={styles.controls}>
|
||||
<Text className={styles.cameraTitle}>{camera?.name || cameraName}</Text>
|
||||
|
||||
<View className={styles.formatSelector}>
|
||||
{formats.map((f) => (
|
||||
<View
|
||||
key={f.key}
|
||||
className={`${styles.formatChip} ${format === f.key ? styles.formatChipActive : ''}`}
|
||||
onClick={() => setFormat(f.key)}
|
||||
>
|
||||
<Text>{f.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={styles.playBtn}
|
||||
onClick={handlePlay}
|
||||
>
|
||||
<Text className={styles.playBtnText}>
|
||||
{loading ? '加载中...' : videoUrl ? '重新播放' : '开始播放'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 摄像头信息 */}
|
||||
{camera && (
|
||||
<View className={styles.infoSection}>
|
||||
<Text className={styles.infoTitle}>设备信息</Text>
|
||||
<View className={styles.infoRow}>
|
||||
<Text className={styles.infoLabel}>编码</Text>
|
||||
<Text className={styles.infoValue}>{camera.code}</Text>
|
||||
</View>
|
||||
<View className={styles.infoRow}>
|
||||
<Text className={styles.infoLabel}>状态</Text>
|
||||
<Text className={styles.infoValue}>{camera.isOnline ? '在线' : '离线'}</Text>
|
||||
</View>
|
||||
{camera.position && (
|
||||
<View className={styles.infoRow}>
|
||||
<Text className={styles.infoLabel}>位置</Text>
|
||||
<Text className={styles.infoValue}>{camera.position}</Text>
|
||||
</View>
|
||||
)}
|
||||
{camera.resolution && (
|
||||
<View className={styles.infoRow}>
|
||||
<Text className={styles.infoLabel}>分辨率</Text>
|
||||
<Text className={styles.infoValue}>{camera.resolution}</Text>
|
||||
</View>
|
||||
)}
|
||||
{camera.gbDeviceId && (
|
||||
<View className={styles.infoRow}>
|
||||
<Text className={styles.infoLabel}>国标ID</Text>
|
||||
<Text className={styles.infoValue}>{camera.gbDeviceId}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View className={styles.errorWrap}>
|
||||
<Text className={styles.errorIcon}>⚠️</Text>
|
||||
<Text className={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayerPage;
|
||||
Reference in New Issue
Block a user