refactor(ip-info-card): make ip info card much better (#6226)

* perf(ip-info-card): make ip info card much better

* fix(ip-info-card): remove unused useEffect deps

* Apply suggestion from @Copilot

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* refactor(ip-info-card): use `async-retry`, bail out non-2XX resp

* feat(ip-info-card): add new backend

* feat(ip-info-card): only revalidate when window is visible

* perf(ip-info-card): reduce re-renders when window is hidden

* fix(ip-info-card): remove `mutate` from `useEffect` arg

* Update src/components/home/ip-info-card.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: drop AbortSignal.timeout for old safati compat

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Sukka 2026-02-04 09:40:13 +08:00 committed by GitHub
parent b3a1fb8d23
commit 90e193099f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 148 additions and 183 deletions

View File

@ -58,6 +58,7 @@
"axios": "^1.13.3",
"dayjs": "1.11.19",
"foxact": "^0.2.52",
"foxts": "^5.2.1",
"i18next": "^25.8.0",
"ipaddr.js": "^2.3.0",
"js-yaml": "^4.1.1",

8
pnpm-lock.yaml generated
View File

@ -80,6 +80,9 @@ importers:
foxact:
specifier: ^0.2.52
version: 0.2.52(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
foxts:
specifier: ^5.2.1
version: 5.2.1
i18next:
specifier: ^25.8.0
version: 25.8.0(typescript@5.9.3)
@ -2649,6 +2652,9 @@ packages:
react-dom:
optional: true
foxts@5.2.1:
resolution: {integrity: sha512-EsvV1QDTp8leo7RXluZbiIc1IjO9m06G6ePeX8P8VwJmKodSviS++2AKSXUh1GBMliFl57oH8ZV2fCJWEbh2rw==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -6340,6 +6346,8 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
foxts@5.2.1: {}
fsevents@2.3.3:
optional: true

View File

@ -5,10 +5,12 @@ import {
VisibilityOutlined,
} from "@mui/icons-material";
import { Box, Button, IconButton, Skeleton, Typography } from "@mui/material";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect } from "foxact/use-abortable-effect";
import { memo, useCallback, useState, useEffectEvent, useMemo } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { useAppData } from "@/providers/app-data-context";
import { getIpInfo } from "@/services/api";
import { EnhancedCard } from "./enhanced-card";
@ -17,8 +19,7 @@ import { EnhancedCard } from "./enhanced-card";
const IP_REFRESH_SECONDS = 300;
const IP_INFO_CACHE_KEY = "cv_ip_info_cache";
// 提取InfoItem子组件并使用memo优化
const InfoItem = memo(({ label, value }: { label: string; value: string }) => (
const InfoItem = memo(({ label, value }: { label: string; value?: string }) => (
<Box sx={{ mb: 0.7, display: "flex", alignItems: "flex-start" }}>
<Typography
variant="body2"
@ -44,7 +45,7 @@ const InfoItem = memo(({ label, value }: { label: string; value: string }) => (
));
// 获取国旗表情
const getCountryFlag = (countryCode: string) => {
const getCountryFlag = (countryCode: string | undefined) => {
if (!countryCode) return "";
const codePoints = countryCode
.toUpperCase()
@ -56,149 +57,71 @@ const getCountryFlag = (countryCode: string) => {
// IP信息卡片组件
export const IpInfoCard = () => {
const { t } = useTranslation();
const { clashConfig } = useAppData();
const [ipInfo, setIpInfo] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showIp, setShowIp] = useState(false);
const appWindow = useMemo(() => getCurrentWebviewWindow(), []);
const [countdown, setCountdown] = useState(IP_REFRESH_SECONDS);
const lastFetchRef = useRef<number | null>(null);
const fetchIpInfo = useCallback(
async (force = false) => {
setError("");
const {
data: ipInfo,
error,
isLoading,
mutate,
} = useSWR(IP_INFO_CACHE_KEY, getIpInfo, {
refreshInterval: 0,
refreshWhenOffline: false,
revalidateOnFocus: true,
shouldRetryOnError: true,
});
try {
if (!force && typeof window !== "undefined" && window.sessionStorage) {
const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
const now = Date.now();
if (
parsed?.ts &&
parsed?.data &&
now - parsed.ts < IP_REFRESH_SECONDS * 1000
) {
setIpInfo(parsed.data);
lastFetchRef.current = parsed.ts;
const elapsed = Math.floor((now - parsed.ts) / 1000);
setCountdown(Math.max(IP_REFRESH_SECONDS - elapsed, 0));
setLoading(false);
return;
}
}
}
} catch (e) {
console.warn("Failed to read IP info from sessionStorage:", e);
}
// function useEffectEvent
const onCountdownTick = useEffectEvent(async () => {
const now = Date.now();
const ts = ipInfo?.lastFetchTs;
if (!ts) {
return;
}
if (typeof navigator !== "undefined" && !navigator.onLine) {
setLoading(false);
lastFetchRef.current = Date.now();
const elapsed = Math.floor((now - ts) / 1000);
const remaining = IP_REFRESH_SECONDS - elapsed;
if (remaining <= 0) {
if (navigator.onLine && (await appWindow.isVisible())) {
mutate();
setCountdown(IP_REFRESH_SECONDS);
return;
} else {
// do nothing. we even skip "setCountdown" to reduce re-renders
//
// but the remaining time still <= 0, and setInterval is not stopped, this
// callback will still be regularly triggered, as soon as the window is visible
// or network online again, we mutate() immediately in the following tick.
}
} else {
setCountdown(remaining);
}
});
if (!clashConfig) {
setLoading(false);
lastFetchRef.current = Date.now();
setCountdown(IP_REFRESH_SECONDS);
return;
}
try {
setLoading(true);
const data = await getIpInfo();
setIpInfo(data);
const ts = Date.now();
lastFetchRef.current = ts;
try {
if (typeof window !== "undefined" && window.sessionStorage) {
window.sessionStorage.setItem(
IP_INFO_CACHE_KEY,
JSON.stringify({ data, ts }),
);
}
} catch (e) {
console.warn("Failed to write IP info to sessionStorage:", e);
}
setCountdown(IP_REFRESH_SECONDS);
} catch (err) {
setError(
err instanceof Error
? err.message
: t("home.components.ipInfo.errors.load"),
);
lastFetchRef.current = Date.now();
setCountdown(IP_REFRESH_SECONDS);
} finally {
setLoading(false);
}
},
[t, clashConfig],
);
// 组件加载时获取IP信息并启动基于上次请求时间的倒计时
// Countdown / refresh scheduler — updates UI every 1s and triggers immediate revalidation when expired
useEffect(() => {
fetchIpInfo();
let timer: number | null = null;
const startCountdown = () => {
timer = window.setInterval(() => {
const now = Date.now();
let ts = lastFetchRef.current;
try {
if (!ts && typeof window !== "undefined" && window.sessionStorage) {
const raw = window.sessionStorage.getItem(IP_INFO_CACHE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
ts = parsed?.ts || null;
}
}
} catch (e) {
console.warn("Failed to read IP info from sessionStorage:", e);
ts = ts || null;
}
const elapsed = ts ? Math.floor((now - ts) / 1000) : 0;
let remaining = IP_REFRESH_SECONDS - elapsed;
if (remaining <= 0) {
fetchIpInfo();
remaining = IP_REFRESH_SECONDS;
}
// 每5秒或倒计时结束时才更新UI
if (remaining % 5 === 0 || remaining <= 0) {
setCountdown(remaining);
}
}, 1000);
};
startCountdown();
const timer: number | null = window.setInterval(onCountdownTick, 1000);
return () => {
if (timer) clearInterval(timer);
if (timer != null) clearInterval(timer);
};
}, [fetchIpInfo]);
}, []);
const toggleShowIp = useCallback(() => {
setShowIp((prev) => !prev);
}, []);
// 渲染加载状态
if (loading) {
// Loading
if (isLoading) {
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton
size="small"
onClick={() => fetchIpInfo(true)}
disabled={true}
>
<IconButton size="small" onClick={() => mutate()} disabled>
<RefreshOutlined />
</IconButton>
}
@ -213,7 +136,7 @@ export const IpInfoCard = () => {
);
}
// 渲染错误状态
// Error
if (error) {
return (
<EnhancedCard
@ -221,7 +144,7 @@ export const IpInfoCard = () => {
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => fetchIpInfo(true)}>
<IconButton size="small" onClick={() => mutate()}>
<RefreshOutlined />
</IconButton>
}
@ -237,9 +160,11 @@ export const IpInfoCard = () => {
}}
>
<Typography variant="body1" color="error">
{error}
{error instanceof Error
? error.message
: t("home.components.ipInfo.errors.load")}
</Typography>
<Button onClick={() => fetchIpInfo(true)} sx={{ mt: 2 }}>
<Button onClick={() => mutate()} sx={{ mt: 2 }}>
{t("shared.actions.retry")}
</Button>
</Box>
@ -247,14 +172,14 @@ export const IpInfoCard = () => {
);
}
// 渲染正常数据
// Normal render
return (
<EnhancedCard
title={t("home.components.ipInfo.title")}
icon={<LocationOnOutlined />}
iconColor="info"
action={
<IconButton size="small" onClick={() => fetchIpInfo(true)}>
<IconButton size="small" onClick={() => mutate()}>
<RefreshOutlined />
</IconButton>
}
@ -355,7 +280,7 @@ export const IpInfoCard = () => {
<Box sx={{ width: "60%", overflow: "auto" }}>
<InfoItem
label={t("home.components.ipInfo.labels.isp")}
value={ipInfo?.isp}
value={ipInfo?.organization}
/>
<InfoItem
label={t("home.components.ipInfo.labels.org")}
@ -396,8 +321,7 @@ export const IpInfoCard = () => {
whiteSpace: "nowrap",
}}
>
{ipInfo?.country_code}, {ipInfo?.longitude?.toFixed(2)},{" "}
{ipInfo?.latitude?.toFixed(2)}
{`${ipInfo?.country_code ?? "N/A"}, ${ipInfo?.longitude?.toFixed(2) ?? "N/A"}, ${ipInfo?.latitude?.toFixed(2) ?? "N/A"}`}
</Typography>
</Box>
</Box>

View File

@ -1,4 +1,6 @@
import { fetch } from "@tauri-apps/plugin-http";
import { asyncRetry } from "foxts/async-retry";
import { extractErrorMessage } from "foxts/extract-error-message";
import { debugLog } from "@/utils/debug";
@ -90,6 +92,38 @@ const IP_CHECK_SERVICES: ServiceConfig[] = [
timezone: data.timezone?.id || "",
}),
},
{
url: "https://ip.api.skk.moe/cf-geoip",
mapping: (data) => ({
ip: data.ip || "",
country_code: data.country || "",
country: data.country || "",
region: data.region || "",
city: data.city || "",
organization: data.asOrg || "",
asn: data.asn || 0,
asn_organization: data.asOrg || "",
longitude: data.longitude || 0,
latitude: data.latitude || 0,
timezone: data.timezone || "",
}),
},
{
url: "https://get.geojs.io/v1/ip/geo.json",
mapping: (data) => ({
ip: data.ip || "",
country_code: data.country_code || "",
country: data.country || "",
region: data.region || "",
city: data.city || "",
organization: data.organization_name || "",
asn: data.asn || 0,
asn_organization: data.organization_name || "",
longitude: Number(data.longitude) || 0,
latitude: Number(data.latitude) || 0,
timezone: data.timezone || "",
}),
},
];
// 随机性服务列表洗牌函数
@ -149,76 +183,74 @@ function createPrng(seed: number): () => number {
}
// 获取当前IP和地理位置信息
export const getIpInfo = async (): Promise<IpInfo> => {
export const getIpInfo = async (): Promise<
IpInfo & { lastFetchTs: number }
> => {
const lastFetchTs = Date.now();
// 配置参数
const maxRetries = 3;
const serviceTimeout = 5000;
const overallTimeout = 20000; // 增加总超时时间以容纳延迟
const overallTimeoutController = new AbortController();
const overallTimeoutId = setTimeout(() => {
overallTimeoutController.abort();
}, overallTimeout);
const shuffledServices = shuffleServices();
let lastError: unknown | null = null;
try {
const shuffledServices = shuffleServices();
let lastError: Error | null = null;
for (const service of shuffledServices) {
debugLog(`尝试IP检测服务: ${service.url}`);
for (const service of shuffledServices) {
debugLog(`尝试IP检测服务: ${service.url}`);
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => {
timeoutController.abort();
}, service.timeout || serviceTimeout);
for (let attempt = 0; attempt < maxRetries; attempt++) {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const timeoutController = new AbortController();
timeoutId = setTimeout(() => {
timeoutController.abort();
}, service.timeout || serviceTimeout);
try {
return await asyncRetry(
async (bail) => {
console.debug("Fetching IP information...");
const response = await fetch(service.url, {
method: "GET",
signal: timeoutController.signal,
signal: timeoutController.signal, // AbortSignal.timeout(service.timeout || serviceTimeout),
connectTimeout: service.timeout || serviceTimeout,
});
const data = await response.json();
if (!response.ok) {
return bail(
new Error(
`IP 检测服务出错,状态码: ${response.status} from ${service.url}`,
),
);
}
if (timeoutId) clearTimeout(timeoutId);
const data = await response.json();
if (data && data.ip) {
debugLog(`IP检测成功使用服务: ${service.url}`);
return service.mapping(data);
return Object.assign(service.mapping(data), { lastFetchTs });
} else {
throw new Error(`无效的响应格式 from ${service.url}`);
}
} catch (error: any) {
if (timeoutId) clearTimeout(timeoutId);
lastError = error;
console.warn(
`尝试 ${attempt + 1}/${maxRetries} 失败 (${service.url}):`,
error,
);
if (error.name === "AbortError") {
throw error;
}
if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}
},
{
retries: maxRetries,
minTimeout: 500,
maxTimeout: 2000,
randomize: true,
},
);
} catch (error) {
debugLog(`IP检测服务失败: ${service.url}`, error);
lastError = error;
} finally {
clearTimeout(timeoutId);
}
}
if (lastError) {
throw new Error(`所有IP检测服务都失败: ${lastError.message}`);
} else {
throw new Error("没有可用的IP检测服务");
}
} finally {
clearTimeout(overallTimeoutId);
if (lastError) {
throw new Error(
`所有IP检测服务都失败: ${extractErrorMessage(lastError) || "未知错误"}`,
);
} else {
throw new Error("没有可用的IP检测服务");
}
};