mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 13:30:31 +08:00
perf(ip-info-card): reduce IP API load even more (#6263)
* refactor(ip-info-card): reduce retry, use succeed as lastFetchTs * refactor(ip-info-card): stop countdown during revalidation * perf(ip-info-card): avoid aggressive schedule revalidation * perf(ip-info-card): try stop interval on window minimized * perf(ip-info-card): only mutate after card scroll into view once * perf(ip-info-card): interval only when card has been visible * chore: add more debug information * Update src/components/home/ip-info-card.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: reset countdown state after mutate finishes --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
781313e8f0
commit
e9d63aee5e
@ -1,5 +1,5 @@
|
|||||||
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
import { Box, Typography, alpha, useTheme } from "@mui/material";
|
||||||
import { ReactNode } from "react";
|
import React, { forwardRef, ReactNode } from "react";
|
||||||
|
|
||||||
// 自定义卡片组件接口
|
// 自定义卡片组件接口
|
||||||
interface EnhancedCardProps {
|
interface EnhancedCardProps {
|
||||||
@ -19,103 +19,109 @@ interface EnhancedCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 自定义卡片组件
|
// 自定义卡片组件
|
||||||
export const EnhancedCard = ({
|
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
|
||||||
title,
|
(
|
||||||
icon,
|
{
|
||||||
action,
|
title,
|
||||||
children,
|
icon,
|
||||||
iconColor = "primary",
|
action,
|
||||||
minHeight,
|
children,
|
||||||
noContentPadding = false,
|
iconColor = "primary",
|
||||||
}: EnhancedCardProps) => {
|
minHeight,
|
||||||
const theme = useTheme();
|
noContentPadding = false,
|
||||||
const isDark = theme.palette.mode === "dark";
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const isDark = theme.palette.mode === "dark";
|
||||||
|
|
||||||
// 统一的标题截断样式
|
// 统一的标题截断样式
|
||||||
const titleTruncateStyle = {
|
const titleTruncateStyle = {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: "100%",
|
maxWidth: "100%",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
display: "block",
|
display: "block",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
borderRadius: 2,
|
|
||||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
px: 2,
|
height: "100%",
|
||||||
py: 1,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
flexDirection: "column",
|
||||||
justifyContent: "space-between",
|
borderRadius: 2,
|
||||||
borderBottom: 1,
|
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||||
borderColor: "divider",
|
|
||||||
}}
|
}}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minWidth: 0,
|
justifyContent: "space-between",
|
||||||
flex: 1,
|
borderBottom: 1,
|
||||||
overflow: "hidden",
|
borderColor: "divider",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
minWidth: 0,
|
||||||
borderRadius: 1.5,
|
flex: 1,
|
||||||
width: 38,
|
overflow: "hidden",
|
||||||
height: 38,
|
|
||||||
mr: 1.5,
|
|
||||||
flexShrink: 0,
|
|
||||||
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
|
||||||
color: theme.palette[iconColor].main,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{icon}
|
<Box
|
||||||
</Box>
|
sx={{
|
||||||
<Box sx={{ minWidth: 0, flex: 1 }}>
|
display: "flex",
|
||||||
{typeof title === "string" ? (
|
alignItems: "center",
|
||||||
<Typography
|
justifyContent: "center",
|
||||||
variant="h6"
|
borderRadius: 1.5,
|
||||||
fontWeight="medium"
|
width: 38,
|
||||||
fontSize={18}
|
height: 38,
|
||||||
sx={titleTruncateStyle}
|
mr: 1.5,
|
||||||
title={title}
|
flexShrink: 0,
|
||||||
>
|
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
||||||
{title}
|
color: theme.palette[iconColor].main,
|
||||||
</Typography>
|
}}
|
||||||
) : (
|
>
|
||||||
<Box sx={titleTruncateStyle}>{title}</Box>
|
{icon}
|
||||||
)}
|
</Box>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
{typeof title === "string" ? (
|
||||||
|
<Typography
|
||||||
|
variant="h6"
|
||||||
|
fontWeight="medium"
|
||||||
|
fontSize={18}
|
||||||
|
sx={titleTruncateStyle}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Box sx={titleTruncateStyle}>{title}</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
p: noContentPadding ? 0 : 2,
|
||||||
|
...(minHeight && { minHeight }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
</Box>
|
</Box>
|
||||||
{action && <Box sx={{ ml: 2, flexShrink: 0 }}>{action}</Box>}
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
);
|
||||||
sx={{
|
},
|
||||||
flex: 1,
|
);
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
p: noContentPadding ? 0 : 2,
|
|
||||||
...(minHeight && { minHeight }),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@ -7,9 +7,18 @@ import {
|
|||||||
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { useEffect } from "foxact/use-abortable-effect";
|
import { useEffect } from "foxact/use-abortable-effect";
|
||||||
import { memo, useCallback, useState, useEffectEvent, useMemo } from "react";
|
import { useIntersection } from "foxact/use-intersection";
|
||||||
|
import type { XOR } from "foxts/ts-xor";
|
||||||
|
import {
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffectEvent,
|
||||||
|
useMemo,
|
||||||
|
forwardRef,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import useSWR from "swr";
|
import useSWRImmutable from "swr/immutable";
|
||||||
|
|
||||||
import { getIpInfo } from "@/services/api";
|
import { getIpInfo } from "@/services/api";
|
||||||
|
|
||||||
@ -54,26 +63,60 @@ const getCountryFlag = (countryCode: string | undefined) => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
return String.fromCodePoint(...codePoints);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CountDownState = XOR<
|
||||||
|
{
|
||||||
|
type: "countdown";
|
||||||
|
remainingSeconds: number;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "revalidating";
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
const IPInfoCardContainer = forwardRef<HTMLElement, React.PropsWithChildren>(
|
||||||
|
({ children }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { mutate } = useIPInfo();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EnhancedCard
|
||||||
|
title={t("home.components.ipInfo.title")}
|
||||||
|
icon={<LocationOnOutlined />}
|
||||||
|
iconColor="info"
|
||||||
|
ref={ref}
|
||||||
|
action={
|
||||||
|
<IconButton size="small" onClick={() => mutate()}>
|
||||||
|
<RefreshOutlined />
|
||||||
|
</IconButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</EnhancedCard>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// IP信息卡片组件
|
// IP信息卡片组件
|
||||||
export const IpInfoCard = () => {
|
export const IpInfoCard = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [showIp, setShowIp] = useState(false);
|
const [showIp, setShowIp] = useState(false);
|
||||||
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
||||||
|
|
||||||
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
|
// track ip info card has been in viewport or not
|
||||||
|
// hasIntersected default to false, and will be true once the card is in viewport
|
||||||
const {
|
// and will never be false again afterwards (unless resetIntersected is called or
|
||||||
data: ipInfo,
|
// the component is unmounted)
|
||||||
error,
|
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
|
||||||
isLoading,
|
rootMargin: "0px",
|
||||||
mutate,
|
|
||||||
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
|
|
||||||
refreshInterval: 0,
|
|
||||||
refreshWhenOffline: false,
|
|
||||||
revalidateOnFocus: true,
|
|
||||||
shouldRetryOnError: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [countdown, setCountdown] = useState<CountDownState>({
|
||||||
|
type: "countdown",
|
||||||
|
remainingSeconds: IP_REFRESH_SECONDS,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
|
||||||
|
|
||||||
// function useEffectEvent
|
// function useEffectEvent
|
||||||
const onCountdownTick = useEffectEvent(async () => {
|
const onCountdownTick = useEffectEvent(async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -86,9 +129,30 @@ export const IpInfoCard = () => {
|
|||||||
const remaining = IP_REFRESH_SECONDS - elapsed;
|
const remaining = IP_REFRESH_SECONDS - elapsed;
|
||||||
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
if (navigator.onLine && (await appWindow.isVisible())) {
|
if (
|
||||||
mutate();
|
// has intersected at least once
|
||||||
setCountdown(IP_REFRESH_SECONDS);
|
// this avoids unncessary revalidation if user never scrolls down,
|
||||||
|
// then we will only load initially once.
|
||||||
|
hasIntersected &&
|
||||||
|
// is online
|
||||||
|
navigator.onLine &&
|
||||||
|
// there is no ongoing revalidation already scheduled
|
||||||
|
countdown.type !== "revalidating" &&
|
||||||
|
// window is visible
|
||||||
|
(await appWindow.isVisible())
|
||||||
|
) {
|
||||||
|
setCountdown({ type: "revalidating" });
|
||||||
|
// we do not care about the result of mutate here. after mutate is done,
|
||||||
|
// simply wait for next interval tick with `setCountdown({ type: "countdown", ... })`
|
||||||
|
try {
|
||||||
|
await mutate();
|
||||||
|
} finally {
|
||||||
|
// in case mutate throws error, we still need to reset the countdown state
|
||||||
|
setCountdown({
|
||||||
|
type: "countdown",
|
||||||
|
remainingSeconds: IP_REFRESH_SECONDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// do nothing. we even skip "setCountdown" to reduce re-renders
|
// do nothing. we even skip "setCountdown" to reduce re-renders
|
||||||
//
|
//
|
||||||
@ -97,58 +161,86 @@ export const IpInfoCard = () => {
|
|||||||
// or network online again, we mutate() immediately in the following tick.
|
// or network online again, we mutate() immediately in the following tick.
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setCountdown(remaining);
|
setCountdown({
|
||||||
|
type: "countdown",
|
||||||
|
remainingSeconds: remaining,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer: number | null = window.setInterval(onCountdownTick, 1000);
|
let timer: number | null = null;
|
||||||
|
|
||||||
|
// Do not add document.hidden check here as it is not reliable in Tauri.
|
||||||
|
//
|
||||||
|
// Thank god IntersectionObserver is a DOM API that relies on DOM/webview
|
||||||
|
// instead of Tauri, which is reliable enough.
|
||||||
|
if (hasIntersected) {
|
||||||
|
console.debug(
|
||||||
|
"IP info card has entered the viewport, starting the countdown interval.",
|
||||||
|
);
|
||||||
|
timer = window.setInterval(onCountdownTick, 1000);
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
"IP info card has not yet entered the viewport, no counting down.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This will fire when the window is minimized or restored
|
||||||
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
|
// Tauri's visibility change detection is actually broken on some platforms:
|
||||||
|
// https://github.com/tauri-apps/tauri/issues/10592
|
||||||
|
//
|
||||||
|
// It is working on macOS though (tested).
|
||||||
|
// So at least we should try to pause countdown on supported platforms to
|
||||||
|
// reduce power consumption.
|
||||||
|
function onVisibilityChange() {
|
||||||
|
if (document.hidden) {
|
||||||
|
console.debug("Document hidden, pause the interval");
|
||||||
|
// Pause the timer
|
||||||
|
if (timer != null) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
} else if (hasIntersected) {
|
||||||
|
console.debug("Document visible, resume the interval");
|
||||||
|
// Resume the timer only when previous one is cleared
|
||||||
|
if (timer == null) {
|
||||||
|
timer = window.setInterval(onCountdownTick, 1000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.debug(
|
||||||
|
"Document visible, but IP info card has never entered the viewport, not even once, not starting the interval.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timer != null) clearInterval(timer);
|
if (timer != null) clearInterval(timer);
|
||||||
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [hasIntersected]);
|
||||||
|
|
||||||
const toggleShowIp = useCallback(() => {
|
const toggleShowIp = useCallback(() => {
|
||||||
setShowIp((prev) => !prev);
|
setShowIp((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Loading
|
let mainElement: React.ReactElement;
|
||||||
if (isLoading) {
|
|
||||||
return (
|
switch (true) {
|
||||||
<EnhancedCard
|
case isLoading:
|
||||||
title={t("home.components.ipInfo.title")}
|
mainElement = (
|
||||||
icon={<LocationOnOutlined />}
|
|
||||||
iconColor="info"
|
|
||||||
action={
|
|
||||||
<IconButton size="small" onClick={() => mutate()} disabled>
|
|
||||||
<RefreshOutlined />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
<Skeleton variant="text" width="60%" height={30} />
|
<Skeleton variant="text" width="60%" height={30} />
|
||||||
<Skeleton variant="text" width="80%" height={24} />
|
<Skeleton variant="text" width="80%" height={24} />
|
||||||
<Skeleton variant="text" width="70%" height={24} />
|
<Skeleton variant="text" width="70%" height={24} />
|
||||||
<Skeleton variant="text" width="50%" height={24} />
|
<Skeleton variant="text" width="50%" height={24} />
|
||||||
</Box>
|
</Box>
|
||||||
</EnhancedCard>
|
);
|
||||||
);
|
break;
|
||||||
}
|
case !!error:
|
||||||
|
mainElement = (
|
||||||
// Error
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<EnhancedCard
|
|
||||||
title={t("home.components.ipInfo.title")}
|
|
||||||
icon={<LocationOnOutlined />}
|
|
||||||
iconColor="info"
|
|
||||||
action={
|
|
||||||
<IconButton size="small" onClick={() => mutate()}>
|
|
||||||
<RefreshOutlined />
|
|
||||||
</IconButton>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@ -168,163 +260,167 @@ export const IpInfoCard = () => {
|
|||||||
{t("shared.actions.retry")}
|
{t("shared.actions.retry")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</EnhancedCard>
|
);
|
||||||
);
|
break;
|
||||||
}
|
default: // Normal render
|
||||||
|
mainElement = (
|
||||||
// Normal render
|
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
||||||
return (
|
<Box
|
||||||
<EnhancedCard
|
sx={{
|
||||||
title={t("home.components.ipInfo.title")}
|
display: "flex",
|
||||||
icon={<LocationOnOutlined />}
|
flexDirection: "row",
|
||||||
iconColor="info"
|
flex: 1,
|
||||||
action={
|
overflow: "hidden",
|
||||||
<IconButton size="small" onClick={() => mutate()}>
|
}}
|
||||||
<RefreshOutlined />
|
>
|
||||||
</IconButton>
|
{/* 左侧:国家和IP地址 */}
|
||||||
}
|
<Box sx={{ width: "40%", overflow: "hidden" }}>
|
||||||
>
|
|
||||||
<Box sx={{ height: "100%", display: "flex", flexDirection: "column" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "row",
|
|
||||||
flex: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 左侧:国家和IP地址 */}
|
|
||||||
<Box sx={{ width: "40%", overflow: "hidden" }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
mb: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Box
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
fontSize: "1.5rem",
|
|
||||||
mr: 1,
|
|
||||||
display: "inline-block",
|
|
||||||
width: 28,
|
|
||||||
textAlign: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
fontFamily: '"twemoji mozilla", sans-serif',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{getCountryFlag(ipInfo?.country_code)}
|
|
||||||
</Box>
|
|
||||||
<Typography
|
|
||||||
variant="subtitle1"
|
|
||||||
sx={{
|
|
||||||
fontWeight: "medium",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
maxWidth: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{ipInfo?.country || t("home.components.ipInfo.labels.unknown")}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
|
||||||
<Typography
|
|
||||||
variant="body2"
|
|
||||||
color="text.secondary"
|
|
||||||
sx={{ flexShrink: 0 }}
|
|
||||||
>
|
|
||||||
{t("home.components.ipInfo.labels.ip")}:
|
|
||||||
</Typography>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
ml: 1,
|
mb: 1,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
maxWidth: "calc(100% - 30px)",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Typography
|
<Box
|
||||||
variant="body2"
|
component="span"
|
||||||
sx={{
|
sx={{
|
||||||
fontFamily: "monospace",
|
fontSize: "1.5rem",
|
||||||
fontSize: "0.75rem",
|
mr: 1,
|
||||||
overflow: "hidden",
|
display: "inline-block",
|
||||||
textOverflow: "ellipsis",
|
width: 28,
|
||||||
wordBreak: "break-all",
|
textAlign: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
fontFamily: '"twemoji mozilla", sans-serif',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showIp ? ipInfo?.ip : "••••••••••"}
|
{getCountryFlag(ipInfo?.country_code)}
|
||||||
|
</Box>
|
||||||
|
<Typography
|
||||||
|
variant="subtitle1"
|
||||||
|
sx={{
|
||||||
|
fontWeight: "medium",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
maxWidth: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ipInfo?.country ||
|
||||||
|
t("home.components.ipInfo.labels.unknown")}
|
||||||
</Typography>
|
</Typography>
|
||||||
<IconButton size="small" onClick={toggleShowIp}>
|
|
||||||
{showIp ? (
|
|
||||||
<VisibilityOffOutlined fontSize="small" />
|
|
||||||
) : (
|
|
||||||
<VisibilityOutlined fontSize="small" />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
color="text.secondary"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
{t("home.components.ipInfo.labels.ip")}:
|
||||||
|
</Typography>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
ml: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
maxWidth: "calc(100% - 30px)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant="body2"
|
||||||
|
sx={{
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showIp ? ipInfo?.ip : "••••••••••"}
|
||||||
|
</Typography>
|
||||||
|
<IconButton size="small" onClick={toggleShowIp}>
|
||||||
|
{showIp ? (
|
||||||
|
<VisibilityOffOutlined fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<VisibilityOutlined fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<InfoItem
|
||||||
|
label={t("home.components.ipInfo.labels.asn")}
|
||||||
|
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<InfoItem
|
{/* 右侧:组织、ISP和位置信息 */}
|
||||||
label={t("home.components.ipInfo.labels.asn")}
|
<Box sx={{ width: "60%", overflow: "auto" }}>
|
||||||
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
|
<InfoItem
|
||||||
/>
|
label={t("home.components.ipInfo.labels.isp")}
|
||||||
|
value={ipInfo?.organization}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("home.components.ipInfo.labels.org")}
|
||||||
|
value={ipInfo?.asn_organization}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("home.components.ipInfo.labels.location")}
|
||||||
|
value={[ipInfo?.city, ipInfo?.region]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ")}
|
||||||
|
/>
|
||||||
|
<InfoItem
|
||||||
|
label={t("home.components.ipInfo.labels.timezone")}
|
||||||
|
value={ipInfo?.timezone}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* 右侧:组织、ISP和位置信息 */}
|
<Box
|
||||||
<Box sx={{ width: "60%", overflow: "auto" }}>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.isp")}
|
|
||||||
value={ipInfo?.organization}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.org")}
|
|
||||||
value={ipInfo?.asn_organization}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.location")}
|
|
||||||
value={[ipInfo?.city, ipInfo?.region].filter(Boolean).join(", ")}
|
|
||||||
/>
|
|
||||||
<InfoItem
|
|
||||||
label={t("home.components.ipInfo.labels.timezone")}
|
|
||||||
value={ipInfo?.timezone}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
mt: "auto",
|
|
||||||
pt: 0.5,
|
|
||||||
borderTop: 1,
|
|
||||||
borderColor: "divider",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
opacity: 0.7,
|
|
||||||
fontSize: "0.7rem",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption">
|
|
||||||
{t("home.components.ipInfo.labels.autoRefresh")}: {countdown}s
|
|
||||||
</Typography>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
sx={{
|
||||||
textOverflow: "ellipsis",
|
mt: "auto",
|
||||||
overflow: "hidden",
|
pt: 0.5,
|
||||||
whiteSpace: "nowrap",
|
borderTop: 1,
|
||||||
|
borderColor: "divider",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: "0.7rem",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
|
<Typography variant="caption">
|
||||||
</Typography>
|
{t("home.components.ipInfo.labels.autoRefresh")}
|
||||||
|
{countdown.type === "countdown"
|
||||||
|
? `: ${countdown.remainingSeconds}s`
|
||||||
|
: "..."}
|
||||||
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
);
|
||||||
</EnhancedCard>
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IPInfoCardContainer ref={containerRef}>{mainElement}</IPInfoCardContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function useIPInfo() {
|
||||||
|
return useSWRImmutable(IP_INFO_CACHE_KEY, getIpInfo, {
|
||||||
|
shouldRetryOnError: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -186,10 +186,8 @@ function createPrng(seed: number): () => number {
|
|||||||
export const getIpInfo = async (): Promise<
|
export const getIpInfo = async (): Promise<
|
||||||
IpInfo & { lastFetchTs: number }
|
IpInfo & { lastFetchTs: number }
|
||||||
> => {
|
> => {
|
||||||
const lastFetchTs = Date.now();
|
|
||||||
|
|
||||||
// 配置参数
|
// 配置参数
|
||||||
const maxRetries = 3;
|
const maxRetries = 2;
|
||||||
const serviceTimeout = 5000;
|
const serviceTimeout = 5000;
|
||||||
|
|
||||||
const shuffledServices = shuffleServices();
|
const shuffledServices = shuffleServices();
|
||||||
@ -206,7 +204,7 @@ export const getIpInfo = async (): Promise<
|
|||||||
try {
|
try {
|
||||||
return await asyncRetry(
|
return await asyncRetry(
|
||||||
async (bail) => {
|
async (bail) => {
|
||||||
console.debug("Fetching IP information...");
|
console.debug("Fetching IP information:", service.url);
|
||||||
|
|
||||||
const response = await fetch(service.url, {
|
const response = await fetch(service.url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@ -226,15 +224,18 @@ export const getIpInfo = async (): Promise<
|
|||||||
|
|
||||||
if (data && data.ip) {
|
if (data && data.ip) {
|
||||||
debugLog(`IP检测成功,使用服务: ${service.url}`);
|
debugLog(`IP检测成功,使用服务: ${service.url}`);
|
||||||
return Object.assign(service.mapping(data), { lastFetchTs });
|
return Object.assign(service.mapping(data), {
|
||||||
|
// use last fetch success timestamp
|
||||||
|
lastFetchTs: Date.now(),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`无效的响应格式 from ${service.url}`);
|
throw new Error(`无效的响应格式 from ${service.url}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
retries: maxRetries,
|
retries: maxRetries,
|
||||||
minTimeout: 500,
|
minTimeout: 1000,
|
||||||
maxTimeout: 2000,
|
maxTimeout: 4000,
|
||||||
randomize: true,
|
randomize: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user