mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 13:32:16 +08:00
refactor(shared): replace the traffic error boundary with ErrorBoundary
TrafficErrorBoundary maintained a second boundary system beside BaseErrorBoundary: a report builder that only console.logged constants in the Tauri webview, an onError that re-logged what componentDidCatch already logged, and a fallback wrapper with a single consumer. Fold both mount points into react-error-boundary (the primitive behind BaseErrorBoundary) with local fallbacks that keep the panel styling, message text and retry reset, log each error once, and drop the retry attempt cap and the stack-details collapse along with it.
This commit is contained in:
@@ -3,10 +3,14 @@ import {
|
||||
ArrowUpwardRounded,
|
||||
CloudDownloadRounded,
|
||||
CloudUploadRounded,
|
||||
ErrorOutlineRounded,
|
||||
LinkRounded,
|
||||
MemoryRounded,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
PaletteColor,
|
||||
Paper,
|
||||
@@ -15,9 +19,9 @@ import {
|
||||
useTheme,
|
||||
} from '@mui/material'
|
||||
import { ReactNode, memo, useMemo, useRef } from 'react'
|
||||
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
||||
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
|
||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||
@@ -127,6 +131,50 @@ const CompactStatCard = memo(
|
||||
// 添加显示名称
|
||||
CompactStatCard.displayName = 'CompactStatCard'
|
||||
|
||||
const fallbackSx = {
|
||||
p: 2,
|
||||
minHeight: 200,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'error.main',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'error.light',
|
||||
}
|
||||
|
||||
const reloadPage = () => window.location.reload()
|
||||
|
||||
const TrafficFallback = ({ error, resetErrorBoundary }: FallbackProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Box sx={fallbackSx}>
|
||||
<ErrorOutlineRounded color="error" sx={{ fontSize: 48, mb: 2 }} />
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('shared.feedback.errors.trafficStats')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
{t('shared.feedback.errors.trafficStatsDescription')}
|
||||
</Typography>
|
||||
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t('shared.feedback.errors.unknown')}
|
||||
</Alert>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button variant="contained" size="small" onClick={resetErrorBoundary}>
|
||||
{t('shared.actions.retry')}
|
||||
</Button>
|
||||
<Button variant="outlined" size="small" onClick={reloadPage}>
|
||||
{t('shared.actions.refreshPage')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const EnhancedTrafficStats = () => {
|
||||
const { t } = useTranslation()
|
||||
const theme = useTheme()
|
||||
@@ -254,10 +302,11 @@ export const EnhancedTrafficStats = () => {
|
||||
}, [t, parsedData, displayMemory])
|
||||
|
||||
return (
|
||||
<TrafficErrorBoundary
|
||||
onError={(error, errorInfo) => {
|
||||
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
|
||||
}}
|
||||
<ErrorBoundary
|
||||
FallbackComponent={TrafficFallback}
|
||||
onError={(error, errorInfo) =>
|
||||
console.error('[EnhancedTrafficStats]', error, errorInfo)
|
||||
}
|
||||
>
|
||||
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
||||
{trafficGraph && (
|
||||
@@ -273,6 +322,6 @@ export const EnhancedTrafficStats = () => {
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</TrafficErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import {
|
||||
ArrowDownwardRounded,
|
||||
ArrowUpwardRounded,
|
||||
ErrorOutlineRounded,
|
||||
MemoryRounded,
|
||||
} from '@mui/icons-material'
|
||||
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ErrorBoundary } from 'react-error-boundary'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||
import { useVerge } from '@/hooks/use-verge'
|
||||
@@ -17,6 +18,30 @@ import parseTraffic from '@/utils/parse-traffic'
|
||||
|
||||
import { TrafficGraph, type TrafficRef } from './traffic-graph'
|
||||
|
||||
const unavailableSx = {
|
||||
p: 1,
|
||||
minHeight: 60,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'error.light',
|
||||
borderRadius: 1,
|
||||
color: 'error.contrastText',
|
||||
}
|
||||
|
||||
const TrafficUnavailableFallback = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Box sx={unavailableSx}>
|
||||
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
|
||||
<Typography variant="caption">
|
||||
{t('shared.feedback.errors.trafficUnavailable')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const LayoutTraffic = () => {
|
||||
const { t } = useTranslation()
|
||||
const { verge } = useVerge()
|
||||
@@ -75,7 +100,12 @@ export const LayoutTraffic = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<LightweightTrafficErrorBoundary>
|
||||
<ErrorBoundary
|
||||
FallbackComponent={TrafficUnavailableFallback}
|
||||
onError={(error, errorInfo) =>
|
||||
console.error('[LayoutTraffic]', error, errorInfo)
|
||||
}
|
||||
>
|
||||
<Box sx={{ position: 'relative' }}>
|
||||
{trafficGraph && pageVisible && (
|
||||
<div
|
||||
@@ -138,6 +168,6 @@ export const LayoutTraffic = () => {
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</LightweightTrafficErrorBoundary>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
import {
|
||||
ErrorOutlineRounded,
|
||||
RefreshRounded,
|
||||
BugReportRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Typography, Button, Alert, Collapse } from '@mui/material'
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallbackComponent?: ReactNode
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
showDetails: boolean
|
||||
}
|
||||
|
||||
export class TrafficErrorBoundary extends Component<Props, State> {
|
||||
private retryCount = 0
|
||||
private maxRetries = 3
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
showDetails: false,
|
||||
}
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('[TrafficErrorBoundary] 捕获到组件错误:', error, errorInfo)
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
})
|
||||
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo)
|
||||
}
|
||||
|
||||
this.reportError(error, errorInfo)
|
||||
}
|
||||
|
||||
private reportError = (error: Error, errorInfo: ErrorInfo) => {
|
||||
const errorReport = {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
}
|
||||
|
||||
console.error('[TrafficErrorBoundary] 错误报告:', errorReport)
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++
|
||||
console.log(
|
||||
`[TrafficErrorBoundary] 尝试重试 (${this.retryCount}/${this.maxRetries})`,
|
||||
)
|
||||
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
showDetails: false,
|
||||
})
|
||||
} else {
|
||||
console.warn('[TrafficErrorBoundary] 已达到最大重试次数')
|
||||
}
|
||||
}
|
||||
|
||||
private handleRefresh = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
private toggleDetails = () => {
|
||||
this.setState((prev) => ({ showDetails: !prev.showDetails }))
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallbackComponent) {
|
||||
return this.props.fallbackComponent
|
||||
}
|
||||
|
||||
return (
|
||||
<TrafficErrorFallback
|
||||
error={this.state.error}
|
||||
errorInfo={this.state.errorInfo}
|
||||
showDetails={this.state.showDetails}
|
||||
canRetry={this.retryCount < this.maxRetries}
|
||||
retryCount={this.retryCount}
|
||||
maxRetries={this.maxRetries}
|
||||
onRetry={this.handleRetry}
|
||||
onRefresh={this.handleRefresh}
|
||||
onToggleDetails={this.toggleDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
interface TrafficErrorFallbackProps {
|
||||
error: Error | null
|
||||
errorInfo: ErrorInfo | null
|
||||
showDetails: boolean
|
||||
canRetry: boolean
|
||||
retryCount: number
|
||||
maxRetries: number
|
||||
onRetry: () => void
|
||||
onRefresh: () => void
|
||||
onToggleDetails: () => void
|
||||
}
|
||||
|
||||
const TrafficErrorFallback: React.FC<TrafficErrorFallbackProps> = ({
|
||||
error,
|
||||
errorInfo,
|
||||
showDetails,
|
||||
canRetry,
|
||||
retryCount,
|
||||
maxRetries,
|
||||
onRetry,
|
||||
onRefresh,
|
||||
onToggleDetails,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
minHeight: 200,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed',
|
||||
borderColor: 'error.main',
|
||||
borderRadius: 2,
|
||||
bgcolor: 'error.light',
|
||||
color: 'error.contrastText',
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineRounded sx={{ fontSize: 48, mb: 2, color: 'error.main' }} />
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('shared.feedback.errors.trafficStats')}
|
||||
</Typography>
|
||||
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 2, textAlign: 'center' }}
|
||||
>
|
||||
{t('shared.feedback.errors.trafficStatsDescription')}
|
||||
</Typography>
|
||||
|
||||
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
|
||||
<Typography variant="body2">
|
||||
<strong>{t('shared.feedback.errors.label')}:</strong>{' '}
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t('shared.feedback.errors.unknown')}
|
||||
</Typography>
|
||||
{retryCount > 0 && (
|
||||
<Typography variant="caption" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('shared.labels.retryAttempts')}: {retryCount}/{maxRetries}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{canRetry && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<RefreshRounded />}
|
||||
onClick={onRetry}
|
||||
size="small"
|
||||
>
|
||||
{t('shared.actions.retry')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="outlined" onClick={onRefresh} size="small">
|
||||
{t('shared.actions.refreshPage')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
startIcon={<BugReportRounded />}
|
||||
onClick={onToggleDetails}
|
||||
size="small"
|
||||
>
|
||||
{showDetails
|
||||
? t('shared.actions.hideDetails')
|
||||
: t('shared.actions.showDetails')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Collapse in={showDetails} sx={{ width: '100%', maxWidth: 600 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
{t('shared.feedback.errors.details')}:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{error?.stack}
|
||||
</Typography>
|
||||
|
||||
{errorInfo?.componentStack && (
|
||||
<>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
|
||||
{t('shared.feedback.errors.componentStack')}:
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
component="pre"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.75rem',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{errorInfo.componentStack}
|
||||
</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const LightweightTrafficErrorBoundary: React.FC<{
|
||||
children: ReactNode
|
||||
}> = ({ children }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<TrafficErrorBoundary
|
||||
fallbackComponent={
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 60,
|
||||
bgcolor: 'error.light',
|
||||
borderRadius: 1,
|
||||
color: 'error.contrastText',
|
||||
}}
|
||||
>
|
||||
<ErrorOutlineRounded sx={{ mr: 1, fontSize: 20 }} />
|
||||
<Typography variant="caption">
|
||||
{t('shared.feedback.errors.trafficUnavailable')}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TrafficErrorBoundary>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user