mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-10-29 01:52:08 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fd6be642e | ||
|
|
433230aedc | ||
|
|
05475c7bb5 | ||
|
|
eb60c21c62 | ||
|
|
4489c9a26f | ||
|
|
98a7932742 | ||
|
|
c678d60964 | ||
|
|
d5bc4ff58b | ||
|
|
b81981162b | ||
|
|
6fa6d7664f | ||
|
|
4967ad347e | ||
|
|
6394f347f5 | ||
|
|
e56711b2db | ||
|
|
3e28b287d1 | ||
|
|
51a4aa80dd |
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -282,7 +282,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -426,7 +426,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
@@ -524,7 +524,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6.1.0
|
||||
name: Install pnpm
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
if: github.event.inputs[matrix.input] == 'true'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Pnpm Cache
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Restore pnpm cache
|
||||
|
||||
@@ -230,7 +230,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -331,7 +331,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
@@ -466,7 +466,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -547,7 +547,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -573,7 +573,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -638,7 +638,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '26.12.1'
|
||||
node-version: '24.22.1'
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
name: Install pnpm
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@
|
||||
"react-dom": "19.3.1",
|
||||
"react-error-boundary": "6.1.6",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "17.1.9",
|
||||
"react-i18next": "17.1.8",
|
||||
"react-markdown": "10.1.0",
|
||||
"react-router": "^8.4.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
Generated
+5
-5
@@ -275,8 +275,8 @@ importers:
|
||||
specifier: ^7.88.0
|
||||
version: 7.88.0(react@19.3.1)
|
||||
react-i18next:
|
||||
specifier: 17.1.9
|
||||
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)
|
||||
specifier: 17.1.8
|
||||
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:
|
||||
specifier: 10.1.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:
|
||||
react: ^16.8.0 || ^17 || ^18 || ^19
|
||||
|
||||
react-i18next@17.1.9:
|
||||
resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==}
|
||||
react-i18next@17.1.8:
|
||||
resolution: {integrity: sha512-vGH+Tbhqbv5LdN9KvC9GEmQ6ATxJAjyxPEK0zSCSRlkeDqCPNxVtEOvDNRAz+51HTxc6QPwhnHaFD7ICu6pD1A==}
|
||||
peerDependencies:
|
||||
i18next: '>= 26.2.0'
|
||||
react: '>= 16.8.0'
|
||||
@@ -9529,7 +9529,7 @@ snapshots:
|
||||
dependencies:
|
||||
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:
|
||||
'@babel/runtime': 7.29.7
|
||||
html-parse-stringify: 4.0.1
|
||||
|
||||
@@ -21,3 +21,27 @@ These rules supplement the repository's root `AGENTS.md`.
|
||||
- **Command ownership.** `services` owns backend command invocation and transport
|
||||
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 })
|
||||
|
||||
@@ -113,17 +113,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