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