Compare commits

..
Author SHA1 Message Date
Tunglies 2fd6be642e refactor(services): drop the bespoke retry engine from the query wrapper
The custom onErrorRetry existed only to honor a function-form
retryDelay, which exactly one caller (the app data provider) used for
exponential backoff. Fall back to SWR's built-in error retry and
express that caller's cadence with constant options.
2026-10-28 10:52:18 +08:00
Tunglies 433230aedc refactor(hooks): trim dead surface from proxy selection
Drop the connection-cleanup option nobody ever disabled (behavior now
unconditional), the returned changeProxy no consumer called and the two
one-caller argument adapters; both call sites use the shared select
entry directly.
2026-10-28 10:43:01 +08:00
Tunglies 05475c7bb5 refactor(hooks): drop the write-only error channel and key-rotating refresh
The mihomo ws subscription's next callback accepted an error half that
baseNext silently discarded while traffic, memory and log consumers
kept constructing fallback payloads for it. Refresh minted a fresh
SWR key per call via a localStorage timestamp, abandoning old keys in
the cache forever. Reduce next to its data half and reset the snapshot
under a stable key instead.
2026-10-28 10:31:43 +08:00
Tunglies eb60c21c62 refactor(shared): replace the traffic error boundary with ErrorBoundary
TrafficErrorBoundary maintained a second boundary system beside
BaseErrorBoundary: a report builder that only console.logged constants
in the Tauri webview, an onError that re-logged what componentDidCatch
already logged, and a fallback wrapper with a single consumer. Fold
both mount points into react-error-boundary (the primitive behind
BaseErrorBoundary) with local fallbacks that keep the panel styling,
message text and retry reset, log each error once, and drop the retry
attempt cap and the stack-details collapse along with it.
2026-10-28 10:17:37 +08:00
Tunglies 4489c9a26f refactor(traffic): drop unreachable inline monitor fallback
The inline traffic monitor duplicated the worker's message loop,
snapshot throttling and aging logic but only runs when Worker is
undefined or its constructor throws, which no supported Tauri webview
or browser hits. Keep the worker as the single implementation and
remove the mode switch and the pending-message queue that could only
ever buffer the init message.
2026-10-28 09:59:44 +08:00
Tunglies 98a7932742 fix(services): make retries viable and align polling freshness window
Share no abort signal across retry attempts: after a timeout abort the
old signal stays dead and every later fetch rejects immediately, so
retries only burned backoff time. Also drop the system state query's
staleTime: the wrapper maps it to SWR's dedupingInterval, which
swallowed every other 3s poll tick while it exceeded the interval.
2026-10-28 09:42:04 +08:00
Tunglies c678d60964 fix(delay): write pending state once per node in batch checks
checkListDelay pre-marked every node as checking, help() re-marked it
when picked up and measureDelay marked it a third time on entry, so
each batch run queued three identical pending updates per node. Keep
the pre-mark (immediate feedback for queued nodes) and drop the
redundant marks along the chain.
2026-10-28 09:27:29 +08:00
Tunglies d5bc4ff58b fix(setting): mount only reachable proxy control viewers
ProxyControlSwitches unconditionally mounted both SysproxyViewer and
TunViewer while its switch rows are mutually exclusive, so every
instance carried a dialog that could never open, each firing
getEmbeddedServerPort or a clash query subscription on mount. Mount
each viewer under the same condition as the switch row that opens it.

