chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { StyleSheet, View, FlatList, RefreshControl, Alert } from 'react-native';
|
||||
import { Text, Card, Button, useTheme, ActivityIndicator, Surface, IconButton, SegmentedButtons } from 'react-native-paper';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { getCameras, getClips, playCamera } from '../api/video';
|
||||
import { extractErrorMessage, resolveUrl } from '../api/client';
|
||||
import { formatDateTime, formatDuration, formatFileSize } from '../utils/format';
|
||||
import type { Camera, VideoClip } from '../types';
|
||||
|
||||
export default function VideoScreen() {
|
||||
const theme = useTheme();
|
||||
const navigation = useNavigation<any>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||
const [clips, setClips] = useState<VideoClip[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tab, setTab] = useState('cameras');
|
||||
|
||||
const loadData = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true);
|
||||
try {
|
||||
const [camerasData, clipsData] = await Promise.all([
|
||||
getCameras().catch(() => [] as Camera[]),
|
||||
getClips({ limit: 20 }).catch(() => [] as VideoClip[]),
|
||||
]);
|
||||
setCameras(camerasData);
|
||||
setClips(clipsData);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const handlePlay = async (camera: Camera) => {
|
||||
const streamUrl = resolveUrl(`/api/v1/video/cameras/${camera.id}/live/stream`);
|
||||
navigation.navigate('VideoPlayer', {
|
||||
cameraId: camera.id,
|
||||
cameraName: camera.name,
|
||||
streamUrl,
|
||||
});
|
||||
};
|
||||
|
||||
const handlePlayClip = async (clip: VideoClip) => {
|
||||
if (clip.playbackUrl) {
|
||||
navigation.navigate('VideoPlayer', {
|
||||
cameraId: clip.cameraId,
|
||||
cameraName: `录像片段 ${clip.startAt ? formatDateTime(clip.startAt) : ''}`,
|
||||
streamUrl: resolveUrl(clip.playbackUrl),
|
||||
});
|
||||
} else {
|
||||
Alert.alert('提示', '该录像暂无播放地址');
|
||||
}
|
||||
};
|
||||
|
||||
const renderCamera = ({ item }: { item: Camera }) => {
|
||||
const isOnline = item.isOnline ?? item.online;
|
||||
return (
|
||||
<Card style={styles.card}>
|
||||
<Card.Content style={styles.cardContent}>
|
||||
<View style={styles.cameraInfo}>
|
||||
<View style={[styles.iconBox, { backgroundColor: isOnline ? '#E8F0E9' : '#F5F0E8' }]}>
|
||||
<IconButton icon="video" size={24} iconColor={isOnline ? '#7A9E7E' : '#B8B3AA'} />
|
||||
</View>
|
||||
<View style={styles.cameraDetail}>
|
||||
<Text variant="bodyMedium" style={styles.cameraName}>{item.name}</Text>
|
||||
<Text variant="bodySmall" style={styles.cameraCode}>编号: {item.code}</Text>
|
||||
{item.position ? <Text variant="bodySmall" style={styles.cameraMeta}>📍 {item.position}</Text> : null}
|
||||
{item.resolution ? <Text variant="bodySmall" style={styles.cameraMeta}>分辨率: {item.resolution}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.cameraRight}>
|
||||
<Surface style={[styles.statusBadge, { backgroundColor: isOnline ? '#7A9E7E' : '#B8B3AA' }]}>
|
||||
<Text style={styles.statusText}>{isOnline ? '在线' : '离线'}</Text>
|
||||
</Surface>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={() => handlePlay(item)}
|
||||
disabled={!isOnline}
|
||||
style={styles.playBtn}
|
||||
buttonColor="#7A9E7E"
|
||||
icon="play"
|
||||
>
|
||||
播放
|
||||
</Button>
|
||||
</View>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderClip = ({ item }: { item: VideoClip }) => (
|
||||
<Card style={styles.card} onPress={() => handlePlayClip(item)}>
|
||||
<Card.Content style={styles.cardContent}>
|
||||
<View style={styles.clipInfo}>
|
||||
<IconButton icon="filmstrip" size={28} iconColor={theme.colors.primary} />
|
||||
<View style={styles.clipDetail}>
|
||||
<Text variant="bodyMedium" style={styles.clipTitle}>
|
||||
{item.trigger === 'alarm' ? '告警录像' : item.trigger === 'manual' ? '手动录像' : item.trigger === 'schedule' ? '定时录像' : '录像片段'}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={styles.clipMeta}>
|
||||
开始: {formatDateTime(item.startAt)}
|
||||
</Text>
|
||||
<Text variant="bodySmall" style={styles.clipMeta}>
|
||||
时长: {formatDuration(item.durationSec)}
|
||||
{item.resolution ? ` · ${item.resolution}` : ''}
|
||||
</Text>
|
||||
{item.sizeBytes ? (
|
||||
<Text variant="bodySmall" style={styles.clipMeta}>
|
||||
大小: {formatFileSize(item.sizeBytes)}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<IconButton icon="play-circle-outline" size={32} iconColor={theme.colors.primary} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.center}>
|
||||
<ActivityIndicator size="large" />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<View style={styles.filterContainer}>
|
||||
<SegmentedButtons
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
buttons={[
|
||||
{ value: 'cameras', label: '摄像头' },
|
||||
{ value: 'clips', label: '录像片段' },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
data={(tab === 'cameras' ? cameras : clips) as any[]}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
renderItem={(tab === 'cameras' ? renderCamera : renderClip) as any}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={refreshing} onRefresh={() => loadData(true)} />
|
||||
}
|
||||
ListEmptyComponent={
|
||||
error ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>{error}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Text style={styles.emptyText}>
|
||||
{tab === 'cameras' ? '暂无摄像头' : '暂无录像片段'}
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#FAF8F5',
|
||||
},
|
||||
center: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
filterContainer: {
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 32,
|
||||
},
|
||||
card: {
|
||||
marginBottom: 12,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
cardContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
cameraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
iconBox: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
cameraDetail: {
|
||||
flex: 1,
|
||||
marginLeft: 4,
|
||||
},
|
||||
cameraName: {
|
||||
color: '#2D2A26',
|
||||
fontWeight: '500',
|
||||
},
|
||||
cameraCode: {
|
||||
color: '#B8B3AA',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
cameraMeta: {
|
||||
color: '#B8B3AA',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
cameraRight: {
|
||||
alignItems: 'flex-end',
|
||||
gap: 6,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
elevation: 0,
|
||||
},
|
||||
statusText: {
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontWeight: '500',
|
||||
},
|
||||
playBtn: {
|
||||
borderRadius: 999,
|
||||
},
|
||||
clipInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
clipDetail: {
|
||||
flex: 1,
|
||||
marginLeft: 4,
|
||||
},
|
||||
clipTitle: {
|
||||
color: '#2D2A26',
|
||||
fontWeight: '500',
|
||||
},
|
||||
clipMeta: {
|
||||
color: '#B8B3AA',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
emptyContainer: {
|
||||
alignItems: 'center',
|
||||
paddingTop: 64,
|
||||
},
|
||||
emptyText: {
|
||||
color: '#B8B3AA',
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user