mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-29 01:52:08 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd6be642e | ||
|
|
433230aedc | ||
|
|
05475c7bb5 | ||
|
|
eb60c21c62 | ||
|
|
4489c9a26f | ||
|
|
98a7932742 | ||
|
|
c678d60964 | ||
|
|
d5bc4ff58b | ||
|
|
b81981162b | ||
|
|
6fa6d7664f | ||
|
|
4967ad347e | ||
|
|
6394f347f5 | ||
|
|
e56711b2db | ||
|
|
3e28b287d1 | ||
|
|
51a4aa80dd |
+1
-1
@@ -79,7 +79,7 @@
|
|||||||
"react-dom": "19.3.1",
|
"react-dom": "19.3.1",
|
||||||
"react-error-boundary": "6.1.6",
|
"react-error-boundary": "6.1.6",
|
||||||
"react-hook-form": "^7.88.0",
|
"react-hook-form": "^7.88.0",
|
||||||
"react-i18next": "17.1.9",
|
"react-i18next": "17.1.8",
|
||||||
"react-markdown": "10.1.0",
|
"react-markdown": "10.1.0",
|
||||||
"react-router": "^8.4.0",
|
"react-router": "^8.4.0",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
|||||||
Generated
+5
-5
@@ -275,8 +275,8 @@ importers:
|
|||||||
specifier: ^7.88.0
|
specifier: ^7.88.0
|
||||||
version: 7.88.0(react@19.3.1)
|
version: 7.88.0(react@19.3.1)
|
||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: 17.1.9
|
specifier: 17.1.8
|
||||||
version: 17.1.9(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
|
version: 17.1.8(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
|
||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: 10.1.0
|
specifier: 10.1.0
|
||||||
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
|
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
|
||||||
@@ -4443,8 +4443,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||||
|
|
||||||
react-i18next@17.1.9:
|
react-i18next@17.1.8:
|
||||||
resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==}
|
resolution: {integrity: sha512-vGH+Tbhqbv5LdN9KvC9GEmQ6ATxJAjyxPEK0zSCSRlkeDqCPNxVtEOvDNRAz+51HTxc6QPwhnHaFD7ICu6pD1A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
i18next: '>= 26.2.0'
|
i18next: '>= 26.2.0'
|
||||||
react: '>= 16.8.0'
|
react: '>= 16.8.0'
|
||||||
@@ -9529,7 +9529,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.3.1
|
react: 19.3.1
|
||||||
|
|
||||||
react-i18next@17.1.9(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
|
react-i18next@17.1.8(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
html-parse-stringify: 4.0.1
|
html-parse-stringify: 4.0.1
|
||||||
|
|||||||
@@ -21,3 +21,27 @@ These rules supplement the repository's root `AGENTS.md`.
|
|||||||
- **Command ownership.** `services` owns backend command invocation and transport
|
- **Command ownership.** `services` owns backend command invocation and transport
|
||||||
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
wrappers. Components and hooks use those boundaries; plugin mutations follow
|
||||||
the same intent ownership as backend commands.
|
the same intent ownership as backend commands.
|
||||||
|
- **State liveness.** Every `useState`/`useRef` member has a reachable write
|
||||||
|
that differs from its initial value and a read that observes the difference.
|
||||||
|
A value that can only ever hold its initial value is a constant: declare the
|
||||||
|
constant and delete the state. A ref nothing reads is deleted, not kept.
|
||||||
|
- **Surface liveness.** Anything a module exposes — props, parameters, context
|
||||||
|
members, imperative-handle methods — ships with at least one external caller
|
||||||
|
in the same change. "Reserved for future use" fields, `_`-prefixed params
|
||||||
|
that callers still pass, and error or callback channels that every receiver
|
||||||
|
ignores are removed, not maintained.
|
||||||
|
- **Path liveness.** A fallback or branch names the concrete runtime condition
|
||||||
|
that reaches it. If no supported runtime can reach it, the branch is not
|
||||||
|
added — or it replaces the primary path. Constant subexpressions and cleanup
|
||||||
|
of resources that cannot exist yet are simplified away.
|
||||||
|
- **Effect economy.** One user interaction produces one state write and one
|
||||||
|
command. A handler does not re-apply a value its wrapper already applied,
|
||||||
|
and an effect does not re-run a refresh another branch of the same effect
|
||||||
|
just ran.
|
||||||
|
- **Mount economy.** Components that issue commands or subscriptions on mount
|
||||||
|
are mounted only where their UI is reachable. A dialog no rendered control
|
||||||
|
can open stays unmounted, not mounted-and-idle.
|
||||||
|
- **Resource bounds.** Every cache key, timer and subscription a feature
|
||||||
|
allocates is reused or released on refresh; refresh never allocates an
|
||||||
|
unbounded sequence of new keys. Retries re-create per-attempt resources so
|
||||||
|
a retry can actually succeed.
|
||||||
|
|||||||
@@ -22,23 +22,16 @@ interface BaseSplitChipEditorProps {
|
|||||||
error?: boolean
|
error?: boolean
|
||||||
helperText?: ReactNode
|
helperText?: ReactNode
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
rows?: number
|
|
||||||
separator?: string
|
separator?: string
|
||||||
splitPattern?: RegExp
|
|
||||||
defaultMode?: BaseSplitChipEditorMode
|
|
||||||
showModeToggle?: boolean
|
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
addLabel?: ReactNode
|
|
||||||
emptyLabel?: ReactNode
|
|
||||||
modeLabels?: Partial<Record<BaseSplitChipEditorMode, ReactNode>>
|
|
||||||
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
renderHeader?: (modeToggle: ReactNode) => ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
const DEFAULT_SPLIT_PATTERN = /[,\n;\r]+/
|
||||||
|
|
||||||
const splitValue = (value: string, splitPattern: RegExp) =>
|
const splitValue = (value: string) =>
|
||||||
value
|
value
|
||||||
.split(splitPattern)
|
.split(DEFAULT_SPLIT_PATTERN)
|
||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
@@ -49,35 +42,25 @@ export const BaseSplitChipEditor = ({
|
|||||||
error = false,
|
error = false,
|
||||||
helperText,
|
helperText,
|
||||||
placeholder,
|
placeholder,
|
||||||
rows = 4,
|
|
||||||
separator = ',',
|
separator = ',',
|
||||||
splitPattern = DEFAULT_SPLIT_PATTERN,
|
|
||||||
defaultMode = 'visual',
|
|
||||||
showModeToggle = true,
|
|
||||||
ariaLabel,
|
ariaLabel,
|
||||||
addLabel,
|
|
||||||
emptyLabel,
|
|
||||||
modeLabels,
|
|
||||||
renderHeader,
|
renderHeader,
|
||||||
}: BaseSplitChipEditorProps) => {
|
}: BaseSplitChipEditorProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [mode, setMode] = useState<BaseSplitChipEditorMode>(defaultMode)
|
const [mode, setMode] = useState<BaseSplitChipEditorMode>('visual')
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
|
|
||||||
const resolvedLabels = useMemo(
|
const resolvedLabels = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
visual: modeLabels?.visual ?? t('shared.editorModes.visualization'),
|
visual: t('shared.editorModes.visualization'),
|
||||||
advanced: modeLabels?.advanced ?? t('shared.editorModes.advanced'),
|
advanced: t('shared.editorModes.advanced'),
|
||||||
add: addLabel ?? t('shared.actions.new'),
|
add: t('shared.actions.new'),
|
||||||
empty: emptyLabel ?? t('shared.statuses.empty'),
|
empty: t('shared.statuses.empty'),
|
||||||
}),
|
}),
|
||||||
[t, modeLabels, addLabel, emptyLabel],
|
[t],
|
||||||
)
|
)
|
||||||
|
|
||||||
const values = useMemo(
|
const values = useMemo(() => splitValue(value), [value])
|
||||||
() => splitValue(value, splitPattern),
|
|
||||||
[value, splitPattern],
|
|
||||||
)
|
|
||||||
|
|
||||||
const items = useMemo(() => {
|
const items = useMemo(() => {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<string, number>()
|
||||||
@@ -92,7 +75,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
}, [values])
|
}, [values])
|
||||||
|
|
||||||
const handleAddDraft = () => {
|
const handleAddDraft = () => {
|
||||||
const nextValues = splitValue(draft, splitPattern)
|
const nextValues = splitValue(draft)
|
||||||
if (!nextValues.length) {
|
if (!nextValues.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,7 +96,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
const resolvedAriaLabel =
|
const resolvedAriaLabel =
|
||||||
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
ariaLabel ?? (typeof toggleLabel === 'string' ? toggleLabel : undefined)
|
||||||
|
|
||||||
const modeToggle = showModeToggle ? (
|
const modeToggle = (
|
||||||
<Tooltip title={toggleLabel}>
|
<Tooltip title={toggleLabel}>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
@@ -128,7 +111,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
<ToggleIcon fontSize="small" />
|
<ToggleIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -202,7 +185,7 @@ export const BaseSplitChipEditor = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
size="small"
|
size="small"
|
||||||
multiline
|
multiline
|
||||||
rows={rows}
|
rows={4}
|
||||||
sx={{ width: '100%' }}
|
sx={{ width: '100%' }}
|
||||||
value={value}
|
value={value}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
|
|||||||
@@ -596,7 +596,7 @@ export const CurrentProxyCard = () => {
|
|||||||
[getProfileStorageKey],
|
[getProfileStorageKey],
|
||||||
)
|
)
|
||||||
|
|
||||||
const { handleSelectChange } = useProxySelection({
|
const { select } = useProxySelection({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
refreshProxy()
|
refreshProxy()
|
||||||
},
|
},
|
||||||
@@ -766,15 +766,9 @@ export const CurrentProxyCard = () => {
|
|||||||
const previousProxy = selectedGroup.now
|
const previousProxy = selectedGroup.now
|
||||||
const nextName = option.member.ref.name
|
const nextName = option.member.ref.name
|
||||||
// The profile selection is the durable source across core restarts and run modes.
|
// The profile selection is the durable source across core restarts and run modes.
|
||||||
handleSelectChange(
|
select(selectedGroup.name, nextName, previousProxy!, selectedGroup.fixed!)
|
||||||
selectedGroup.name,
|
|
||||||
previousProxy!,
|
|
||||||
selectedGroup.fixed!,
|
|
||||||
)({
|
|
||||||
target: { value: nextName },
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[handleSelectChange, isDirectMode, selectedGroup, unsortedProxyOptions],
|
[isDirectMode, select, selectedGroup, unsortedProxyOptions],
|
||||||
)
|
)
|
||||||
|
|
||||||
const goToProxies = useCallback(() => {
|
const goToProxies = useCallback(() => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
import { useTrafficGraphDataEnhanced } from '@/hooks/use-traffic-monitor'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
import { debugLog } from '@/utils/debug'
|
|
||||||
import parseTraffic from '@/utils/parse-traffic'
|
import parseTraffic from '@/utils/parse-traffic'
|
||||||
import {
|
import {
|
||||||
formatTrafficHourMinute,
|
formatTrafficHourMinute,
|
||||||
@@ -23,14 +22,7 @@ import {
|
|||||||
isSameTrafficData,
|
isSameTrafficData,
|
||||||
} from '@/utils/traffic-sampler'
|
} from '@/utils/traffic-sampler'
|
||||||
|
|
||||||
interface ITrafficItem {
|
|
||||||
up: number
|
|
||||||
down: number
|
|
||||||
timestamp?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnhancedCanvasTrafficGraphRef {
|
export interface EnhancedCanvasTrafficGraphRef {
|
||||||
appendData: (data: ITrafficItem) => void
|
|
||||||
toggleStyle: () => void
|
toggleStyle: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +134,6 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
const debounceTimeoutRef = useRef<number | null>(null)
|
const debounceTimeoutRef = useRef<number | null>(null)
|
||||||
const [currentFPS, setCurrentFPS] = useState(GRAPH_CONFIG.targetFPS)
|
|
||||||
|
|
||||||
const colors = useMemo(
|
const colors = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -183,8 +174,6 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
lastDataTimestampRef.current = 0
|
lastDataTimestampRef.current = 0
|
||||||
dataStaleRef.current = false
|
dataStaleRef.current = false
|
||||||
// eslint-disable-next-line @eslint-react/set-state-in-effect
|
|
||||||
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,18 +189,11 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
}
|
}
|
||||||
}, [displayData])
|
}, [displayData])
|
||||||
|
|
||||||
const handleFocusStateChange = useCallback(
|
const handleFocusStateChange = useCallback((focused: boolean) => {
|
||||||
(focused: boolean) => {
|
isWindowFocusedRef.current = focused
|
||||||
isWindowFocusedRef.current = focused
|
|
||||||
|
|
||||||
if (focused || !pause_render_traffic_stats_on_blur) {
|
scheduleDrawGraphRef.current()
|
||||||
setCurrentFPS(GRAPH_CONFIG.targetFPS)
|
}, [])
|
||||||
}
|
|
||||||
|
|
||||||
scheduleDrawGraphRef.current()
|
|
||||||
},
|
|
||||||
[pause_render_traffic_stats_on_blur],
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||||
@@ -684,19 +666,13 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(getX(0), getY(0))
|
ctx.moveTo(getX(0), getY(0))
|
||||||
|
|
||||||
if (chartStyle === 'bezier') {
|
for (let i = 1; i < data.length; i++) {
|
||||||
for (let i = 1; i < data.length; i++) {
|
const currentX = getX(i)
|
||||||
const currentX = getX(i)
|
const currentY = getY(i)
|
||||||
const currentY = getY(i)
|
const nextIndex = Math.min(i + 1, lastIndex)
|
||||||
const nextIndex = Math.min(i + 1, lastIndex)
|
const controlX = (currentX + getX(nextIndex)) / 2
|
||||||
const controlX = (currentX + getX(nextIndex)) / 2
|
const controlY = (currentY + getY(nextIndex)) / 2
|
||||||
const controlY = (currentY + getY(nextIndex)) / 2
|
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
||||||
ctx.quadraticCurveTo(currentX, currentY, controlX, controlY)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (let i = 1; i < data.length; i++) {
|
|
||||||
ctx.lineTo(getX(i), getY(i))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.lineTo(getX(lastIndex), height - padding.bottom)
|
ctx.lineTo(getX(lastIndex), height - padding.bottom)
|
||||||
@@ -984,21 +960,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
|
setChartStyle((prev) => (prev === 'bezier' ? 'line' : 'bezier'))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const appendData = useCallback((data: ITrafficItem) => {
|
useImperativeHandle(ref, () => ({ toggleStyle }), [toggleStyle])
|
||||||
debugLog(
|
|
||||||
'[EnhancedCanvasTrafficGraphV2] appendData called (using global data):',
|
|
||||||
data,
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
appendData,
|
|
||||||
toggleStyle,
|
|
||||||
}),
|
|
||||||
[appendData, toggleStyle],
|
|
||||||
)
|
|
||||||
|
|
||||||
const getTimeRangeText = useCallback(() => {
|
const getTimeRangeText = useCallback(() => {
|
||||||
return t('home.components.traffic.patterns.minutes', {
|
return t('home.components.traffic.patterns.minutes', {
|
||||||
@@ -1142,7 +1104,7 @@ export const EnhancedCanvasTrafficGraph = memo(
|
|||||||
{t('home.components.traffic.diagnostics', {
|
{t('home.components.traffic.diagnostics', {
|
||||||
points: displayData.length,
|
points: displayData.length,
|
||||||
compressed: samplerStats.compressedBufferSize,
|
compressed: samplerStats.compressedBufferSize,
|
||||||
fps: currentFPS,
|
fps: GRAPH_CONFIG.targetFPS,
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import {
|
|||||||
ArrowUpwardRounded,
|
ArrowUpwardRounded,
|
||||||
CloudDownloadRounded,
|
CloudDownloadRounded,
|
||||||
CloudUploadRounded,
|
CloudUploadRounded,
|
||||||
|
ErrorOutlineRounded,
|
||||||
LinkRounded,
|
LinkRounded,
|
||||||
MemoryRounded,
|
MemoryRounded,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
Grid,
|
Grid,
|
||||||
PaletteColor,
|
PaletteColor,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -15,9 +19,9 @@ import {
|
|||||||
useTheme,
|
useTheme,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { ReactNode, memo, useMemo, useRef } from 'react'
|
import { ReactNode, memo, useMemo, useRef } from 'react'
|
||||||
|
import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { TrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
|
||||||
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
|
import { useConnectionSummaryData } from '@/hooks/use-connection-data'
|
||||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||||
@@ -127,6 +131,50 @@ const CompactStatCard = memo(
|
|||||||
// 添加显示名称
|
// 添加显示名称
|
||||||
CompactStatCard.displayName = 'CompactStatCard'
|
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 = () => {
|
export const EnhancedTrafficStats = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
@@ -254,10 +302,11 @@ export const EnhancedTrafficStats = () => {
|
|||||||
}, [t, parsedData, displayMemory])
|
}, [t, parsedData, displayMemory])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TrafficErrorBoundary
|
<ErrorBoundary
|
||||||
onError={(error, errorInfo) => {
|
FallbackComponent={TrafficFallback}
|
||||||
console.error('[EnhancedTrafficStats] 组件错误:', error, errorInfo)
|
onError={(error, errorInfo) =>
|
||||||
}}
|
console.error('[EnhancedTrafficStats]', error, errorInfo)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
<Grid container spacing={1} columns={{ xs: 8, sm: 8, md: 12 }}>
|
||||||
{trafficGraph && (
|
{trafficGraph && (
|
||||||
@@ -273,6 +322,6 @@ export const EnhancedTrafficStats = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
</TrafficErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,16 +86,11 @@ export const SystemInfoCard = () => {
|
|||||||
}, [verge, patchVerge])
|
}, [verge, patchVerge])
|
||||||
|
|
||||||
const handleRunningModeClick = useCallback(async () => {
|
const handleRunningModeClick = useCallback(async () => {
|
||||||
if (isSidecarMode || (isAdminMode && isSidecarMode)) {
|
if (isSidecarMode) {
|
||||||
await installServiceAndRestartCore()
|
await installServiceAndRestartCore()
|
||||||
await mutateSystemState()
|
await mutateSystemState()
|
||||||
}
|
}
|
||||||
}, [
|
}, [isSidecarMode, installServiceAndRestartCore, mutateSystemState])
|
||||||
isSidecarMode,
|
|
||||||
isAdminMode,
|
|
||||||
installServiceAndRestartCore,
|
|
||||||
mutateSystemState,
|
|
||||||
])
|
|
||||||
|
|
||||||
const onCheckUpdate = useLockFn(async () => {
|
const onCheckUpdate = useLockFn(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -122,18 +117,16 @@ export const SystemInfoCard = () => {
|
|||||||
|
|
||||||
const runningModeStyle = useMemo(
|
const runningModeStyle = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
cursor:
|
cursor: isSidecarMode ? 'pointer' : 'default',
|
||||||
isSidecarMode || (isAdminMode && isSidecarMode) ? 'pointer' : 'default',
|
textDecoration: isSidecarMode ? 'underline' : 'none',
|
||||||
textDecoration:
|
|
||||||
isSidecarMode || (isAdminMode && isSidecarMode) ? 'underline' : 'none',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 0.5,
|
gap: 0.5,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
opacity: isSidecarMode || (isAdminMode && isSidecarMode) ? 0.7 : 1,
|
opacity: isSidecarMode ? 0.7 : 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[isSidecarMode, isAdminMode],
|
[isSidecarMode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const getModeIcon = () => {
|
const getModeIcon = () => {
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const TestCard = () => {
|
|||||||
x.uid === uid ? { ...x, ...patch } : x,
|
x.uid === uid ? { ...x, ...patch } : x,
|
||||||
)
|
)
|
||||||
|
|
||||||
mutateVerge({ ...verge, test_list: newList }, false)
|
mutateVerge({ ...verge, test_list: newList })
|
||||||
},
|
},
|
||||||
[testList, verge, mutateVerge],
|
[testList, verge, mutateVerge],
|
||||||
)
|
)
|
||||||
@@ -96,7 +96,7 @@ export const TestCard = () => {
|
|||||||
(uid: string) => {
|
(uid: string) => {
|
||||||
const newList = testList.filter((x) => x.uid !== uid)
|
const newList = testList.filter((x) => x.uid !== uid)
|
||||||
patchVerge({ test_list: newList })
|
patchVerge({ test_list: newList })
|
||||||
mutateVerge({ ...verge, test_list: newList }, false)
|
mutateVerge({ ...verge, test_list: newList })
|
||||||
},
|
},
|
||||||
[testList, verge, patchVerge, mutateVerge],
|
[testList, verge, patchVerge, mutateVerge],
|
||||||
)
|
)
|
||||||
@@ -129,7 +129,7 @@ export const TestCard = () => {
|
|||||||
newList.splice(newIndex, 0, removed)
|
newList.splice(newIndex, 0, removed)
|
||||||
|
|
||||||
// 优化:先本地更新,再异步 patch,避免UI卡死
|
// 优化:先本地更新,再异步 patch,避免UI卡死
|
||||||
mutateVerge({ ...verge, test_list: newList }, false)
|
mutateVerge({ ...verge, test_list: newList })
|
||||||
const patchFn = () => {
|
const patchFn = () => {
|
||||||
try {
|
try {
|
||||||
patchVerge({ test_list: newList })
|
patchVerge({ test_list: newList })
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ export const LayoutSidebar = (props: LayoutSidebarProps) => {
|
|||||||
|
|
||||||
const handleMenuOrderOptimisticUpdate = useCallback(
|
const handleMenuOrderOptimisticUpdate = useCallback(
|
||||||
(order: string[]) => {
|
(order: string[]) => {
|
||||||
mutateVerge(
|
mutateVerge((prev) => (prev ? { ...prev, menu_order: order } : prev))
|
||||||
(prev) => (prev ? { ...prev, menu_order: order } : prev),
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[mutateVerge],
|
[mutateVerge],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
ArrowDownwardRounded,
|
ArrowDownwardRounded,
|
||||||
ArrowUpwardRounded,
|
ArrowUpwardRounded,
|
||||||
|
ErrorOutlineRounded,
|
||||||
MemoryRounded,
|
MemoryRounded,
|
||||||
} from '@mui/icons-material'
|
} from '@mui/icons-material'
|
||||||
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
|
import type { BoxProps, SvgIconProps, TypographyProps } from '@mui/material'
|
||||||
import { Box, Typography } from '@mui/material'
|
import { Box, Typography } from '@mui/material'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { LightweightTrafficErrorBoundary } from '@/components/shared/traffic-error-boundary'
|
|
||||||
import { useMemoryData } from '@/hooks/use-memory-data'
|
import { useMemoryData } from '@/hooks/use-memory-data'
|
||||||
import { useTrafficData } from '@/hooks/use-traffic-data'
|
import { useTrafficData } from '@/hooks/use-traffic-data'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
@@ -17,6 +18,30 @@ import parseTraffic from '@/utils/parse-traffic'
|
|||||||
|
|
||||||
import { TrafficGraph, type TrafficRef } from './traffic-graph'
|
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 = () => {
|
export const LayoutTraffic = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { verge } = useVerge()
|
const { verge } = useVerge()
|
||||||
@@ -75,7 +100,12 @@ export const LayoutTraffic = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LightweightTrafficErrorBoundary>
|
<ErrorBoundary
|
||||||
|
FallbackComponent={TrafficUnavailableFallback}
|
||||||
|
onError={(error, errorInfo) =>
|
||||||
|
console.error('[LayoutTraffic]', error, errorInfo)
|
||||||
|
}
|
||||||
|
>
|
||||||
<Box sx={{ position: 'relative' }}>
|
<Box sx={{ position: 'relative' }}>
|
||||||
{trafficGraph && pageVisible && (
|
{trafficGraph && pageVisible && (
|
||||||
<div
|
<div
|
||||||
@@ -130,7 +160,6 @@ export const LayoutTraffic = () => {
|
|||||||
cursor: 'auto',
|
cursor: 'auto',
|
||||||
}}
|
}}
|
||||||
color={'disabled'}
|
color={'disabled'}
|
||||||
onClick={async () => {}}
|
|
||||||
>
|
>
|
||||||
<MemoryRounded {...iconStyle} />
|
<MemoryRounded {...iconStyle} />
|
||||||
<Typography {...valStyle}>{inuse}</Typography>
|
<Typography {...valStyle}>{inuse}</Typography>
|
||||||
@@ -139,6 +168,6 @@ export const LayoutTraffic = () => {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</LightweightTrafficErrorBoundary>
|
</ErrorBoundary>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
|
import { Close, CropSquare, FilterNone, Minimize } from '@mui/icons-material'
|
||||||
import { Box, IconButton } from '@mui/material'
|
import { Box, IconButton } from '@mui/material'
|
||||||
import {
|
import { type PointerEvent, useCallback } from 'react'
|
||||||
forwardRef,
|
|
||||||
type PointerEvent,
|
|
||||||
useCallback,
|
|
||||||
useImperativeHandle,
|
|
||||||
} from 'react'
|
|
||||||
|
|
||||||
import { useWindowControls } from '@/hooks/use-window'
|
import { useWindowControls } from '@/hooks/use-window'
|
||||||
import getSystem from '@/utils/get-system'
|
import getSystem from '@/utils/get-system'
|
||||||
@@ -63,36 +58,9 @@ export const WindowResizeHandles = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const WindowControls = forwardRef(function WindowControls(props, ref) {
|
export const WindowControls = () => {
|
||||||
const OS = getSystem()
|
const OS = getSystem()
|
||||||
const {
|
const { maximized, minimize, close, toggleMaximize } = useWindowControls()
|
||||||
currentWindow,
|
|
||||||
maximized,
|
|
||||||
minimize,
|
|
||||||
close,
|
|
||||||
toggleFullscreen,
|
|
||||||
toggleMaximize,
|
|
||||||
} = useWindowControls()
|
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
currentWindow,
|
|
||||||
maximized,
|
|
||||||
minimize,
|
|
||||||
close,
|
|
||||||
toggleFullscreen,
|
|
||||||
toggleMaximize,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
currentWindow,
|
|
||||||
maximized,
|
|
||||||
minimize,
|
|
||||||
close,
|
|
||||||
toggleFullscreen,
|
|
||||||
toggleMaximize,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
// 通过前端对 tauri 窗口进行翻转全屏时会短暂地与系统图标重叠渲染。
|
||||||
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
// 这可能是上游缺陷,保险起见跨平台以窗口的最大化翻转为准。
|
||||||
@@ -186,4 +154,4 @@ export const WindowControls = forwardRef(function WindowControls(props, ref) {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
})
|
}
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ function NormalProxyGroups(props: { mode: string }) {
|
|||||||
}
|
}
|
||||||
}, [handleScroll])
|
}, [handleScroll])
|
||||||
|
|
||||||
const { handleProxyGroupChange } = useProxySelection({
|
const { select } = useProxySelection({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
onProxies()
|
onProxies()
|
||||||
},
|
},
|
||||||
@@ -477,9 +477,9 @@ function NormalProxyGroups(props: { mode: string }) {
|
|||||||
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
if (!['Selector', 'URLTest', 'Fallback'].includes(group.type)) return
|
||||||
if (!isInteractableMember(member)) return
|
if (!isInteractableMember(member)) return
|
||||||
|
|
||||||
handleProxyGroupChange(group, { name: member.ref.name })
|
select(group.name, member.ref.name, group.now!, group.fixed!)
|
||||||
},
|
},
|
||||||
[handleProxyGroupChange],
|
[select],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLocation = useStableCallback((group: ProxyGroupView) => {
|
const handleLocation = useStableCallback((group: ProxyGroupView) => {
|
||||||
|
|||||||
@@ -30,10 +30,8 @@ export const useProxyGroupHeaderLayout = () => {
|
|||||||
if (canceled || !operation.source || !operation.target) return
|
if (canceled || !operation.source || !operation.target) return
|
||||||
|
|
||||||
const position = toolsOnLeft ? 'right' : 'left'
|
const position = toolsOnLeft ? 'right' : 'left'
|
||||||
mutateVerge(
|
mutateVerge((prev) =>
|
||||||
(prev) =>
|
prev ? { ...prev, proxy_group_tools_position: position } : prev,
|
||||||
prev ? { ...prev, proxy_group_tools_position: position } : prev,
|
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
await patchVerge({ proxy_group_tools_position: position })
|
await patchVerge({ proxy_group_tools_position: position })
|
||||||
|
|||||||
@@ -113,17 +113,15 @@ export const BackupConfigViewer = memo(
|
|||||||
errorNotice: false,
|
errorNotice: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await mutateVerge(
|
await mutateVerge((current) =>
|
||||||
(current) =>
|
current
|
||||||
current
|
? {
|
||||||
? {
|
...current,
|
||||||
...current,
|
webdav_url: trimmedUrl,
|
||||||
webdav_url: trimmedUrl,
|
webdav_username: trimmedUsername,
|
||||||
webdav_username: trimmedUsername,
|
webdav_password: data.password,
|
||||||
webdav_password: data.password,
|
}
|
||||||
}
|
: current,
|
||||||
: current,
|
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
setWebdavStatus(signature, 'unknown')
|
setWebdavStatus(signature, 'unknown')
|
||||||
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
|
showNotice.success('settings.modals.backup.messages.webdavConfigSaved')
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const LayoutViewer = forwardRef<DialogRef>((_, ref) => {
|
|||||||
showNotice.error(err)
|
showNotice.error(err)
|
||||||
}
|
}
|
||||||
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
||||||
mutateVerge({ ...verge, ...patch }, false)
|
mutateVerge({ ...verge, ...patch })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,44 +15,33 @@ import { BaseDialog, DialogRef, Switch, TooltipIcon } from '@/components/base'
|
|||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
import { showNotice } from '@/services/notice-service'
|
import { showNotice } from '@/services/notice-service'
|
||||||
|
|
||||||
|
const buildInitialValues = (verge?: IVergeConfig | null) => ({
|
||||||
|
appLogLevel: verge?.app_log_level ?? 'warn',
|
||||||
|
appLogMaxSize: verge?.app_log_max_size ?? 128,
|
||||||
|
appLogMaxCount: verge?.app_log_max_count ?? 8,
|
||||||
|
autoCloseConnection: verge?.auto_close_connection ?? true,
|
||||||
|
autoCheckUpdate: verge?.auto_check_update ?? true,
|
||||||
|
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
|
||||||
|
proxyLayoutColumn: verge?.proxy_layout_column || 6,
|
||||||
|
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
|
||||||
|
autoDelayDetectionIntervalMinutes:
|
||||||
|
verge?.auto_delay_detection_interval_minutes ?? 5,
|
||||||
|
defaultLatencyTest: verge?.default_latency_test || '',
|
||||||
|
autoLogClean: verge?.auto_log_clean || 0,
|
||||||
|
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
|
||||||
|
})
|
||||||
|
|
||||||
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
|
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { verge, patchVerge } = useVerge()
|
const { verge, patchVerge } = useVerge()
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [values, setValues] = useState({
|
const [values, setValues] = useState(() => buildInitialValues(null))
|
||||||
appLogLevel: 'warn',
|
|
||||||
appLogMaxSize: 8,
|
|
||||||
appLogMaxCount: 12,
|
|
||||||
autoCloseConnection: true,
|
|
||||||
autoCheckUpdate: true,
|
|
||||||
enableBuiltinEnhanced: true,
|
|
||||||
proxyLayoutColumn: 6,
|
|
||||||
enableAutoDelayDetection: false,
|
|
||||||
autoDelayDetectionIntervalMinutes: 5,
|
|
||||||
defaultLatencyTest: '',
|
|
||||||
autoLogClean: 2,
|
|
||||||
defaultLatencyTimeout: 10000,
|
|
||||||
})
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
setValues({
|
setValues(buildInitialValues(verge))
|
||||||
appLogLevel: verge?.app_log_level ?? 'warn',
|
|
||||||
appLogMaxSize: verge?.app_log_max_size ?? 128,
|
|
||||||
appLogMaxCount: verge?.app_log_max_count ?? 8,
|
|
||||||
autoCloseConnection: verge?.auto_close_connection ?? true,
|
|
||||||
autoCheckUpdate: verge?.auto_check_update ?? true,
|
|
||||||
enableBuiltinEnhanced: verge?.enable_builtin_enhanced ?? true,
|
|
||||||
proxyLayoutColumn: verge?.proxy_layout_column || 6,
|
|
||||||
enableAutoDelayDetection: verge?.enable_auto_delay_detection ?? false,
|
|
||||||
autoDelayDetectionIntervalMinutes:
|
|
||||||
verge?.auto_delay_detection_interval_minutes ?? 5,
|
|
||||||
defaultLatencyTest: verge?.default_latency_test || '',
|
|
||||||
autoLogClean: verge?.auto_log_clean || 0,
|
|
||||||
defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
close: () => setOpen(false),
|
close: () => setOpen(false),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ const splitBypass = (value?: string) =>
|
|||||||
.map((item) => item.trim())
|
.map((item) => item.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const buildInitialValues = (verge?: IVergeConfig | null) => ({
|
||||||
|
guard: verge?.enable_proxy_guard,
|
||||||
|
enable_bypass_check: verge?.enable_bypass_check ?? true,
|
||||||
|
bypass: verge?.system_proxy_bypass,
|
||||||
|
duration: verge?.proxy_guard_duration ?? 10,
|
||||||
|
use_default: verge?.use_default_bypass ?? true,
|
||||||
|
pac: verge?.proxy_auto_config,
|
||||||
|
pac_content: verge?.pac_file_content ?? DEFAULT_PAC,
|
||||||
|
proxy_host: verge?.proxy_host ?? '127.0.0.1',
|
||||||
|
})
|
||||||
|
|
||||||
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const systemName = getSystem()
|
const systemName = getSystem()
|
||||||
@@ -116,16 +127,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
proxy_host,
|
proxy_host,
|
||||||
} = verge ?? {}
|
} = verge ?? {}
|
||||||
|
|
||||||
const [value, setValue] = useState({
|
const [value, setValue] = useState(() => buildInitialValues(null))
|
||||||
guard: enable_proxy_guard,
|
|
||||||
enable_bypass_check: enable_bypass_check ?? true,
|
|
||||||
bypass: system_proxy_bypass,
|
|
||||||
duration: proxy_guard_duration ?? 10,
|
|
||||||
use_default: use_default_bypass ?? true,
|
|
||||||
pac: proxy_auto_config,
|
|
||||||
pac_content: pac_file_content ?? DEFAULT_PAC,
|
|
||||||
proxy_host: proxy_host ?? '127.0.0.1',
|
|
||||||
})
|
|
||||||
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
|
const [embeddedServerPort, setEmbeddedServerPort] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
)
|
)
|
||||||
@@ -192,16 +194,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
setValue({
|
setValue(buildInitialValues(verge))
|
||||||
guard: enable_proxy_guard,
|
|
||||||
enable_bypass_check: enable_bypass_check ?? true,
|
|
||||||
bypass: system_proxy_bypass,
|
|
||||||
duration: proxy_guard_duration ?? 10,
|
|
||||||
use_default: use_default_bypass ?? true,
|
|
||||||
pac: proxy_auto_config,
|
|
||||||
pac_content: pac_file_content ?? DEFAULT_PAC,
|
|
||||||
proxy_host: proxy_host ?? '127.0.0.1',
|
|
||||||
})
|
|
||||||
fetchNetworkInterfaces()
|
fetchNetworkInterfaces()
|
||||||
},
|
},
|
||||||
close: () => setOpen(false),
|
close: () => setOpen(false),
|
||||||
@@ -339,7 +332,7 @@ export const SysproxyViewer = forwardRef<DialogRef>((props, ref) => {
|
|||||||
Promise.resolve().then(async () => {
|
Promise.resolve().then(async () => {
|
||||||
try {
|
try {
|
||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
mutateVerge({ ...verge, ...patch }, false)
|
mutateVerge({ ...verge, ...patch })
|
||||||
}
|
}
|
||||||
if (Object.keys(patch).length > 0) {
|
if (Object.keys(patch).length > 0) {
|
||||||
await patchVerge(patch)
|
await patchVerge(patch)
|
||||||
|
|||||||
@@ -76,34 +76,28 @@ const proxyOptionToken = ({ memberIndex, member }: TunnelProxyOption) =>
|
|||||||
member.kind === 'node' ? member.node.recordId : member.ref.name
|
member.kind === 'node' ? member.node.recordId : member.ref.name
|
||||||
}`
|
}`
|
||||||
|
|
||||||
|
const buildInitialValues = (): TunnelFormValues => ({
|
||||||
|
localAddr: '',
|
||||||
|
localPort: '',
|
||||||
|
targetAddr: '',
|
||||||
|
targetPort: '',
|
||||||
|
network: 'tcp+udp',
|
||||||
|
group: '',
|
||||||
|
proxy: null,
|
||||||
|
})
|
||||||
|
|
||||||
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
|
export const TunnelsViewer = forwardRef<TunnelsViewerRef>((_, ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { clash, mutateClash, patchClash } = useClash()
|
const { clash, mutateClash, patchClash } = useClash()
|
||||||
|
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const [values, setValues] = useState<TunnelFormValues>({
|
const [values, setValues] = useState<TunnelFormValues>(buildInitialValues)
|
||||||
localAddr: '',
|
|
||||||
localPort: '',
|
|
||||||
targetAddr: '',
|
|
||||||
targetPort: '',
|
|
||||||
network: 'tcp+udp',
|
|
||||||
group: '',
|
|
||||||
proxy: null,
|
|
||||||
})
|
|
||||||
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
|
const [draftTunnels, setDraftTunnels] = useState<TunnelEntry[]>([])
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
open: () => {
|
open: () => {
|
||||||
setValues(() => ({
|
setValues(buildInitialValues())
|
||||||
localAddr: '',
|
|
||||||
localPort: '',
|
|
||||||
targetAddr: '',
|
|
||||||
targetPort: '',
|
|
||||||
network: 'tcp+udp',
|
|
||||||
group: '',
|
|
||||||
proxy: null,
|
|
||||||
}))
|
|
||||||
setDraftTunnels(() => clash?.tunnels ?? [])
|
setDraftTunnels(() => clash?.tunnels ?? [])
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
// 如果没有隧道,则自动展开
|
// 如果没有隧道,则自动展开
|
||||||
|
|||||||
@@ -50,21 +50,21 @@ export function WebUIViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
|||||||
|
|
||||||
const handleAdd = useLockFn(async (value: string) => {
|
const handleAdd = useLockFn(async (value: string) => {
|
||||||
const newList = [...webUIList, value]
|
const newList = [...webUIList, value]
|
||||||
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
|
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
|
||||||
await patchVerge({ web_ui_list: newList })
|
await patchVerge({ web_ui_list: newList })
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleChange = useLockFn(async (index: number, value?: string) => {
|
const handleChange = useLockFn(async (index: number, value?: string) => {
|
||||||
const newList = [...webUIList]
|
const newList = [...webUIList]
|
||||||
newList[index] = value ?? ''
|
newList[index] = value ?? ''
|
||||||
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
|
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
|
||||||
await patchVerge({ web_ui_list: newList })
|
await patchVerge({ web_ui_list: newList })
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleDelete = useLockFn(async (index: number) => {
|
const handleDelete = useLockFn(async (index: number) => {
|
||||||
const newList = [...webUIList]
|
const newList = [...webUIList]
|
||||||
newList.splice(index, 1)
|
newList.splice(index, 1)
|
||||||
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old), false)
|
mutateVerge((old) => (old ? { ...old, web_ui_list: newList } : old))
|
||||||
await patchVerge({ web_ui_list: newList })
|
await patchVerge({ web_ui_list: newList })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import React, { useRef } from 'react'
|
import React from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { DialogRef, Switch, TooltipIcon } from '@/components/base'
|
import { Switch, TooltipIcon } from '@/components/base'
|
||||||
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
|
import ProxyControlSwitches from '@/components/shared/proxy-control-switches'
|
||||||
import { useVerge } from '@/hooks/use-verge'
|
import { useVerge } from '@/hooks/use-verge'
|
||||||
|
|
||||||
import { GuardState } from './mods/guard-state'
|
import { GuardState } from './mods/guard-state'
|
||||||
import { SettingList, SettingItem } from './mods/setting-comp'
|
import { SettingList, SettingItem } from './mods/setting-comp'
|
||||||
import { SysproxyViewer } from './mods/sysproxy-viewer'
|
|
||||||
import { TunViewer } from './mods/tun-viewer'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onError?: (err: Error) => void
|
onError?: (err: Error) => void
|
||||||
@@ -21,22 +19,16 @@ const SettingSystem = ({ onError }: Props) => {
|
|||||||
|
|
||||||
const { enable_auto_launch, enable_silent_start } = verge ?? {}
|
const { enable_auto_launch, enable_silent_start } = verge ?? {}
|
||||||
|
|
||||||
const sysproxyRef = useRef<DialogRef>(null)
|
|
||||||
const tunRef = useRef<DialogRef>(null)
|
|
||||||
|
|
||||||
const onSwitchFormat = (
|
const onSwitchFormat = (
|
||||||
_e: React.ChangeEvent<HTMLInputElement>,
|
_e: React.ChangeEvent<HTMLInputElement>,
|
||||||
value: boolean,
|
value: boolean,
|
||||||
) => value
|
) => value
|
||||||
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
const onChangeData = (patch: Partial<IVergeConfig>) => {
|
||||||
mutateVerge({ ...verge, ...patch }, false)
|
mutateVerge({ ...verge, ...patch })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingList title={t('settings.sections.system.title')}>
|
<SettingList title={t('settings.sections.system.title')}>
|
||||||
<SysproxyViewer ref={sysproxyRef} />
|
|
||||||
<TunViewer ref={tunRef} />
|
|
||||||
|
|
||||||
<ProxyControlSwitches
|
<ProxyControlSwitches
|
||||||
label={t('settings.sections.system.toggles.tunMode')}
|
label={t('settings.sections.system.toggles.tunMode')}
|
||||||
onError={onError}
|
onError={onError}
|
||||||
@@ -58,8 +50,6 @@ const SettingSystem = ({ onError }: Props) => {
|
|||||||
}}
|
}}
|
||||||
onGuard={async (e) => {
|
onGuard={async (e) => {
|
||||||
try {
|
try {
|
||||||
// 先触发UI更新立即看到反馈
|
|
||||||
onChangeData({ enable_auto_launch: e })
|
|
||||||
await patchVerge({ enable_auto_launch: e })
|
await patchVerge({ enable_auto_launch: e })
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ const SettingVergeBasic = ({ onError }: Props) => {
|
|||||||
const backupRef = useRef<DialogRef>(null)
|
const backupRef = useRef<DialogRef>(null)
|
||||||
|
|
||||||
const onChangeData = (patch: any) => {
|
const onChangeData = (patch: any) => {
|
||||||
mutateVerge({ ...verge, ...patch }, false)
|
mutateVerge({ ...verge, ...patch })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onCopyClashEnv = useCallback(async () => {
|
const onCopyClashEnv = useCallback(async () => {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const ProxyControlSwitches = ({
|
|||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
mutateVerge({ ...verge, enable_tun_mode: value }, false)
|
mutateVerge({ ...verge, enable_tun_mode: value })
|
||||||
await patchVerge({ enable_tun_mode: value })
|
await patchVerge({ enable_tun_mode: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,8 +229,8 @@ const ProxyControlSwitches = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SysproxyViewer ref={sysproxyRef} />
|
{isSystemProxyMode && <SysproxyViewer ref={sysproxyRef} />}
|
||||||
<TunViewer ref={tunRef} />
|
{isTunMode && <TunViewer ref={tunRef} />}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { downloadIconCache } from '@/services/cmds'
|
import { downloadIconCache } from '@/services/cmds'
|
||||||
import { useQuery } from '@/services/query-client'
|
import { useQuery } from '@/services/query-client'
|
||||||
@@ -23,9 +22,8 @@ export const useIconCache = ({
|
|||||||
const iconValue = icon?.trim() ?? ''
|
const iconValue = icon?.trim() ?? ''
|
||||||
const cacheKeyValue = cacheKey?.trim() ?? ''
|
const cacheKeyValue = cacheKey?.trim() ?? ''
|
||||||
|
|
||||||
const isEnabled = useMemo(() => {
|
const isEnabled =
|
||||||
return enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
|
enabled && iconValue.startsWith('http') && cacheKeyValue !== ''
|
||||||
}, [enabled, iconValue, cacheKeyValue])
|
|
||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ['icon-cache', iconValue, cacheKeyValue],
|
queryKey: ['icon-cache', iconValue, cacheKeyValue],
|
||||||
|
|||||||
@@ -54,8 +54,7 @@ export const useLogData = () => {
|
|||||||
const hasLoadedInitialLogsRef = useRef(false)
|
const hasLoadedInitialLogsRef = useRef(false)
|
||||||
|
|
||||||
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
|
const { response, refresh, setData } = useMihomoWsSubscription<ILogItem[]>({
|
||||||
storageKey: 'mihomo_logs_date',
|
subscriptionKey: enableLog ? 'getClashLog' : null,
|
||||||
buildSubscriptKey: (date) => (enableLog ? `getClashLog-${date}` : null),
|
|
||||||
fallbackData: [],
|
fallbackData: [],
|
||||||
connect: () => MihomoWebSocket.connect_logs(logLevel),
|
connect: () => MihomoWebSocket.connect_logs(logLevel),
|
||||||
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
|
setupHandlers: ({ next, scheduleReconnect, isMounted }) => {
|
||||||
@@ -77,14 +76,13 @@ export const useLogData = () => {
|
|||||||
}
|
}
|
||||||
const pendingLogs = buffer.splice(0, buffer.length)
|
const pendingLogs = buffer.splice(0, buffer.length)
|
||||||
flushTimeStr = null
|
flushTimeStr = null
|
||||||
next(null, (current) => appendLogs(current, pendingLogs))
|
next((current) => appendLogs(current, pendingLogs))
|
||||||
flushTimer = null
|
flushTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleMessage: (data) => {
|
handleMessage: (data) => {
|
||||||
if (data.startsWith('Websocket error')) {
|
if (data.startsWith('Websocket error')) {
|
||||||
next(data)
|
|
||||||
void scheduleReconnect()
|
void scheduleReconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,9 +106,7 @@ export const useLogData = () => {
|
|||||||
if (!flushTimer) {
|
if (!flushTimer) {
|
||||||
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
|
flushTimer = setTimeout(flush, FLUSH_DELAY_MS)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {}
|
||||||
next(error)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async onConnected() {
|
async onConnected() {
|
||||||
if (hasLoadedInitialLogsRef.current) {
|
if (hasLoadedInitialLogsRef.current) {
|
||||||
@@ -119,7 +115,7 @@ export const useLogData = () => {
|
|||||||
const logs = await getClashLogs()
|
const logs = await getClashLogs()
|
||||||
hasLoadedInitialLogsRef.current = true
|
hasLoadedInitialLogsRef.current = true
|
||||||
if (isMounted()) {
|
if (isMounted()) {
|
||||||
next(null, (current) => {
|
next((current) => {
|
||||||
if (!current || current.length === 0) {
|
if (!current || current.length === 0) {
|
||||||
return clampLogs(filterLogsByLevel(logs, allowedTypes))
|
return clampLogs(filterLogsByLevel(logs, allowedTypes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,15 +33,13 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
|
|||||||
const enabled = options?.enabled ?? true
|
const enabled = options?.enabled ?? true
|
||||||
|
|
||||||
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
|
const { response, refresh } = useMihomoWsSubscription<IMemoryUsageItem>({
|
||||||
storageKey: 'mihomo_memory_date',
|
subscriptionKey: enabled ? 'getClashMemory' : null,
|
||||||
buildSubscriptKey: (date) => (enabled ? `getClashMemory-${date}` : null),
|
|
||||||
fallbackData: FALLBACK_MEMORY_USAGE,
|
fallbackData: FALLBACK_MEMORY_USAGE,
|
||||||
connect: () => MihomoWebSocket.connect_memory(),
|
connect: () => MihomoWebSocket.connect_memory(),
|
||||||
throttleMs: 500,
|
throttleMs: 500,
|
||||||
setupHandlers: ({ next, scheduleReconnect }) => ({
|
setupHandlers: ({ next, scheduleReconnect }) => ({
|
||||||
handleMessage: (data) => {
|
handleMessage: (data) => {
|
||||||
if (data.startsWith('Websocket error')) {
|
if (data.startsWith('Websocket error')) {
|
||||||
next(data, FALLBACK_MEMORY_USAGE)
|
|
||||||
void scheduleReconnect()
|
void scheduleReconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -51,10 +49,8 @@ export const useMemoryData = (options?: { enabled?: boolean }) => {
|
|||||||
if (shouldSkipDuplicateMemory(parsed)) {
|
if (shouldSkipDuplicateMemory(parsed)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
next(null, parsed)
|
next(parsed)
|
||||||
} catch (error) {
|
} catch {}
|
||||||
next(error, FALLBACK_MEMORY_USAGE)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { useLocalStorage } from 'foxact/use-local-storage'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useCallback, useEffect, useRef } from 'react'
|
|
||||||
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
import { type Message, type MihomoWebSocket } from 'tauri-plugin-mihomo-api'
|
||||||
|
|
||||||
import {
|
import { setCacheData, useQuery } from '@/services/query-client'
|
||||||
removeCacheData,
|
|
||||||
setCacheData,
|
|
||||||
useQuery,
|
|
||||||
} from '@/services/query-client'
|
|
||||||
|
|
||||||
const RECONNECT_DELAY_MS = 1000
|
const RECONNECT_DELAY_MS = 1000
|
||||||
|
|
||||||
@@ -31,7 +26,6 @@ interface SharedSubscriptionEntry {
|
|||||||
|
|
||||||
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
|
const sharedSubscriptions = new Map<string, SharedSubscriptionEntry>()
|
||||||
const subscriptionSnapshots = new Map<string, unknown>()
|
const subscriptionSnapshots = new Map<string, unknown>()
|
||||||
const initialSubscriptionDate = Date.now()
|
|
||||||
|
|
||||||
const getSubscriptionSnapshot = <T>(key: string) =>
|
const getSubscriptionSnapshot = <T>(key: string) =>
|
||||||
subscriptionSnapshots.get(key) as T | undefined
|
subscriptionSnapshots.get(key) as T | undefined
|
||||||
@@ -136,15 +130,13 @@ const createSharedSubscriptionEntry = (
|
|||||||
return entry
|
return entry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Updater<T> = (current?: T) => T
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors SWR's MutatorCallback: consumers can pass either a plain value or a
|
* Pass either a plain value or a functional updater; the updater is resolved
|
||||||
* functional updater `(current?: T) => T`. The functional form is resolved
|
* against the current subscription snapshot before the cache is written.
|
||||||
* against the current subscription snapshot before updating SWR.
|
|
||||||
*/
|
*/
|
||||||
type NextFn<T> = (
|
type NextFn<T> = (data: T | Updater<T>) => void
|
||||||
error?: any,
|
|
||||||
data?: T | ((current?: T) => T | undefined),
|
|
||||||
) => void
|
|
||||||
|
|
||||||
interface HandlerContext<T> {
|
interface HandlerContext<T> {
|
||||||
next: NextFn<T>
|
next: NextFn<T>
|
||||||
@@ -159,8 +151,7 @@ interface HandlerResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface UseMihomoWsSubscriptionOptions<T> {
|
interface UseMihomoWsSubscriptionOptions<T> {
|
||||||
storageKey: string
|
subscriptionKey: string | null
|
||||||
buildSubscriptKey: (date: number) => string | null
|
|
||||||
fallbackData: T
|
fallbackData: T
|
||||||
connect: () => Promise<MihomoWebSocket>
|
connect: () => Promise<MihomoWebSocket>
|
||||||
/**
|
/**
|
||||||
@@ -178,18 +169,12 @@ interface UseMihomoWsSubscriptionOptions<T> {
|
|||||||
export const useMihomoWsSubscription = <T>(
|
export const useMihomoWsSubscription = <T>(
|
||||||
options: UseMihomoWsSubscriptionOptions<T>,
|
options: UseMihomoWsSubscriptionOptions<T>,
|
||||||
) => {
|
) => {
|
||||||
const {
|
const { subscriptionKey, fallbackData, connect, throttleMs, setupHandlers } =
|
||||||
storageKey,
|
options
|
||||||
buildSubscriptKey,
|
|
||||||
fallbackData,
|
|
||||||
connect,
|
|
||||||
throttleMs,
|
|
||||||
setupHandlers,
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const [date, setDate] = useLocalStorage(storageKey, initialSubscriptionDate)
|
const subscriptionCacheKey = subscriptionKey
|
||||||
const subscriptKey = buildSubscriptKey(date)
|
? `$sub$${subscriptionKey}`
|
||||||
const subscriptionCacheKey = subscriptKey ? `$sub$${subscriptKey}` : null
|
: null
|
||||||
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
|
const lastSubscriptionCacheKeyRef = useRef<string | null>(null)
|
||||||
if (subscriptionCacheKey) {
|
if (subscriptionCacheKey) {
|
||||||
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
|
lastSubscriptionCacheKeyRef.current = subscriptionCacheKey
|
||||||
@@ -197,20 +182,9 @@ export const useMihomoWsSubscription = <T>(
|
|||||||
const responseCacheKey =
|
const responseCacheKey =
|
||||||
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
|
subscriptionCacheKey ?? lastSubscriptionCacheKeyRef.current
|
||||||
|
|
||||||
const resolveNextData = useCallback(
|
// Bumping the epoch re-runs the subscription effect, rebuilding the shared
|
||||||
(
|
// entry (and its captured `connect`/handler closures) under the same key.
|
||||||
data: T | ((current?: T) => T | undefined) | undefined,
|
const [refreshEpoch, setRefreshEpoch] = useState(0)
|
||||||
cacheKey: string,
|
|
||||||
): T => {
|
|
||||||
if (typeof data === 'function') {
|
|
||||||
const updater = data as (current?: T) => T | undefined
|
|
||||||
const current = getSubscriptionSnapshot<T>(cacheKey)
|
|
||||||
return updater(current) ?? fallbackData
|
|
||||||
}
|
|
||||||
return data ?? fallbackData
|
|
||||||
},
|
|
||||||
[fallbackData],
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = useQuery<T>({
|
const response = useQuery<T>({
|
||||||
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
|
queryKey: responseCacheKey ? [responseCacheKey] : ['$sub$__disabled__'],
|
||||||
@@ -238,44 +212,35 @@ export const useMihomoWsSubscription = <T>(
|
|||||||
let throttleCleanup: (() => void) | undefined
|
let throttleCleanup: (() => void) | undefined
|
||||||
let wrappedNext: NextFn<T>
|
let wrappedNext: NextFn<T>
|
||||||
|
|
||||||
const baseNext: NextFn<T> = (error, data) => {
|
const baseNext: NextFn<T> = (data) => {
|
||||||
if (error !== undefined && error !== null) {
|
const resolved =
|
||||||
return
|
typeof data === 'function'
|
||||||
}
|
? (data as Updater<T>)(
|
||||||
if (data === undefined) return
|
getSubscriptionSnapshot<T>(subscriptionCacheKey),
|
||||||
const resolved = resolveNextData(data, subscriptionCacheKey)
|
)
|
||||||
|
: (data as T)
|
||||||
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
|
writeSubscriptionSnapshot(subscriptionCacheKey, resolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (throttleMs && throttleMs > 0) {
|
if (throttleMs && throttleMs > 0) {
|
||||||
let pendingData: T | ((current?: T) => T | undefined) | undefined
|
let pendingData: T | Updater<T> | undefined
|
||||||
let hasPending = false
|
|
||||||
let timerId: ReturnType<typeof setTimeout> | null = null
|
let timerId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
const flush = () => {
|
const flush = () => {
|
||||||
timerId = null
|
timerId = null
|
||||||
if (hasPending) {
|
if (pendingData !== undefined) {
|
||||||
const data = pendingData
|
const data = pendingData
|
||||||
pendingData = undefined
|
pendingData = undefined
|
||||||
hasPending = false
|
baseNext(data)
|
||||||
baseNext(undefined, data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
wrappedNext = (
|
wrappedNext = (data) => {
|
||||||
error?: any,
|
|
||||||
data?: T | ((current?: T) => T | undefined),
|
|
||||||
) => {
|
|
||||||
if (error !== undefined && error !== null) {
|
|
||||||
baseNext(error, data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!timerId) {
|
if (!timerId) {
|
||||||
baseNext(undefined, data)
|
baseNext(data)
|
||||||
timerId = setTimeout(flush, throttleMs)
|
timerId = setTimeout(flush, throttleMs)
|
||||||
} else {
|
} else {
|
||||||
pendingData = data
|
pendingData = data
|
||||||
hasPending = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,15 +306,14 @@ export const useMihomoWsSubscription = <T>(
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line react-compiler/react-compiler
|
// eslint-disable-next-line react-compiler/react-compiler
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps, @eslint-react/exhaustive-deps
|
||||||
}, [subscriptionCacheKey])
|
}, [subscriptionCacheKey, refreshEpoch])
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
if (subscriptionCacheKey) {
|
if (subscriptionCacheKey) {
|
||||||
subscriptionSnapshots.delete(subscriptionCacheKey)
|
writeSubscriptionSnapshot(subscriptionCacheKey, fallbackData)
|
||||||
void removeCacheData([subscriptionCacheKey])
|
|
||||||
}
|
}
|
||||||
setDate(Date.now())
|
setRefreshEpoch((epoch) => epoch + 1)
|
||||||
}, [subscriptionCacheKey, setDate])
|
}, [subscriptionCacheKey, fallbackData])
|
||||||
|
|
||||||
const setData = useCallback(
|
const setData = useCallback(
|
||||||
(data: T) => {
|
(data: T) => {
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ it('applies and records independent group selections while another group is pend
|
|||||||
const secondSuccess = vi.fn()
|
const secondSuccess = vi.fn()
|
||||||
const first = useProxySelection({ onSuccess: firstSuccess })
|
const first = useProxySelection({ onSuccess: firstSuccess })
|
||||||
const second = useProxySelection({ onSuccess: secondSuccess })
|
const second = useProxySelection({ onSuccess: secondSuccess })
|
||||||
first.changeProxy('Group A', 'Node A')
|
first.select('Group A', 'Node A')
|
||||||
second.changeProxy('Group B', 'Node B')
|
second.select('Group B', 'Node B')
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
|
expect(record).toHaveBeenCalledWith('Group B', 'Node B'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const cleanupConnections = async (previousProxy: string) => {
|
|||||||
interface ProxySelectionOptions {
|
interface ProxySelectionOptions {
|
||||||
onSuccess?: () => void
|
onSuccess?: () => void
|
||||||
onError?: (error: any) => void
|
onError?: (error: any) => void
|
||||||
enableConnectionCleanup?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProxyChangeRequest {
|
interface ProxyChangeRequest {
|
||||||
@@ -53,7 +52,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
|
const pendingRequestRef = useRef<ProxyChangeRequest | null>(null)
|
||||||
const isProcessingRef = useRef(false)
|
const isProcessingRef = useRef(false)
|
||||||
|
|
||||||
const { onSuccess, onError, enableConnectionCleanup = true } = options
|
const { onSuccess, onError } = options
|
||||||
|
|
||||||
const autoCloseConnection = verge?.auto_close_connection ?? false
|
const autoCloseConnection = verge?.auto_close_connection ?? false
|
||||||
|
|
||||||
@@ -98,7 +97,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enableConnectionCleanup && autoCloseConnection && previousProxy) {
|
if (autoCloseConnection && previousProxy) {
|
||||||
void cleanupConnections(previousProxy)
|
void cleanupConnections(previousProxy)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -112,7 +111,6 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
[
|
[
|
||||||
autoCloseConnection,
|
autoCloseConnection,
|
||||||
forgetSelection,
|
forgetSelection,
|
||||||
enableConnectionCleanup,
|
|
||||||
onError,
|
onError,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
recordSelection,
|
recordSelection,
|
||||||
@@ -138,7 +136,7 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
}
|
}
|
||||||
}, [executeChange])
|
}, [executeChange])
|
||||||
|
|
||||||
const changeProxy = useCallback(
|
const select = useCallback(
|
||||||
(
|
(
|
||||||
groupName: string,
|
groupName: string,
|
||||||
proxyName: string,
|
proxyName: string,
|
||||||
@@ -156,27 +154,5 @@ export const useProxySelection = (options: ProxySelectionOptions = {}) => {
|
|||||||
[flushChangeQueue],
|
[flushChangeQueue],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSelectChange = useCallback(
|
return { select }
|
||||||
(groupName: string, previousProxy?: string, fixed?: string) =>
|
|
||||||
(event: { target: { value: string } }) => {
|
|
||||||
changeProxy(groupName, event.target.value, previousProxy, fixed)
|
|
||||||
},
|
|
||||||
[changeProxy],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleProxyGroupChange = useCallback(
|
|
||||||
(
|
|
||||||
group: { name: string; now?: string | null; fixed?: string | null },
|
|
||||||
proxy: { name: string },
|
|
||||||
) => {
|
|
||||||
changeProxy(group.name, proxy.name, group.now!, group.fixed!)
|
|
||||||
},
|
|
||||||
[changeProxy],
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
changeProxy,
|
|
||||||
handleSelectChange,
|
|
||||||
handleProxyGroupChange,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,8 @@ export const useSystemProxyState = () => {
|
|||||||
const toggleSystemProxy = async (enabled: boolean) => {
|
const toggleSystemProxy = async (enabled: boolean) => {
|
||||||
// Roll failed optimistic writes back to the latest confirmed state.
|
// Roll failed optimistic writes back to the latest confirmed state.
|
||||||
let confirmed = verge?.enable_system_proxy ?? false
|
let confirmed = verge?.enable_system_proxy ?? false
|
||||||
mutateVerge(
|
mutateVerge((prev) =>
|
||||||
(prev) => (prev ? { ...prev, enable_system_proxy: enabled } : prev),
|
prev ? { ...prev, enable_system_proxy: enabled } : prev,
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
pendingRef.current = enabled
|
pendingRef.current = enabled
|
||||||
|
|
||||||
@@ -78,9 +77,8 @@ export const useSystemProxyState = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
mutateVerge(
|
mutateVerge((prev) =>
|
||||||
(prev) => (prev ? { ...prev, enable_system_proxy: confirmed } : prev),
|
prev ? { ...prev, enable_system_proxy: confirmed } : prev,
|
||||||
false,
|
|
||||||
)
|
)
|
||||||
// Queued requests were based on a state that never landed.
|
// Queued requests were based on a state that never landed.
|
||||||
pendingRef.current = null
|
pendingRef.current = null
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export function useAppUptime() {
|
|||||||
const { data: uptime = 0 } = useQuery({
|
const { data: uptime = 0 } = useQuery({
|
||||||
queryKey: appUptimeQueryKey,
|
queryKey: appUptimeQueryKey,
|
||||||
queryFn: getAppUptime,
|
queryFn: getAppUptime,
|
||||||
staleTime: 5000,
|
// The wrapper maps staleTime to SWR's dedupingInterval, which drops
|
||||||
|
// refetchInterval ticks while it exceeds the interval.
|
||||||
refetchInterval: 3000,
|
refetchInterval: 3000,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
|
|||||||
@@ -32,15 +32,13 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
|
|||||||
graphData: { appendData },
|
graphData: { appendData },
|
||||||
} = useTrafficMonitorEnhanced({ subscribe: false, enabled })
|
} = useTrafficMonitorEnhanced({ subscribe: false, enabled })
|
||||||
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
|
const { response, refresh } = useMihomoWsSubscription<ITrafficItem>({
|
||||||
storageKey: 'mihomo_traffic_date',
|
subscriptionKey: enabled ? 'getClashTraffic' : null,
|
||||||
buildSubscriptKey: (date) => (enabled ? `getClashTraffic-${date}` : null),
|
|
||||||
fallbackData: FALLBACK_TRAFFIC,
|
fallbackData: FALLBACK_TRAFFIC,
|
||||||
connect: () => MihomoWebSocket.connect_traffic(),
|
connect: () => MihomoWebSocket.connect_traffic(),
|
||||||
throttleMs: 200,
|
throttleMs: 200,
|
||||||
setupHandlers: ({ next, scheduleReconnect }) => ({
|
setupHandlers: ({ next, scheduleReconnect }) => ({
|
||||||
handleMessage: (data) => {
|
handleMessage: (data) => {
|
||||||
if (data.startsWith('Websocket error')) {
|
if (data.startsWith('Websocket error')) {
|
||||||
next(data, FALLBACK_TRAFFIC)
|
|
||||||
void scheduleReconnect()
|
void scheduleReconnect()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -51,10 +49,8 @@ export const useTrafficData = (options?: { enabled?: boolean }) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
appendData(parsed)
|
appendData(parsed)
|
||||||
next(null, parsed)
|
next(parsed)
|
||||||
} catch (error) {
|
} catch {}
|
||||||
next(error, FALLBACK_TRAFFIC)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Traffic } from 'tauri-plugin-mihomo-api'
|
|||||||
|
|
||||||
import { useVisibility } from '@/hooks/use-visibility'
|
import { useVisibility } from '@/hooks/use-visibility'
|
||||||
import { debugLog } from '@/utils/debug'
|
import { debugLog } from '@/utils/debug'
|
||||||
import { TrafficDataSampler, isSameTrafficData } from '@/utils/traffic-sampler'
|
|
||||||
|
|
||||||
// 引用计数管理器
|
// 引用计数管理器
|
||||||
class ReferenceCounter {
|
class ReferenceCounter {
|
||||||
@@ -32,119 +31,11 @@ const WORKER_CONFIG = {
|
|||||||
defaultRangeMinutes: 10,
|
defaultRangeMinutes: 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
class InlineTrafficMonitor {
|
|
||||||
private config = { ...WORKER_CONFIG }
|
|
||||||
private sampler = new TrafficDataSampler(this.config)
|
|
||||||
private throttleTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
private agingTimer: ReturnType<typeof setInterval> | null = null
|
|
||||||
private currentRange = this.config.defaultRangeMinutes
|
|
||||||
private lastTimestamp: number | undefined
|
|
||||||
private lastBroadcast: ITrafficDataPoint[] = []
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private emit: (snapshot: ITrafficWorkerSnapshotMessage) => void,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
start(rangeMinutes?: number) {
|
|
||||||
this.currentRange = rangeMinutes ?? this.currentRange
|
|
||||||
this.handle({
|
|
||||||
type: 'init',
|
|
||||||
config: {
|
|
||||||
...this.config,
|
|
||||||
defaultRangeMinutes: this.currentRange,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
if (this.throttleTimer !== null) {
|
|
||||||
clearTimeout(this.throttleTimer)
|
|
||||||
this.throttleTimer = null
|
|
||||||
}
|
|
||||||
if (this.agingTimer !== null) {
|
|
||||||
clearInterval(this.agingTimer)
|
|
||||||
this.agingTimer = null
|
|
||||||
}
|
|
||||||
this.sampler.clear()
|
|
||||||
this.lastTimestamp = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
handle(message: TrafficWorkerRequestMessage) {
|
|
||||||
switch (message.type) {
|
|
||||||
case 'init': {
|
|
||||||
this.config = { ...message.config }
|
|
||||||
this.sampler = new TrafficDataSampler(this.config)
|
|
||||||
this.currentRange = message.config.defaultRangeMinutes
|
|
||||||
// Re-broadcast when points age out of the range, not on every tick
|
|
||||||
this.agingTimer = setInterval(() => {
|
|
||||||
if (this.throttleTimer !== null) return
|
|
||||||
const slice = this.sampler.getDataForTimeRange(this.currentRange)
|
|
||||||
if (!isSameTrafficData(this.lastBroadcast, slice)) {
|
|
||||||
this.emitSnapshot()
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
this.emitSnapshot()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'append': {
|
|
||||||
const timestamp = message.payload.timestamp ?? Date.now()
|
|
||||||
const dataPoint: ITrafficDataPoint = {
|
|
||||||
up: message.payload.up || 0,
|
|
||||||
down: message.payload.down || 0,
|
|
||||||
timestamp,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.lastTimestamp = timestamp
|
|
||||||
this.sampler.addDataPoint(dataPoint)
|
|
||||||
this.scheduleSnapshot()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'setRange': {
|
|
||||||
if (this.currentRange !== message.minutes) {
|
|
||||||
this.currentRange = message.minutes
|
|
||||||
this.emitSnapshot()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case 'requestSnapshot': {
|
|
||||||
this.emitSnapshot()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitSnapshot() {
|
|
||||||
const dataPoints = this.sampler.getDataForTimeRange(this.currentRange)
|
|
||||||
|
|
||||||
this.emit({
|
|
||||||
type: 'snapshot',
|
|
||||||
dataPoints,
|
|
||||||
samplerStats: this.sampler.getStats(),
|
|
||||||
lastTimestamp: this.lastTimestamp,
|
|
||||||
})
|
|
||||||
this.lastBroadcast = dataPoints
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleSnapshot() {
|
|
||||||
if (this.throttleTimer !== null) return
|
|
||||||
this.throttleTimer = setTimeout(() => {
|
|
||||||
this.throttleTimer = null
|
|
||||||
this.emitSnapshot()
|
|
||||||
}, this.config.snapshotIntervalMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TrafficWorkerClient {
|
class TrafficWorkerClient {
|
||||||
private worker: Worker | null = null
|
private worker: Worker | null = null
|
||||||
private inlineMonitor: InlineTrafficMonitor | null = null
|
|
||||||
private mode: 'worker' | 'inline' | null = null
|
|
||||||
private listeners = new Set<
|
private listeners = new Set<
|
||||||
(snapshot: ITrafficWorkerSnapshotMessage) => void
|
(snapshot: ITrafficWorkerSnapshotMessage) => void
|
||||||
>()
|
>()
|
||||||
private pendingMessages: TrafficWorkerRequestMessage[] = []
|
|
||||||
private ready = false
|
|
||||||
private currentRange = WORKER_CONFIG.defaultRangeMinutes
|
private currentRange = WORKER_CONFIG.defaultRangeMinutes
|
||||||
|
|
||||||
start(rangeMinutes?: number) {
|
start(rangeMinutes?: number) {
|
||||||
@@ -155,101 +46,52 @@ class TrafficWorkerClient {
|
|||||||
|
|
||||||
this.currentRange = rangeMinutes ?? this.currentRange
|
this.currentRange = rangeMinutes ?? this.currentRange
|
||||||
|
|
||||||
if (this.ready) return
|
if (this.worker) return
|
||||||
|
|
||||||
const initMessage: TrafficWorkerRequestMessage = {
|
const initMessage: TrafficWorkerRequestMessage = {
|
||||||
type: 'init',
|
type: 'init',
|
||||||
config: {
|
config: { ...WORKER_CONFIG, defaultRangeMinutes: this.currentRange },
|
||||||
rawDataMinutes: WORKER_CONFIG.rawDataMinutes,
|
|
||||||
compressedDataMinutes: WORKER_CONFIG.compressedDataMinutes,
|
|
||||||
compressionRatio: WORKER_CONFIG.compressionRatio,
|
|
||||||
snapshotIntervalMs: WORKER_CONFIG.snapshotIntervalMs,
|
|
||||||
defaultRangeMinutes: this.currentRange,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof Worker !== 'undefined') {
|
this.worker = new Worker(
|
||||||
try {
|
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
|
||||||
this.worker = new Worker(
|
{ type: 'module' },
|
||||||
new URL('../services/traffic-monitor-worker.ts', import.meta.url),
|
)
|
||||||
{ type: 'module' },
|
|
||||||
)
|
|
||||||
this.mode = 'worker'
|
|
||||||
|
|
||||||
this.worker.onmessage = (
|
this.worker.onmessage = (
|
||||||
event: MessageEvent<TrafficWorkerResponseMessage>,
|
event: MessageEvent<TrafficWorkerResponseMessage>,
|
||||||
) => {
|
) => {
|
||||||
const message = event.data
|
const message = event.data
|
||||||
if (message.type === 'snapshot') {
|
if (message.type === 'snapshot') {
|
||||||
if (import.meta.env.MODE === 'perf') {
|
if (import.meta.env.MODE === 'perf') {
|
||||||
document.documentElement.dataset.perfWorker = String(
|
document.documentElement.dataset.perfWorker = String(
|
||||||
message.lastTimestamp ?? 0,
|
message.lastTimestamp ?? 0,
|
||||||
)
|
)
|
||||||
document.documentElement.dataset.perfPoints = String(
|
document.documentElement.dataset.perfPoints = String(
|
||||||
message.dataPoints.length,
|
message.dataPoints.length,
|
||||||
)
|
)
|
||||||
const last = message.dataPoints.at(-1)
|
const last = message.dataPoints.at(-1)
|
||||||
document.documentElement.dataset.perfLastUp = String(
|
document.documentElement.dataset.perfLastUp = String(last?.up ?? 0)
|
||||||
last?.up ?? 0,
|
document.documentElement.dataset.perfLastDown = String(
|
||||||
)
|
last?.down ?? 0,
|
||||||
document.documentElement.dataset.perfLastDown = String(
|
)
|
||||||
last?.down ?? 0,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.listeners.forEach((listener) => {
|
|
||||||
listener(message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.listeners.forEach((listener) => {
|
||||||
this.worker.onerror = (error) => {
|
listener(message)
|
||||||
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
|
})
|
||||||
}
|
|
||||||
|
|
||||||
this.ready = true
|
|
||||||
this.post(initMessage)
|
|
||||||
this.flushQueue()
|
|
||||||
return
|
|
||||||
} catch (error) {
|
|
||||||
debugLog(
|
|
||||||
`[TrafficWorkerClient] Worker initialization failed, falling back to inline sampler: ${String(error)}`,
|
|
||||||
)
|
|
||||||
this.worker = null
|
|
||||||
this.mode = null
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
debugLog(
|
|
||||||
'[TrafficWorkerClient] Worker not supported, using inline sampler',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.startInline(initMessage)
|
this.worker.onerror = (error) => {
|
||||||
}
|
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
|
||||||
|
}
|
||||||
|
|
||||||
private startInline(initMessage: TrafficWorkerRequestMessage) {
|
|
||||||
this.inlineMonitor = new InlineTrafficMonitor((snapshot) => {
|
|
||||||
this.listeners.forEach((listener) => {
|
|
||||||
listener(snapshot)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
this.mode = 'inline'
|
|
||||||
this.ready = true
|
|
||||||
this.post(initMessage)
|
this.post(initMessage)
|
||||||
this.flushQueue()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
if (this.worker) {
|
this.worker?.terminate()
|
||||||
this.worker.terminate()
|
|
||||||
}
|
|
||||||
if (this.inlineMonitor) {
|
|
||||||
this.inlineMonitor.stop()
|
|
||||||
}
|
|
||||||
this.worker = null
|
this.worker = null
|
||||||
this.inlineMonitor = null
|
|
||||||
this.mode = null
|
|
||||||
this.ready = false
|
|
||||||
this.pendingMessages = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) {
|
onSnapshot(listener: (snapshot: ITrafficWorkerSnapshotMessage) => void) {
|
||||||
@@ -260,37 +102,11 @@ class TrafficWorkerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private post(message: TrafficWorkerRequestMessage) {
|
private post(message: TrafficWorkerRequestMessage) {
|
||||||
if (!this.ready) {
|
this.worker?.postMessage(message)
|
||||||
this.pendingMessages.push(message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode === 'worker' && this.worker) {
|
|
||||||
this.worker.postMessage(message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode === 'inline' && this.inlineMonitor) {
|
|
||||||
this.inlineMonitor.handle(message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pendingMessages.push(message)
|
|
||||||
}
|
|
||||||
|
|
||||||
private flushQueue() {
|
|
||||||
if (!this.ready || this.pendingMessages.length === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const queued = [...this.pendingMessages]
|
|
||||||
this.pendingMessages = []
|
|
||||||
queued.forEach((message) => {
|
|
||||||
this.post(message)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ensureStarted() {
|
private ensureStarted() {
|
||||||
if (!this.ready) {
|
if (!this.worker) {
|
||||||
this.start(this.currentRange)
|
this.start(this.currentRange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export const useVerge = () => {
|
|||||||
| IVergeConfig
|
| IVergeConfig
|
||||||
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
|
| ((prev: IVergeConfig | undefined) => IVergeConfig | undefined)
|
||||||
| undefined,
|
| undefined,
|
||||||
_revalidate?: boolean,
|
|
||||||
) => {
|
) => {
|
||||||
if (updaterOrData === undefined) {
|
if (updaterOrData === undefined) {
|
||||||
void refetch()
|
void refetch()
|
||||||
|
|||||||
+6
-18
@@ -11,36 +11,24 @@ const useWindow = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useWindowControls = () => {
|
export const useWindowControls = () => {
|
||||||
const {
|
const { maximized, minimize, toggleMaximize, close, currentWindow } =
|
||||||
maximized,
|
useWindow()
|
||||||
minimize,
|
|
||||||
toggleMaximize,
|
|
||||||
close,
|
|
||||||
toggleFullscreen,
|
|
||||||
currentWindow,
|
|
||||||
} = useWindow()
|
|
||||||
return {
|
return {
|
||||||
maximized,
|
maximized,
|
||||||
minimize,
|
minimize,
|
||||||
toggleMaximize,
|
toggleMaximize,
|
||||||
close,
|
close,
|
||||||
toggleFullscreen,
|
|
||||||
currentWindow,
|
currentWindow,
|
||||||
} satisfies Pick<
|
} satisfies Pick<
|
||||||
WindowContextType,
|
WindowContextType,
|
||||||
| 'maximized'
|
'maximized' | 'minimize' | 'toggleMaximize' | 'close' | 'currentWindow'
|
||||||
| 'minimize'
|
|
||||||
| 'toggleMaximize'
|
|
||||||
| 'close'
|
|
||||||
| 'toggleFullscreen'
|
|
||||||
| 'currentWindow'
|
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWindowDecorations = () => {
|
export const useWindowDecorations = () => {
|
||||||
const { decorated, toggleDecorations, refreshDecorated } = useWindow()
|
const { decorated, toggleDecorations } = useWindow()
|
||||||
return { decorated, toggleDecorations, refreshDecorated } satisfies Pick<
|
return { decorated, toggleDecorations } satisfies Pick<
|
||||||
WindowContextType,
|
WindowContextType,
|
||||||
'decorated' | 'toggleDecorations' | 'refreshDecorated'
|
'decorated' | 'toggleDecorations'
|
||||||
>
|
>
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-21
@@ -1,8 +1,7 @@
|
|||||||
import { Paper, ThemeProvider } from '@mui/material'
|
import { Paper, ThemeProvider } from '@mui/material'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
import { useCallback, useEffect, useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { Outlet, useNavigate } from 'react-router'
|
import { Outlet, useNavigate } from 'react-router'
|
||||||
|
|
||||||
import { BaseErrorBoundary } from '@/components/base'
|
import { BaseErrorBoundary } from '@/components/base'
|
||||||
@@ -38,7 +37,6 @@ const OS = getSystem()
|
|||||||
const Layout = () => {
|
const Layout = () => {
|
||||||
const mode = useThemeMode()
|
const mode = useThemeMode()
|
||||||
const isDark = mode !== 'light'
|
const isDark = mode !== 'light'
|
||||||
const { t } = useTranslation()
|
|
||||||
const { theme } = useCustomTheme()
|
const { theme } = useCustomTheme()
|
||||||
const { verge } = useVerge()
|
const { verge } = useVerge()
|
||||||
const { language } = verge ?? {}
|
const { language } = verge ?? {}
|
||||||
@@ -46,7 +44,6 @@ const Layout = () => {
|
|||||||
const { switchLanguage } = useI18n()
|
const { switchLanguage } = useI18n()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const themeReady = useMemo(() => Boolean(theme), [theme])
|
const themeReady = useMemo(() => Boolean(theme), [theme])
|
||||||
const windowControlsRef = useRef<any>(null)
|
|
||||||
const { decorated } = useWindowDecorations()
|
const { decorated } = useWindowDecorations()
|
||||||
|
|
||||||
const customTitlebar = useMemo(
|
const customTitlebar = useMemo(
|
||||||
@@ -57,7 +54,7 @@ const Layout = () => {
|
|||||||
className="the_titlebar-drag-region"
|
className="the_titlebar-drag-region"
|
||||||
data-tauri-drag-region="true"
|
data-tauri-drag-region="true"
|
||||||
/>
|
/>
|
||||||
<WindowControls ref={windowControlsRef} />
|
<WindowControls />
|
||||||
</div>
|
</div>
|
||||||
) : null,
|
) : null,
|
||||||
[decorated],
|
[decorated],
|
||||||
@@ -69,12 +66,12 @@ const Layout = () => {
|
|||||||
(payload: [string, string]) => {
|
(payload: [string, string]) => {
|
||||||
const [status, msg] = payload
|
const [status, msg] = payload
|
||||||
try {
|
try {
|
||||||
handleNoticeMessage(status, msg, t, navigate)
|
handleNoticeMessage(status, msg, navigate)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[通知处理] 失败:', error)
|
console.error('[通知处理] 失败:', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[t, navigate],
|
[navigate],
|
||||||
)
|
)
|
||||||
|
|
||||||
useEventBus(handleNotice)
|
useEventBus(handleNotice)
|
||||||
@@ -110,20 +107,6 @@ const Layout = () => {
|
|||||||
<NoticeManager position={verge?.notice_position} />
|
<NoticeManager position={verge?.notice_position} />
|
||||||
<ServiceMigrationDialog />
|
<ServiceMigrationDialog />
|
||||||
<SysproxyPrivilegeDialog />
|
<SysproxyPrivilegeDialog />
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
animation: 'fadeIn 0.5s',
|
|
||||||
WebkitAnimation: 'fadeIn 0.5s',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<style>
|
|
||||||
{`
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
</style>
|
|
||||||
<Paper
|
<Paper
|
||||||
square
|
square
|
||||||
elevation={0}
|
elevation={0}
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ const ProfilePage = () => {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [url, setUrl] = useState('')
|
const [url, setUrl] = useState('')
|
||||||
const [disabled, setDisabled] = useState(false)
|
|
||||||
const [profileDndRevision, setProfileDndRevision] = useState(0)
|
const [profileDndRevision, setProfileDndRevision] = useState(0)
|
||||||
const [activatings, setActivatings] = useState<string[]>([])
|
const [activatings, setActivatings] = useState<string[]>([])
|
||||||
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
|
const [switchTarget, setSwitchTarget] = useState<string | null>(null)
|
||||||
@@ -257,7 +256,6 @@ const ProfilePage = () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setDisabled(false)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -824,7 +822,7 @@ const ProfilePage = () => {
|
|||||||
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
|
if (event.key !== 'Enter' || event.nativeEvent.isComposing) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!url || disabled || loading) {
|
if (!url || loading) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -857,7 +855,7 @@ const ProfilePage = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
disabled={!url || disabled}
|
disabled={!url}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
+1
-4
@@ -24,11 +24,8 @@ const RulesPage = () => {
|
|||||||
const [showScrollTop, setShowScrollTop] = useState(false)
|
const [showScrollTop, setShowScrollTop] = useState(false)
|
||||||
const pageVisible = useVisibility()
|
const pageVisible = useVisibility()
|
||||||
|
|
||||||
// 在组件挂载时和页面获得焦点时刷新规则数据
|
// 页面可见时刷新规则数据:挂载时可见或从隐藏恢复可见各触发一次
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshRules()
|
|
||||||
refreshRuleProviders()
|
|
||||||
|
|
||||||
if (pageVisible) {
|
if (pageVisible) {
|
||||||
refreshRules()
|
refreshRules()
|
||||||
refreshRuleProviders()
|
refreshRuleProviders()
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ const TQ_MIHOMO = {
|
|||||||
refetchOnReconnect: false,
|
refetchOnReconnect: false,
|
||||||
staleTime: 1500,
|
staleTime: 1500,
|
||||||
retry: 3,
|
retry: 3,
|
||||||
retryDelay: (attempt: number) => Math.min(200 * 2 ** attempt, 3000),
|
// SWR's default retry waits about 2^retryCount * errorRetryInterval with
|
||||||
|
// jitter, so 100 reproduces the previous 200 * 2^attempt backoff.
|
||||||
|
retryDelay: 100,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const TQ_DEFAULTS = {
|
const TQ_DEFAULTS = {
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ export interface WindowContextType {
|
|||||||
decorated: boolean | null
|
decorated: boolean | null
|
||||||
maximized: boolean | null
|
maximized: boolean | null
|
||||||
toggleDecorations: () => Promise<void>
|
toggleDecorations: () => Promise<void>
|
||||||
refreshDecorated: () => Promise<boolean>
|
|
||||||
minimize: () => Promise<void>
|
minimize: () => Promise<void>
|
||||||
close: () => Promise<void>
|
close: () => Promise<void>
|
||||||
toggleMaximize: () => Promise<void>
|
toggleMaximize: () => Promise<void>
|
||||||
toggleFullscreen: () => Promise<void>
|
|
||||||
currentWindow: ReturnType<typeof getCurrentWindow>
|
currentWindow: ReturnType<typeof getCurrentWindow>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,10 +61,6 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
}, [currentWindow])
|
}, [currentWindow])
|
||||||
|
|
||||||
const toggleFullscreen = useCallback(async () => {
|
|
||||||
await currentWindow.setFullscreen(!(await currentWindow.isFullscreen()))
|
|
||||||
}, [currentWindow])
|
|
||||||
|
|
||||||
const refreshDecorated = useCallback(async () => {
|
const refreshDecorated = useCallback(async () => {
|
||||||
const val = await currentWindow.isDecorated()
|
const val = await currentWindow.isDecorated()
|
||||||
setDecorated(val)
|
setDecorated(val)
|
||||||
@@ -87,22 +83,18 @@ export const WindowProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||||||
decorated,
|
decorated,
|
||||||
maximized,
|
maximized,
|
||||||
toggleDecorations,
|
toggleDecorations,
|
||||||
refreshDecorated,
|
|
||||||
minimize,
|
minimize,
|
||||||
close,
|
close,
|
||||||
toggleMaximize,
|
toggleMaximize,
|
||||||
toggleFullscreen,
|
|
||||||
currentWindow,
|
currentWindow,
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
decorated,
|
decorated,
|
||||||
maximized,
|
maximized,
|
||||||
toggleDecorations,
|
toggleDecorations,
|
||||||
refreshDecorated,
|
|
||||||
minimize,
|
minimize,
|
||||||
close,
|
close,
|
||||||
toggleMaximize,
|
toggleMaximize,
|
||||||
toggleFullscreen,
|
|
||||||
currentWindow,
|
currentWindow,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
+38
-37
@@ -35,7 +35,6 @@ interface IpInfo {
|
|||||||
interface ServiceConfig {
|
interface ServiceConfig {
|
||||||
url: string
|
url: string
|
||||||
mapping: (data: any) => IpInfo
|
mapping: (data: any) => IpInfo
|
||||||
timeout?: number // 保留timeout字段(如有需要)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 可用的IP检测服务列表及字段映射
|
// 可用的IP检测服务列表及字段映射
|
||||||
@@ -156,48 +155,52 @@ export const getIpInfo = async (): Promise<
|
|||||||
for (const service of shuffledServices) {
|
for (const service of shuffledServices) {
|
||||||
debugLog(`尝试IP检测服务: ${service.url}`)
|
debugLog(`尝试IP检测服务: ${service.url}`)
|
||||||
|
|
||||||
const timeoutController = new AbortController()
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
timeoutController.abort()
|
|
||||||
}, service.timeout || serviceTimeout)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await asyncRetry(
|
return await asyncRetry(
|
||||||
async (bail) => {
|
async (bail) => {
|
||||||
console.debug('Fetching IP information:', service.url)
|
console.debug('Fetching IP information:', service.url)
|
||||||
|
|
||||||
const response = await fetch(service.url, {
|
const timeoutController = new AbortController()
|
||||||
method: 'GET',
|
const timeoutId = setTimeout(() => {
|
||||||
signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout),
|
timeoutController.abort()
|
||||||
connectTimeout: service.timeout || serviceTimeout,
|
}, serviceTimeout)
|
||||||
headers: {
|
|
||||||
'User-Agent': userAgent,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return bail(
|
|
||||||
new Error(
|
|
||||||
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: any
|
|
||||||
try {
|
try {
|
||||||
data = await response.json()
|
const response = await fetch(service.url, {
|
||||||
} catch {
|
method: 'GET',
|
||||||
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
|
signal: timeoutController.signal,
|
||||||
}
|
connectTimeout: serviceTimeout,
|
||||||
|
headers: {
|
||||||
if (data && data.ip) {
|
'User-Agent': userAgent,
|
||||||
debugLog(`IP检测成功,使用服务: ${service.url}`)
|
},
|
||||||
return Object.assign(service.mapping(data), {
|
|
||||||
// use last fetch success timestamp
|
|
||||||
lastFetchTs: Date.now(),
|
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
return bail(new Error(`无效的响应格式 from ${service.url}`))
|
if (!response.ok) {
|
||||||
|
return bail(
|
||||||
|
new Error(
|
||||||
|
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any
|
||||||
|
try {
|
||||||
|
data = await response.json()
|
||||||
|
} catch {
|
||||||
|
return bail(new Error(`无法解析 JSON 响应 from ${service.url}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && data.ip) {
|
||||||
|
debugLog(`IP检测成功,使用服务: ${service.url}`)
|
||||||
|
return Object.assign(service.mapping(data), {
|
||||||
|
// use last fetch success timestamp
|
||||||
|
lastFetchTs: Date.now(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return bail(new Error(`无效的响应格式 from ${service.url}`))
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -210,8 +213,6 @@ export const getIpInfo = async (): Promise<
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
debugLog(`IP检测服务失败: ${service.url}`, error)
|
debugLog(`IP检测服务失败: ${service.url}`, error)
|
||||||
lastError = error
|
lastError = error
|
||||||
} finally {
|
|
||||||
clearTimeout(timeoutId)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const mountBus = async () => {
|
|||||||
return createEventBus({
|
return createEventBus({
|
||||||
dispatch,
|
dispatch,
|
||||||
handleNotice: ([status, message]: [string, string]) => {
|
handleNotice: ([status, message]: [string, string]) => {
|
||||||
notice(status, message, (key) => key, vi.fn())
|
notice(status, message, vi.fn())
|
||||||
},
|
},
|
||||||
revalidateKeys: (keys) => void revalidateQueries(keys.map((key) => [key])),
|
revalidateKeys: (keys) => void revalidateQueries(keys.map((key) => [key])),
|
||||||
readPendingFailures: () => {},
|
readPendingFailures: () => {},
|
||||||
@@ -72,7 +72,7 @@ it('does not replay a recovered startup error after WebView recreation', async (
|
|||||||
kind: 'startFailed',
|
kind: 'startFailed',
|
||||||
detail: 'startup failed',
|
detail: 'startup failed',
|
||||||
})
|
})
|
||||||
first.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
|
first.handleNoticeMessage('core_start::error', '', vi.fn())
|
||||||
await flush()
|
await flush()
|
||||||
expect(showNotice.error).toHaveBeenCalledOnce()
|
expect(showNotice.error).toHaveBeenCalledOnce()
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ it('does not replay a recovered startup error after WebView recreation', async (
|
|||||||
vi.mocked(getCoreStartupError).mockResolvedValue(null)
|
vi.mocked(getCoreStartupError).mockResolvedValue(null)
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
const recreated = await import('@/services/notice-handlers')
|
const recreated = await import('@/services/notice-handlers')
|
||||||
recreated.handleNoticeMessage('core_start::error', '', (key) => key, vi.fn())
|
recreated.handleNoticeMessage('core_start::error', '', vi.fn())
|
||||||
await flush()
|
await flush()
|
||||||
expect(showNotice.error).toHaveBeenCalledOnce()
|
expect(showNotice.error).toHaveBeenCalledOnce()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -277,6 +277,8 @@ class DelayManager {
|
|||||||
group: string,
|
group: string,
|
||||||
timeout: number,
|
timeout: number,
|
||||||
): Promise<DelayUpdate> {
|
): Promise<DelayUpdate> {
|
||||||
|
// Entry points own the pending mark; measureDelay only writes results.
|
||||||
|
this.setDelay(member.ref.name, group, -2)
|
||||||
const update = await this.measureDelay(member, group, timeout)
|
const update = await this.measureDelay(member, group, timeout)
|
||||||
this.queueGroupNotification(group)
|
this.queueGroupNotification(group)
|
||||||
return update
|
return update
|
||||||
@@ -298,8 +300,6 @@ class DelayManager {
|
|||||||
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
|
`[DelayManager] 开始测试延迟,代理: ${name}, 组: ${group}, 超时: ${timeout}ms`,
|
||||||
)
|
)
|
||||||
|
|
||||||
this.setDelay(name, group, -2)
|
|
||||||
|
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -359,8 +359,6 @@ class DelayManager {
|
|||||||
const currName = currMember.ref.name
|
const currName = currMember.ref.name
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.setDelay(currName, group, -2)
|
|
||||||
|
|
||||||
// Stagger requests so a batch does not hit the core at once.
|
// Stagger requests so a batch does not hit the core at once.
|
||||||
if (index > 1) {
|
if (index > 1) {
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) =>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { hideNotice, showNotice } from '@/services/notice-service'
|
|||||||
import { requestService } from '@/services/service-request'
|
import { requestService } from '@/services/service-request'
|
||||||
|
|
||||||
type NavigateFunction = (path: string, options?: any) => void
|
type NavigateFunction = (path: string, options?: any) => void
|
||||||
type TranslateFunction = (key: string) => string
|
|
||||||
|
|
||||||
let shownStartupError: string | null = null
|
let shownStartupError: string | null = null
|
||||||
// Reads can settle out of order; only the newest may act, and a reset counts as one.
|
// Reads can settle out of order; only the newest may act, and a reset counts as one.
|
||||||
@@ -241,7 +240,6 @@ const handlers: Record<
|
|||||||
export const handleNoticeMessage = (
|
export const handleNoticeMessage = (
|
||||||
status: string,
|
status: string,
|
||||||
msg: string,
|
msg: string,
|
||||||
t: TranslateFunction,
|
|
||||||
navigate: NavigateFunction,
|
navigate: NavigateFunction,
|
||||||
) => {
|
) => {
|
||||||
const handler = handlers[status as NoticeStatus]
|
const handler = handlers[status as NoticeStatus]
|
||||||
|
|||||||
@@ -148,15 +148,6 @@ function parseNoticeExtras(extras: NoticeExtra[]): ParsedNoticeExtras {
|
|||||||
raw = extra
|
raw = extra
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!params && isPlainRecord(extra)) {
|
|
||||||
params = extra
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration === undefined && typeof extra === 'number') {
|
|
||||||
duration = extra
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ type QueryOptions<T> = {
|
|||||||
placeholderData?: T | (() => T | undefined)
|
placeholderData?: T | (() => T | undefined)
|
||||||
staleTime?: number
|
staleTime?: number
|
||||||
retry?: number | false
|
retry?: number | false
|
||||||
retryDelay?: number | ((attempt: number) => number)
|
retryDelay?: number
|
||||||
refetchInterval?: number | false
|
refetchInterval?: number | false
|
||||||
refetchIntervalInBackground?: boolean
|
refetchIntervalInBackground?: boolean
|
||||||
revalidateOnMount?: boolean
|
revalidateOnMount?: boolean
|
||||||
@@ -90,25 +90,9 @@ export function useQuery<T>(options: QueryOptions<T>): QueryResult<T> {
|
|||||||
const swr = useSWR<T>(enabled ? queryKey : null, queryFn, {
|
const swr = useSWR<T>(enabled ? queryKey : null, queryFn, {
|
||||||
dedupingInterval: staleTime,
|
dedupingInterval: staleTime,
|
||||||
errorRetryCount: retry === false ? 0 : retry,
|
errorRetryCount: retry === false ? 0 : retry,
|
||||||
errorRetryInterval:
|
errorRetryInterval: retryDelay ?? swrConfig.errorRetryInterval,
|
||||||
typeof retryDelay === 'number'
|
|
||||||
? retryDelay
|
|
||||||
: swrConfig.errorRetryInterval,
|
|
||||||
fallbackData,
|
fallbackData,
|
||||||
keepPreviousData: placeholderData !== undefined,
|
keepPreviousData: placeholderData !== undefined,
|
||||||
onErrorRetry: (_error, _key, config, revalidate, { retryCount }) => {
|
|
||||||
const maxRetries = config.errorRetryCount
|
|
||||||
if (maxRetries !== undefined && retryCount > maxRetries) return
|
|
||||||
|
|
||||||
const interval =
|
|
||||||
typeof retryDelay === 'function'
|
|
||||||
? retryDelay(Math.max(retryCount - 1, 0))
|
|
||||||
: config.errorRetryInterval
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
revalidate({ retryCount, dedupe: true })
|
|
||||||
}, interval)
|
|
||||||
},
|
|
||||||
revalidateOnFocus: refetchOnWindowFocus,
|
revalidateOnFocus: refetchOnWindowFocus,
|
||||||
revalidateOnMount,
|
revalidateOnMount,
|
||||||
revalidateOnReconnect: refetchOnReconnect,
|
revalidateOnReconnect: refetchOnReconnect,
|
||||||
|
|||||||
Vendored
-24
@@ -105,30 +105,6 @@ type IProxyGroupItem = Omit<IProxyItem, 'all'> & {
|
|||||||
all: IProxyItem[]
|
all: IProxyItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProxyProviderItem {
|
|
||||||
name: string
|
|
||||||
type: string
|
|
||||||
proxies: IProxyItem[]
|
|
||||||
updatedAt: string
|
|
||||||
vehicleType: string
|
|
||||||
subscriptionInfo?: {
|
|
||||||
Upload: number
|
|
||||||
Download: number
|
|
||||||
Total: number
|
|
||||||
Expire: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IRuleProviderItem {
|
|
||||||
name: string
|
|
||||||
behavior: string
|
|
||||||
format: string
|
|
||||||
ruleCount: number
|
|
||||||
type: string
|
|
||||||
updatedAt: string
|
|
||||||
vehicleType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ITrafficItem {
|
interface ITrafficItem {
|
||||||
up: number
|
up: number
|
||||||
down: number
|
down: number
|
||||||
|
|||||||
Reference in New Issue
Block a user