SettingSystem also mounted its own SysproxyViewer and TunViewer whose
refs were never opened there; the reachable entries are the
ProxyControlSwitches rows, so the dead pair is removed. Viewer mounts
across the app drop from eight (setting-system: two own + two per
instance, proxy-tun-card: two) to three (one per rendered switch
variant).
2026-10-28 09:19:23 +08:00
Tunglies b81981162b fix(setting): build viewer form defaults from a single source
sysproxy, misc and tunnels viewers duplicated their form default
literals in useState and open(), and the two copies in misc-viewer had
already diverged (app log max size, app log max count, auto log clean).
Extract a buildInitialValues factory per viewer and unify on the open()
values, which are the only ones users could ever see.
2026-10-28 09:13:14 +08:00
Tunglies 6fa6d7664f fix(setting): write auto launch patch once per toggle
GuardState applies the optimistic onChange write before onGuard runs,
so repeating the same onChangeData patch inside the guard handler
duplicated every cache write and subscriber notification for the auto
launch switch.
2026-10-28 06:41:18 +08:00
Tunglies 4967ad347e fix(rules): refresh once per visibility trigger
The mount effect fired refreshRules and refreshRuleProviders
unconditionally and again under the page-visible flag, double-firing
both queries on mount and on every hide-to-show transition. Gate the
refresh on visibility once so each trigger performs exactly one
revalidation.
2026-10-28 06:36:33 +08:00
Tunglies 6394f347f5 refactor(types): remove dead provider view types superseded by bindings
Drop the handwritten IProxyProviderItem and IRuleProviderItem
interfaces: IProxyProviderItem is a stale duplicate of the generated
ProxyProviderView contract, and neither has any references left in
the tree.

