chore: 初始化仓库基线(AGENTS.md、git 规范、敏感文件排除)
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user