Files
silk/app/src/screens/AlertsScreen.tsx
T

313 lines
8.5 KiB
TypeScript

import React, { useEffect, useState, useCallback } from 'react';
import { StyleSheet, View, FlatList, RefreshControl, Alert } from 'react-native';
import { Text, Card, useTheme, ActivityIndicator, Surface, SegmentedButtons, Button, Snackbar } from 'react-native-paper';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native';
import { getAlarms, ackAlarm, getAlarmClip } from '../api/alarms';
import { extractErrorMessage } from '../api/client';
import { formatRelativeTime, getSeverityLabel } from '../utils/format';
import type { Alarm } from '../types';
export default function AlertsScreen() {
const theme = useTheme();
const navigation = useNavigation<any>();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [filter, setFilter] = useState('open');
const [error, setError] = useState<string | null>(null);
const [snackMsg, setSnackMsg] = useState('');
const [snackVisible, setSnackVisible] = useState(false);
const [ackingId, setAkingId] = useState<string | null>(null);
const getSevColor = (severity?: string) => {
switch (severity?.toLowerCase()) {
case 'danger':
case 'critical':
case 'high':
return '#D4847A';
case 'warn':
case 'warning':
case 'medium':
return '#D4B87A';
case 'info':
case 'low':
return '#6B8F71';
default:
return '#B8B3AA';
}
};
const loadData = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const data = await getAlarms({ openOnly: filter === 'open' });
setAlarms(data);
setError(null);
} catch (err: any) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
setRefreshing(false);
}
}, [filter]);
useEffect(() => {
setLoading(true);
loadData();
}, [loadData]);
const handleAck = async (id: string) => {
setAkingId(id);
try {
await ackAlarm(id);
setAlarms((prev) =>
prev.map((a) => (a.id === id ? { ...a, acknowledged: true, open: false } : a)),
);
setSnackMsg('告警已确认');
setSnackVisible(true);
} catch (err: any) {
Alert.alert('操作失败', extractErrorMessage(err));
} finally {
setAkingId(null);
}
};
const handleViewClip = async (alarm: Alarm) => {
try {
const clip = await getAlarmClip(alarm.id);
if (clip.playbackUrl) {
Alert.alert(
'告警片段',
`开始时间: ${formatRelativeTime(clip.startAt)}\n结束时间: ${formatRelativeTime(clip.endAt)}`,
);
} else {
Alert.alert('提示', '暂无关联的视频片段');
}
} catch (err: any) {
Alert.alert('获取片段失败', extractErrorMessage(err));
}
};
const renderItem = ({ item }: { item: Alarm }) => {
const sevColor = getSevColor(item.severity || item.level);
return (
<Card style={[styles.card, { borderLeftColor: sevColor, borderLeftWidth: 4 }]}>
<Card.Content style={styles.cardContent}>
<View style={styles.alarmHeader}>
<View style={styles.alarmTitleRow}>
<Surface style={[styles.sevBadge, { backgroundColor: sevColor }]}>
<Text style={styles.sevBadgeText}>{getSeverityLabel(item.severity || item.level)}</Text>
</Surface>
<Text variant="bodyMedium" style={styles.alarmTitle}>
{item.title || item.message || item.content || item.code || '告警'}
</Text>
</View>
{item.open ? (
<Surface style={styles.openBadge}>
<Text style={styles.openBadgeText}>未处理</Text>
</Surface>
) : (
<Surface style={[styles.openBadge, { backgroundColor: '#F0EDE8' }]}>
<Text style={[styles.openBadgeText, { color: '#8C8780' }]}>已关闭</Text>
</Surface>
)}
</View>
{item.message && item.title ? (
<Text variant="bodySmall" style={styles.alarmMessage}>{item.message}</Text>
) : null}
<View style={styles.alarmMeta}>
{item.deviceKey ? <Text style={styles.metaText}>设备: {item.deviceKey}</Text> : null}
{item.metric ? <Text style={styles.metaText}>指标: {item.metric}</Text> : null}
{item.value !== undefined ? <Text style={styles.metaText}>: {item.value}</Text> : null}
</View>
<View style={styles.alarmFooter}>
<Text style={styles.timeText}>
{formatRelativeTime(item.triggeredAt || item.createdAt)}
</Text>
<View style={styles.footerActions}>
{item.open && !item.acknowledged ? (
<Button
mode="text"
onPress={() => handleAck(item.id)}
loading={ackingId === item.id}
disabled={ackingId === item.id}
textColor="#5C7D60"
>
确认
</Button>
) : null}
<Button
mode="text"
onPress={() => handleViewClip(item)}
textColor="#6B8F71"
icon="video"
>
片段
</Button>
</View>
</View>
</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={filter}
onValueChange={setFilter}
buttons={[
{ value: 'open', label: '未处理' },
{ value: 'all', label: '全部' },
]}
/>
</View>
<FlatList
data={alarms}
keyExtractor={(item) => item.id}
renderItem={renderItem}
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}>暂无告警</Text>
</View>
)
}
/>
<Snackbar
visible={snackVisible}
onDismiss={() => setSnackVisible(false)}
duration={2000}
>
{snackMsg}
</Snackbar>
</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: {
paddingVertical: 12,
},
alarmHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 4,
},
alarmTitleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
flex: 1,
},
sevBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
elevation: 0,
},
sevBadgeText: {
color: 'white',
fontSize: 11,
fontWeight: '500',
},
alarmTitle: {
color: '#2D2A26',
fontWeight: '500',
flex: 1,
},
openBadge: {
backgroundColor: '#F9E8E5',
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
elevation: 0,
},
openBadgeText: {
color: '#D4847A',
fontSize: 11,
fontWeight: '500',
},
alarmMessage: {
color: '#8C8780',
marginTop: 4,
},
alarmMeta: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 12,
marginTop: 8,
},
metaText: {
fontSize: 12,
color: '#B8B3AA',
},
alarmFooter: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 8,
},
timeText: {
fontSize: 12,
color: '#B8B3AA',
},
footerActions: {
flexDirection: 'row',
gap: 4,
},
emptyContainer: {
alignItems: 'center',
paddingTop: 64,
},
emptyText: {
color: '#B8B3AA',
fontSize: 16,
},
});