Keep IProxyItem and IProxyGroupItem: IConfigData still declares
'proxy-groups'?: IProxyGroupItem[], which builds on IProxyItem.
2026-10-28 06:33:08 +08:00
Tunglies e56711b2db refactor(frontend): drop unused params, props and api surface
Remove the never-read _revalidate argument of mutateVerge and its 16
call-site placeholders, seven never-passed props of the split chip
editor, the unconsumed refreshDecorated context member, the now-orphaned
toggleFullscreen control, the unused translate argument of the notice
message handler, the reserved-but-unset ServiceConfig.timeout field and
unreachable branches in parseNoticeExtras.
2026-10-28 06:30:46 +08:00
Tunglies 3e28b287d1 refactor(frontend): remove dead states, refs and unreachable branches
Drop state that can only hold its initial value (profiles page disabled
flag, traffic graph currentFPS), a write-only window controls ref with
its unconsumed imperative handle, a no-op appendData ref method, an
unreachable gradient branch, an invisible fadeIn shell, constant-true
subexpressions in the system info card, an empty click handler and a
pointless useMemo.
2026-10-28 06:08:44 +08:00
Tunglies 51a4aa80dd docs(agents): add frontend liveness constraints
Add six liveness rules to the frontend guardrails covering dead state,
unused exposed surface, unreachable fallback paths, duplicate side
effects per interaction, unreachable mounts and unbounded resource
lifecycles. These target defect classes that the existing CI gates
(knip, eslint, arch:check) cannot detect mechanically.
2026-10-28 06:03:11 +08:00
49 changed files with 379 additions and 1073 deletions
+1 -1
View File
@@ -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",
+5 -5
View File
@@ -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
+24
View File
@@ -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.
+13 -30
View File
@@ -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}
+3 -9
View File
@@ -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) {
setCurrentFPS(GRAPH_CONFIG.targetFPS)
}
scheduleDrawGraphRef.current() 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,7 +666,6 @@ 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)
@@ -693,11 +674,6 @@ export const EnhancedCanvasTrafficGraph = memo(
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)
ctx.lineTo(getX(0), height - padding.bottom) ctx.lineTo(getX(0), 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>
+55 -6
View File
@@ -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>
) )
} }
+6 -13
View File
@@ -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 = () => {
+3 -3
View File
@@ -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 })
+1 -4
View File
@@ -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],
) )
+33 -4
View File
@@ -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>
) )
} }
+4 -36
View File
@@ -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>
) )
}) }
+3 -3
View File
@@ -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,8 +113,7 @@ export const BackupConfigViewer = memo(
errorNotice: false, errorNotice: false,
}, },
) )
await mutateVerge( await mutateVerge((current) =>
(current) =>
current current
? { ? {
...current, ...current,
@@ -123,7 +122,6 @@ export const BackupConfigViewer = memo(
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 (
+14 -25
View File
@@ -15,30 +15,7 @@ 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'
export const MiscViewer = forwardRef<DialogRef>((props, ref) => { const buildInitialValues = (verge?: IVergeConfig | null) => ({
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const [open, setOpen] = useState(false)
const [values, setValues] = useState({
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, () => ({
open: () => {
setOpen(true)
setValues({
appLogLevel: verge?.app_log_level ?? 'warn', appLogLevel: verge?.app_log_level ?? 'warn',
appLogMaxSize: verge?.app_log_max_size ?? 128, appLogMaxSize: verge?.app_log_max_size ?? 128,
appLogMaxCount: verge?.app_log_max_count ?? 8, appLogMaxCount: verge?.app_log_max_count ?? 8,
@@ -52,7 +29,19 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
defaultLatencyTest: verge?.default_latency_test || '', defaultLatencyTest: verge?.default_latency_test || '',
autoLogClean: verge?.auto_log_clean || 0, autoLogClean: verge?.auto_log_clean || 0,
defaultLatencyTimeout: verge?.default_latency_timeout || 10000, defaultLatencyTimeout: verge?.default_latency_timeout || 10000,
}) })
export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
const { t } = useTranslation()
const { verge, patchVerge } = useVerge()
const [open, setOpen] = useState(false)
const [values, setValues] = useState(() => buildInitialValues(null))
useImperativeHandle(ref, () => ({
open: () => {
setOpen(true)
setValues(buildInitialValues(verge))
}, },
close: () => setOpen(false), close: () => setOpen(false),
})) }))
+14 -21
View File
@@ -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)
+12 -18
View File
@@ -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 })
}) })
+3 -13
View File
@@ -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>
)
}
+2 -4
View File
@@ -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],
+4 -8
View File
@@ -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))
} }
+3 -7
View File
@@ -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)
}
}, },
}), }),
}) })
+32 -68
View File
@@ -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) => {
+2 -2
View File
@@ -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'),
) )
+4 -28
View File
@@ -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,
}
} }
+4 -6
View File
@@ -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
+2 -1
View File
@@ -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,
+3 -7
View File
@@ -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)
}
}, },
}), }),
}) })
+6 -190
View File
@@ -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,26 +46,17 @@ 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') {
try {
this.worker = new Worker( this.worker = new Worker(
new URL('../services/traffic-monitor-worker.ts', import.meta.url), new URL('../services/traffic-monitor-worker.ts', import.meta.url),
{ type: 'module' }, { type: 'module' },
) )
this.mode = 'worker'
this.worker.onmessage = ( this.worker.onmessage = (
event: MessageEvent<TrafficWorkerResponseMessage>, event: MessageEvent<TrafficWorkerResponseMessage>,
@@ -189,9 +71,7 @@ class TrafficWorkerClient {
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( document.documentElement.dataset.perfLastDown = String(
last?.down ?? 0, last?.down ?? 0,
) )
@@ -206,50 +86,12 @@ class TrafficWorkerClient {
debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`) debugLog(`[TrafficWorkerClient] Worker error: ${String(error)}`)
} }
this.ready = true
this.post(initMessage) 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)
}
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.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)
} }
} }
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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}
+2 -4
View File
@@ -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
View File
@@ -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()
+3 -1
View File
@@ -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 = {
-2
View File
@@ -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>
} }
-8
View File
@@ -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,
], ],
) )
+11 -10
View File
@@ -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,20 +155,21 @@ 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 timeoutController = new AbortController()
const timeoutId = setTimeout(() => {
timeoutController.abort()
}, serviceTimeout)
try {
const response = await fetch(service.url, { const response = await fetch(service.url, {
method: 'GET', method: 'GET',
signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout), signal: timeoutController.signal,
connectTimeout: service.timeout || serviceTimeout, connectTimeout: serviceTimeout,
headers: { headers: {
'User-Agent': userAgent, 'User-Agent': userAgent,
}, },
@@ -199,6 +199,9 @@ export const getIpInfo = async (): Promise<
} else { } else {
return bail(new Error(`无效的响应格式 from ${service.url}`)) return bail(new Error(`无效的响应格式 from ${service.url}`))
} }
} finally {
clearTimeout(timeoutId)
}
}, },
{ {
retries: maxRetries, retries: maxRetries,
@@ -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)
} }
} }
+3 -3
View File
@@ -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()
}) })
+2 -4
View File
@@ -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) =>
-2
View File
@@ -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]
-9
View File
@@ -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 {
+2 -18
View File
@@ -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,
-24
View File
@@ -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