mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-04-13 05:20:28 +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 { ReactNode } from "react";
|
||||
import React, { forwardRef, ReactNode } from "react";
|
||||
|
||||
// 自定义卡片组件接口
|
||||
interface EnhancedCardProps {
|
||||
@ -19,103 +19,109 @@ interface EnhancedCardProps {
|
||||
}
|
||||
|
||||
// 自定义卡片组件
|
||||
export const EnhancedCard = ({
|
||||
title,
|
||||
icon,
|
||||
action,
|
||||
children,
|
||||
iconColor = "primary",
|
||||
minHeight,
|
||||
noContentPadding = false,
|
||||
}: EnhancedCardProps) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
export const EnhancedCard = forwardRef<HTMLElement, EnhancedCardProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
icon,
|
||||
action,
|
||||
children,
|
||||
iconColor = "primary",
|
||||
minHeight,
|
||||
noContentPadding = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
|
||||
// 统一的标题截断样式
|
||||
const titleTruncateStyle = {
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
};
|
||||
// 统一的标题截断样式
|
||||
const titleTruncateStyle = {
|
||||
minWidth: 0,
|
||||
maxWidth: "100%",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
display: "block",
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
>
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
flexDirection: "column",
|
||||
borderRadius: 2,
|
||||
backgroundColor: isDark ? "#282a36" : "#ffffff",
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1.5,
|
||||
width: 38,
|
||||
height: 38,
|
||||
mr: 1.5,
|
||||
flexShrink: 0,
|
||||
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
||||
color: theme.palette[iconColor].main,
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{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
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: 1.5,
|
||||
width: 38,
|
||||
height: 38,
|
||||
mr: 1.5,
|
||||
flexShrink: 0,
|
||||
backgroundColor: alpha(theme.palette[iconColor].main, 0.12),
|
||||
color: theme.palette[iconColor].main,
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -7,9 +7,18 @@ import {
|
||||
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
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 useSWR from "swr";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
|
||||
import { getIpInfo } from "@/services/api";
|
||||
|
||||
@ -54,26 +63,60 @@ const getCountryFlag = (countryCode: string | undefined) => {
|
||||
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信息卡片组件
|
||||
export const IpInfoCard = () => {
|
||||
const { t } = useTranslation();
|
||||
const [showIp, setShowIp] = useState(false);
|
||||
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
|
||||
|
||||
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
|
||||
|
||||
const {
|
||||
data: ipInfo,
|
||||
error,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
|
||||
refreshInterval: 0,
|
||||
refreshWhenOffline: false,
|
||||
revalidateOnFocus: true,
|
||||
shouldRetryOnError: true,
|
||||
// track ip info card has been in viewport or not
|
||||
// hasIntersected default to false, and will be true once the card is in viewport
|
||||
// and will never be false again afterwards (unless resetIntersected is called or
|
||||
// the component is unmounted)
|
||||
const [containerRef, hasIntersected, _resetIntersected] = useIntersection({
|
||||
rootMargin: "0px",
|
||||
});
|
||||
|
||||
const [countdown, setCountdown] = useState<CountDownState>({
|
||||
type: "countdown",
|
||||
remainingSeconds: IP_REFRESH_SECONDS,
|
||||
});
|
||||
|
||||
const { data: ipInfo, error, isLoading, mutate } = useIPInfo();
|
||||
|
||||
// function useEffectEvent
|
||||
const onCountdownTick = useEffectEvent(async () => {
|
||||
const now = Date.now();
|
||||
@ -86,9 +129,30 @@ export const IpInfoCard = () => {
|
||||
const remaining = IP_REFRESH_SECONDS - elapsed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
if (navigator.onLine && (await appWindow.isVisible())) {
|
||||
mutate();
|
||||
setCountdown(IP_REFRESH_SECONDS);
|
||||
if (
|
||||
// has intersected at least once
|
||||
// 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 {
|
||||
// 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.
|
||||
}
|
||||
} else {
|
||||
setCountdown(remaining);
|
||||
setCountdown({
|
||||
type: "countdown",
|
||||
remainingSeconds: remaining,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
|
||||
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 () => {
|
||||
if (timer != null) clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
}, [hasIntersected]);
|
||||
|
||||
const toggleShowIp = useCallback(() => {
|
||||
setShowIp((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Loading
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("home.components.ipInfo.title")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={() => mutate()} disabled>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
let mainElement: React.ReactElement;
|
||||
|
||||
switch (true) {
|
||||
case isLoading:
|
||||
mainElement = (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
<Skeleton variant="text" width="60%" height={30} />
|
||||
<Skeleton variant="text" width="80%" height={24} />
|
||||
<Skeleton variant="text" width="70%" height={24} />
|
||||
<Skeleton variant="text" width="50%" height={24} />
|
||||
</Box>
|
||||
</EnhancedCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Error
|
||||
if (error) {
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("home.components.ipInfo.title")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={() => mutate()}>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
);
|
||||
break;
|
||||
case !!error:
|
||||
mainElement = (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
@ -168,163 +260,167 @@ export const IpInfoCard = () => {
|
||||
{t("shared.actions.retry")}
|
||||
</Button>
|
||||
</Box>
|
||||
</EnhancedCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Normal render
|
||||
return (
|
||||
<EnhancedCard
|
||||
title={t("home.components.ipInfo.title")}
|
||||
icon={<LocationOnOutlined />}
|
||||
iconColor="info"
|
||||
action={
|
||||
<IconButton size="small" onClick={() => mutate()}>
|
||||
<RefreshOutlined />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
break;
|
||||
default: // Normal render
|
||||
mainElement = (
|
||||
<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",
|
||||
ml: 1,
|
||||
mb: 1,
|
||||
overflow: "hidden",
|
||||
maxWidth: "calc(100% - 30px)",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="body2"
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: "monospace",
|
||||
fontSize: "0.75rem",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
wordBreak: "break-all",
|
||||
fontSize: "1.5rem",
|
||||
mr: 1,
|
||||
display: "inline-block",
|
||||
width: 28,
|
||||
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>
|
||||
<IconButton size="small" onClick={toggleShowIp}>
|
||||
{showIp ? (
|
||||
<VisibilityOffOutlined fontSize="small" />
|
||||
) : (
|
||||
<VisibilityOutlined fontSize="small" />
|
||||
)}
|
||||
</IconButton>
|
||||
</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>
|
||||
|
||||
<InfoItem
|
||||
label={t("home.components.ipInfo.labels.asn")}
|
||||
value={ipInfo?.asn ? `AS${ipInfo.asn}` : "N/A"}
|
||||
/>
|
||||
{/* 右侧:组织、ISP和位置信息 */}
|
||||
<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>
|
||||
|
||||
{/* 右侧:组织、ISP和位置信息 */}
|
||||
<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"
|
||||
<Box
|
||||
sx={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
mt: "auto",
|
||||
pt: 0.5,
|
||||
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>
|
||||
<Typography variant="caption">
|
||||
{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>
|
||||
</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<
|
||||
IpInfo & { lastFetchTs: number }
|
||||
> => {
|
||||
const lastFetchTs = Date.now();
|
||||
|
||||
// 配置参数
|
||||
const maxRetries = 3;
|
||||
const maxRetries = 2;
|
||||
const serviceTimeout = 5000;
|
||||
|
||||
const shuffledServices = shuffleServices();
|
||||
@ -206,7 +204,7 @@ export const getIpInfo = async (): Promise<
|
||||
try {
|
||||
return await asyncRetry(
|
||||
async (bail) => {
|
||||
console.debug("Fetching IP information...");
|
||||
console.debug("Fetching IP information:", service.url);
|
||||
|
||||
const response = await fetch(service.url, {
|
||||
method: "GET",
|
||||
@ -226,15 +224,18 @@ export const getIpInfo = async (): Promise<
|
||||
|
||||
if (data && data.ip) {
|
||||
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 {
|
||||
throw new Error(`无效的响应格式 from ${service.url}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
retries: maxRetries,
|
||||
minTimeout: 500,
|
||||
maxTimeout: 2000,
|
||||
minTimeout: 1000,
|
||||
maxTimeout: 4000,
|
||||
randomize: true,
|
||||
},
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user