From 85f9e4755a55c6d24f135e1e817739f1ee0a39aa Mon Sep 17 00:00:00 2001 From: Memory <134070804+Memory2314@users.noreply.github.com> Date: Sun, 22 Mar 2026 11:35:14 +0800 Subject: [PATCH] feat: add network info page & card with IP display, auto-merge missing sider order entries --- changelog.md | 1 - package.json | 1 + pnpm-lock.yaml | 8 + src/main/resolve/tray.ts | 8 +- src/main/utils/ipc.ts | 18 + src/main/utils/template.ts | 3 +- src/preload/index.ts | 2 + src/renderer/src/App.tsx | 37 +- .../components/profiles/edit-rules-modal.tsx | 22 +- .../settings/local-backup-config.tsx | 6 +- .../src/components/settings/sider-config.tsx | 6 +- .../src/components/sider/conn-card.tsx | 4 +- .../src/components/sider/network-card.tsx | 98 ++++ .../src/components/updater/updater-modal.tsx | 9 +- src/renderer/src/locales/en-US.json | 23 + src/renderer/src/locales/fa-IR.json | 27 +- src/renderer/src/locales/ru-RU.json | 27 +- src/renderer/src/locales/zh-CN.json | 23 + src/renderer/src/locales/zh-TW.json | 23 + src/renderer/src/main.tsx | 1 + src/renderer/src/pages/mihomo.tsx | 17 +- src/renderer/src/pages/network.tsx | 465 ++++++++++++++++++ src/renderer/src/routes/index.tsx | 5 + src/renderer/src/utils/ipc.ts | 4 + src/renderer/src/utils/validate.ts | 24 +- src/shared/types.d.ts | 1 + 26 files changed, 814 insertions(+), 49 deletions(-) create mode 100644 src/renderer/src/components/sider/network-card.tsx create mode 100644 src/renderer/src/pages/network.tsx diff --git a/changelog.md b/changelog.md index 1af8256..e816685 100644 --- a/changelog.md +++ b/changelog.md @@ -80,4 +80,3 @@ - 使用通知系统替换 alert() 弹窗 - 优化连接页面性能 - diff --git a/package.json b/package.json index 3dbbda7..4ef4a97 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "express": "^5.2.1", "file-icon": "^6.0.0", "file-icon-info": "^1.1.1", + "flag-icons": "^7.5.0", "i18next": "^25.8.13", "iconv-lite": "^0.7.2", "js-yaml": "^4.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 59b7b55..c1dbc64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: file-icon-info: specifier: ^1.1.1 version: 1.1.1 + flag-icons: + specifier: ^7.5.0 + version: 7.5.0 i18next: specifier: ^25.8.13 version: 25.8.13(typescript@5.9.3) @@ -3332,6 +3335,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flag-icons@7.5.0: + resolution: {integrity: sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==} + flat-cache@4.0.1: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} @@ -9597,6 +9603,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flag-icons@7.5.0: {} + flat-cache@4.0.1: dependencies: flatted: 3.3.3 diff --git a/src/main/resolve/tray.ts b/src/main/resolve/tray.ts index dddf9a1..d045b94 100644 --- a/src/main/resolve/tray.ts +++ b/src/main/resolve/tray.ts @@ -476,15 +476,11 @@ export async function copyEnv( break } case 'cmd': { - clipboard.writeText( - `set http_proxy=${proxyUrl}\r\nset https_proxy=${proxyUrl}` - ) + clipboard.writeText(`set http_proxy=${proxyUrl}\r\nset https_proxy=${proxyUrl}`) break } case 'powershell': { - clipboard.writeText( - `$env:HTTP_PROXY="${proxyUrl}"; $env:HTTPS_PROXY="${proxyUrl}"` - ) + clipboard.writeText(`$env:HTTP_PROXY="${proxyUrl}"; $env:HTTPS_PROXY="${proxyUrl}"`) break } case 'fish': { diff --git a/src/main/utils/ipc.ts b/src/main/utils/ipc.ts index b8d538c..bb8846e 100644 --- a/src/main/utils/ipc.ts +++ b/src/main/utils/ipc.ts @@ -123,6 +123,7 @@ import { startMonitor } from '../resolve/trafficMonitor' import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow' import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater' import { getImageDataURL } from './image' +import { get as httpGet } from './chromeRequest' import { getIconDataURL } from './icon' import { getAppName } from './appName' import { logDir, rulePath } from './dirs' @@ -190,6 +191,21 @@ async function getSmartOverrideContent(): Promise { } } +async function fetchIPInfo(url: string): Promise { + const res = await httpGet(url, { timeout: 10000, responseType: 'json' }) + return res.data +} + +async function measureLatency(url: string): Promise { + try { + const t0 = Date.now() + await httpGet(url, { timeout: 5000, responseType: 'text' }) + return Date.now() - t0 + } catch { + return null + } +} + async function changeLanguage(lng: string): Promise { await i18next.changeLanguage(lng) ipcMain.emit('updateTrayMenu') @@ -324,6 +340,8 @@ const asyncHandlers: Record = { showContextMenu, // Misc getGistUrl, + fetchIPInfo, + measureLatency, getImageDataURL, getIconDataURL, getAppName, diff --git a/src/main/utils/template.ts b/src/main/utils/template.ts index f9f404c..077890d 100644 --- a/src/main/utils/template.ts +++ b/src/main/utils/template.ts @@ -42,7 +42,8 @@ export const defaultConfig: IAppConfig = { 'dns', 'sniff', 'log', - 'substore' + 'substore', + 'network' ], siderWidth: 250, sysProxy: { enable: false, mode: 'manual' }, diff --git a/src/preload/index.ts b/src/preload/index.ts index d005a85..523c9da 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -146,6 +146,8 @@ const validInvokeChannels = [ 'registerShortcut', // Misc 'getGistUrl', + 'fetchIPInfo', + 'measureLatency', 'getImageDataURL', 'getIconDataURL', 'getAppName', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 30a59e1..7883769 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -32,6 +32,7 @@ import { applyTheme, setNativeTheme, setTitleBarOverlay } from '@renderer/utils/ import { platform } from '@renderer/utils/init' import { TitleBarOverlayOptions } from 'electron' import SubStoreCard from '@renderer/components/sider/substore-card' +import NetworkCard from '@renderer/components/sider/network-card' import { createTourDriver, getDriver, startTourIfNeeded } from '@renderer/utils/tour' import 'driver.js/dist/driver.css' import { useTranslation } from 'react-i18next' @@ -41,6 +42,29 @@ let navigate: NavigateFunction export { getDriver } +const ALL_SIDER_KEYS = [ + 'sysproxy', + 'tun', + 'profile', + 'proxy', + 'rule', + 'resource', + 'override', + 'connection', + 'mihomo', + 'dns', + 'sniff', + 'log', + 'substore', + 'network' +] + +function mergeSiderOrder(saved: string[]): string[] { + const valid = saved.filter((k) => ALL_SIDER_KEYS.includes(k)) + const missing = ALL_SIDER_KEYS.filter((k) => !valid.includes(k)) + return [...valid, ...missing] +} + const App: React.FC = () => { const { t } = useTranslation() const { appConfig, patchAppConfig } = useAppConfig() @@ -62,11 +86,12 @@ const App: React.FC = () => { 'dns', 'sniff', 'log', - 'substore' + 'substore', + 'network' ] } = appConfig || {} const narrowWidth = platform === 'darwin' ? 70 : 60 - const [order, setOrder] = useState(siderOrder) + const [order, setOrder] = useState(mergeSiderOrder(siderOrder)) const [siderWidthValue, setSiderWidthValue] = useState(siderWidth) const siderWidthValueRef = useRef(siderWidthValue) const [resizing, setResizing] = useState(false) @@ -92,7 +117,7 @@ const App: React.FC = () => { }, [useWindowFrame]) useEffect(() => { - setOrder(siderOrder) + setOrder(mergeSiderOrder(siderOrder)) setSiderWidthValue(siderWidth) }, [siderOrder, siderWidth]) @@ -163,7 +188,8 @@ const App: React.FC = () => { rule: 'rules', resource: 'resources', override: 'override', - substore: 'substore' + substore: 'substore', + network: 'network' } const componentMap = { @@ -179,7 +205,8 @@ const App: React.FC = () => { rule: RuleCard, resource: ResourceCard, override: OverrideCard, - substore: SubStoreCard + substore: SubStoreCard, + network: NetworkCard } return ( diff --git a/src/renderer/src/components/profiles/edit-rules-modal.tsx b/src/renderer/src/components/profiles/edit-rules-modal.tsx index 832c570..f1236e1 100644 --- a/src/renderer/src/components/profiles/edit-rules-modal.tsx +++ b/src/renderer/src/components/profiles/edit-rules-modal.tsx @@ -1226,17 +1226,17 @@ const EditRulesModal: React.FC = (props) => { {type} ))} - - setNewRule({ ...newRule, payload: value })} - isDisabled={newRule.type === 'MATCH'} - className={`${newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`} - /> + + setNewRule({ ...newRule, payload: value })} + isDisabled={newRule.type === 'MATCH'} + className={`${newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`} + /> { } const handleImport = async (): Promise => { - onClose(); + onClose() setImporting(true) try { const success = await importLocalBackup() @@ -40,14 +40,14 @@ const LocalBackupConfig: React.FC = () => { window.electron.ipcRenderer.send('appConfigUpdated') window.electron.ipcRenderer.send('controledMihomoConfigUpdated') window.electron.ipcRenderer.send('profileConfigUpdated') - + try { await restartCore() } catch (error) { console.error('Failed to restart core after import:', error) toast.error(t('common.error.restartCoreFailed', { error: error })) } - + new window.Notification(t('localBackup.notification.importSuccess.title'), { body: t('localBackup.notification.importSuccess.body') }) diff --git a/src/renderer/src/components/settings/sider-config.tsx b/src/renderer/src/components/settings/sider-config.tsx index 950ce62..ebee2f8 100644 --- a/src/renderer/src/components/settings/sider-config.tsx +++ b/src/renderer/src/components/settings/sider-config.tsx @@ -18,7 +18,8 @@ const titleMap: Record = { dnsCardStatus: 'sider.cards.dns', sniffCardStatus: 'sider.cards.sniff', logCardStatus: 'sider.cards.logs', - substoreCardStatus: 'sider.cards.substore' + substoreCardStatus: 'sider.cards.substore', + networkCardStatus: 'sider.cards.network' } const sizeMap: Record = { @@ -44,7 +45,8 @@ const SiderConfig: FC = () => { dnsCardStatus: appConfig?.dnsCardStatus || 'col-span-1', sniffCardStatus: appConfig?.sniffCardStatus || 'col-span-1', logCardStatus: appConfig?.logCardStatus || 'col-span-1', - substoreCardStatus: appConfig?.substoreCardStatus || 'col-span-1' + substoreCardStatus: appConfig?.substoreCardStatus || 'col-span-1', + networkCardStatus: appConfig?.networkCardStatus || 'col-span-1' } return ( diff --git a/src/renderer/src/components/sider/conn-card.tsx b/src/renderer/src/components/sider/conn-card.tsx index f765814..5e5585d 100644 --- a/src/renderer/src/components/sider/conn-card.tsx +++ b/src/renderer/src/components/sider/conn-card.tsx @@ -214,7 +214,7 @@ const ConnCard: React.FC = (props) => { ref={setNodeRef} {...attributes} {...listeners} - className={`${match ? 'bg-primary' : 'hover:bg-primary/30'} ${disableAnimations ? '' : `motion-reduce:transition-transform-background ${isDragging ? 'scale-[0.95] tap-highlight-transparent' : ''}`}`} + className={`${match ? 'bg-primary' : 'hover:bg-primary/30'} ${disableAnimations ? '' : `motion-reduce:transition-transform-background ${isDragging ? 'scale-[0.95] tap-highlight-transparent' : ''}`}`} > {!hideConnectionCardWave && (
@@ -263,7 +263,7 @@ const ConnCard: React.FC = (props) => { ref={setNodeRef} {...attributes} {...listeners} - className={`${match ? 'bg-primary' : 'hover:bg-primary/30'} ${disableAnimations ? '' : `motion-reduce:transition-transform-background ${isDragging ? 'scale-[0.95] tap-highlight-transparent' : ''}`}`} + className={`${match ? 'bg-primary' : 'hover:bg-primary/30'} ${disableAnimations ? '' : `motion-reduce:transition-transform-background ${isDragging ? 'scale-[0.95] tap-highlight-transparent' : ''}`}`} >
diff --git a/src/renderer/src/components/sider/network-card.tsx b/src/renderer/src/components/sider/network-card.tsx new file mode 100644 index 0000000..d1b6fc2 --- /dev/null +++ b/src/renderer/src/components/sider/network-card.tsx @@ -0,0 +1,98 @@ +import { Button, Card, CardBody, CardFooter, Tooltip } from '@heroui/react' +import { IoGlobeOutline } from 'react-icons/io5' +import { useLocation, useNavigate } from 'react-router-dom' +import { useSortable } from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +import { useAppConfig } from '@renderer/hooks/use-app-config' +import React from 'react' +import { useTranslation } from 'react-i18next' + +interface Props { + iconOnly?: boolean +} + +const IPCard: React.FC = (props) => { + const { t } = useTranslation() + const { appConfig } = useAppConfig() + const { iconOnly } = props + const { networkCardStatus = 'col-span-1', disableAnimations = false } = appConfig || {} + const location = useLocation() + const navigate = useNavigate() + const match = location.pathname.includes('/network') + const { + attributes, + listeners, + setNodeRef, + transform: tf, + transition, + isDragging + } = useSortable({ + id: 'network' + }) + const transform = tf ? { x: tf.x, y: tf.y, scaleX: 1, scaleY: 1 } : null + + if (iconOnly) { + return ( +
+ + + +
+ ) + } + + return ( +
+ + +
+ +
+
+ +

+ {t('sider.cards.ip')} +

+
+
+
+ ) +} + +export default IPCard diff --git a/src/renderer/src/components/updater/updater-modal.tsx b/src/renderer/src/components/updater/updater-modal.tsx index 006f741..aa95eef 100644 --- a/src/renderer/src/components/updater/updater-modal.tsx +++ b/src/renderer/src/components/updater/updater-modal.tsx @@ -22,7 +22,10 @@ interface Props { const UpdaterModal: React.FC = (props) => { const { version, changelog, onClose } = props const [downloading, setDownloading] = useState(false) - const [progress, setProgress] = useState<{ status: 'downloading' | 'verifying'; percent?: number } | null>(null) + const [progress, setProgress] = useState<{ + status: 'downloading' | 'verifying' + percent?: number + } | null>(null) const { t } = useTranslation() useEffect(() => { @@ -78,7 +81,9 @@ const UpdaterModal: React.FC = (props) => {

diff --git a/src/renderer/src/locales/en-US.json b/src/renderer/src/locales/en-US.json index dfdb97f..712acd5 100644 --- a/src/renderer/src/locales/en-US.json +++ b/src/renderer/src/locales/en-US.json @@ -309,6 +309,29 @@ "sider.cards.sniff": "Sniff OVRD", "sider.cards.logs": "Logs", "sider.cards.substore": "Sub-Store", + "sider.cards.network": "Network Info", + "network.title": "Network Info", + "network.ipCard.title": "Current IP", + "network.ipAddress": "IP Address", + "network.country": "Country", + "network.city": "City", + "network.region": "Region", + "network.organization": "Organization", + "network.timezone": "Timezone", + "network.proxyDetection": "Proxy Detection", + "network.clean": "Clean", + "network.noData": "No data", + "network.fetchFailed": "Failed to fetch IP info", + "network.location": "Location", + "network.network": "Network", + "network.security": "Security", + "network.coordinates": "Coordinates", + "network.copy": "Copy IP", + "network.copied": "Copied", + "network.dataSource": "Data Source", + "network.latency.title": "Network Latency", + "network.latency.average": "Avg", + "network.latency.timeout": "Timeout", "sider.cards.config": "Runtime Config", "sider.cards.emptyProfile": "Empty Profile", "sider.cards.viewRuntimeConfig": "View Runtime Config", diff --git a/src/renderer/src/locales/fa-IR.json b/src/renderer/src/locales/fa-IR.json index 4fdec21..7e4aaac 100644 --- a/src/renderer/src/locales/fa-IR.json +++ b/src/renderer/src/locales/fa-IR.json @@ -255,8 +255,8 @@ "localBackup.import.button": "وارد کردن پشتیبان", "localBackup.import.confirm.title": "تایید وارد کردن", "localBackup.import.confirm.body": "وارد کردن پشتیبان محلی، تمام پیکربندی‌های فعلی را بازنویسی می‌کند. آیا مطمئن هستید که می‌خواهید ادامه دهید؟", - "localBackup.notification.exportSuccess.title": "صادر کردن موفق", - "localBackup.notification.exportSuccess.body": "پشتیبان محلی در مکان مشخص شده صادر شد", + "localBackup.notification.exportSuccess.title": "صادر کردن م��فق", + "localBackup.notification.exportSuccess.body": "پشتیبان محلی در مکان ��شخص شده صادر شد", "localBackup.notification.importSuccess.title": "وارد کردن موفق", "localBackup.notification.importSuccess.body": "پشتیبان محلی با موفقیت وارد شد", "shortcuts.title": "میانبرهای صفحه کلید", @@ -284,6 +284,29 @@ "sider.cards.sniff": "لغو بو کشیدن", "sider.cards.logs": "گزارش‌ها", "sider.cards.substore": "ساب استور", + "sider.cards.network": "اطلاعات شبکه", + "network.title": "اطلاعات شبکه", + "network.ipCard.title": "IP فعلی", + "network.ipAddress": "آدرس IP", + "network.country": "کشور", + "network.city": "شهر", + "network.region": "منطقه", + "network.organization": "سازمان", + "network.timezone": "منطقه زمانی", + "network.proxyDetection": "تشخیص پروکسی", + "network.clean": "پاک", + "network.noData": "داده‌ای وجود ندارد", + "network.fetchFailed": "دریافت اطلاعات IP ناموفق بود", + "network.location": "موقعیت", + "network.network": "شبکه", + "network.security": "امنیت", + "network.coordinates": "مختصات", + "network.copy": "کپی IP", + "network.copied": "کپی شد", + "network.dataSource": "منبع داده", + "network.latency.title": "تاخیر شبکه", + "network.latency.average": "میانگین", + "network.latency.timeout": "وقفه", "sider.cards.config": "پیکربندی اجرا", "sider.cards.emptyProfile": "پروفایل خالی", "sider.cards.viewRuntimeConfig": "مشاهده پیکربندی اجرا", diff --git a/src/renderer/src/locales/ru-RU.json b/src/renderer/src/locales/ru-RU.json index 8ce64a1..239ce0b 100644 --- a/src/renderer/src/locales/ru-RU.json +++ b/src/renderer/src/locales/ru-RU.json @@ -172,8 +172,8 @@ "mihomo.smartCoreStrategy": "Режим стратегии", "mihomo.smartCoreStrategyStickySession": "Липкие сессии", "mihomo.smartCoreStrategyRoundRobin": "Круговой опрос", - "mihomo.smartCoreUseLightGBMTooltip": "Использовать предварительно обученную универсальную модель для быстрого улучшения выбора узлов, но может не подходить для вашей специфической сетевой среды", - "mihomo.smartCoreCollectDataTooltip": "Собирать данные о вашем сетевом использовании для обучения пользовательских моделей, более подходящих для вашей сетевой среды (отключите, если не знаете, как обучать модели)", + "mihomo.smartCoreUseLightGBMTooltip": "Использовать предварительно обученную универсальную модель для быстрого улучшения выбора узлов, но может не подходить для вашей специфи��еско�� сетевой среды", + "mihomo.smartCoreCollectDataTooltip": "Собирать данны�� о вашем сетевом использовании для обучения пользовательских моделей, более подходящих для вашей сетевой среды (отключите, если не знаете, как обучать модели)", "mihomo.mixedPort": "Смешанный порт", "mihomo.confirm": "Подтвердить", "mihomo.socksPort": "Порт Socks", @@ -286,6 +286,29 @@ "sider.cards.sniff": "переопределение сниффинга", "sider.cards.logs": "Журналы", "sider.cards.substore": "Sub-Store", + "sider.cards.network": "Сетевая информация", + "network.title": "Сетевая информация", + "network.ipCard.title": "Текущий IP", + "network.ipAddress": "IP-адрес", + "network.country": "Страна", + "network.city": "Город", + "network.region": "Регион", + "network.organization": "Организация", + "network.timezone": "Часовой пояс", + "network.proxyDetection": "Обнаружение прокси", + "network.clean": "Чисто", + "network.noData": "Нет данных", + "network.fetchFailed": "Не удалось получить информацию об IP", + "network.location": "Местоположение", + "network.network": "Сеть", + "network.security": "Безопасность", + "network.coordinates": "Координаты", + "network.copy": "Копировать IP", + "network.copied": "Скопировано", + "network.dataSource": "Источник данных", + "network.latency.title": "Задержка сети", + "network.latency.average": "Среднее", + "network.latency.timeout": "Таймаут", "sider.cards.config": "Конфигурация", "sider.cards.emptyProfile": "Пустой профиль", "sider.cards.viewRuntimeConfig": "Просмотр текущей конфигурации", diff --git a/src/renderer/src/locales/zh-CN.json b/src/renderer/src/locales/zh-CN.json index 43f45c7..d6bafa4 100644 --- a/src/renderer/src/locales/zh-CN.json +++ b/src/renderer/src/locales/zh-CN.json @@ -309,6 +309,29 @@ "sider.cards.sniff": "嗅探覆写", "sider.cards.logs": "日志", "sider.cards.substore": "Sub-Store", + "sider.cards.network": "网络信息", + "network.title": "网络信息", + "network.ipCard.title": "当前 IP", + "network.ipAddress": "IP 地址", + "network.country": "国家/地区", + "network.city": "城市", + "network.region": "地区", + "network.organization": "组织", + "network.timezone": "时区", + "network.proxyDetection": "代理检测", + "network.clean": "纯净", + "network.noData": "暂无数据", + "network.fetchFailed": "获取 IP 信息失败", + "network.location": "位置信息", + "network.network": "网络信息", + "network.security": "安全检测", + "network.coordinates": "坐标", + "network.copy": "复制 IP", + "network.copied": "已复制", + "network.dataSource": "数据来源", + "network.latency.title": "网络延迟", + "network.latency.average": "平均", + "network.latency.timeout": "超时", "sider.cards.config": "运行时配置", "sider.cards.emptyProfile": "空白配置", "sider.cards.viewRuntimeConfig": "查看运行时配置", diff --git a/src/renderer/src/locales/zh-TW.json b/src/renderer/src/locales/zh-TW.json index e29733a..8a49b9a 100644 --- a/src/renderer/src/locales/zh-TW.json +++ b/src/renderer/src/locales/zh-TW.json @@ -309,6 +309,29 @@ "sider.cards.sniff": "嗅探覆寫", "sider.cards.logs": "日誌", "sider.cards.substore": "Sub-Store", + "sider.cards.network": "網路資訊", + "network.title": "網路資訊", + "network.ipCard.title": "目前 IP", + "network.ipAddress": "IP 位址", + "network.country": "國家/地區", + "network.city": "城市", + "network.region": "地區", + "network.organization": "組織", + "network.timezone": "時區", + "network.proxyDetection": "代理偵測", + "network.clean": "純淨", + "network.noData": "暫無資料", + "network.fetchFailed": "取得 IP 資訊失敗", + "network.location": "位置資訊", + "network.network": "網路資訊", + "network.security": "安全偵測", + "network.coordinates": "座標", + "network.copy": "複製 IP", + "network.copied": "已複製", + "network.dataSource": "資料來源", + "network.latency.title": "網路延遲", + "network.latency.average": "平均", + "network.latency.timeout": "逾時", "sider.cards.config": "運行時配置", "sider.cards.emptyProfile": "空白配置", "sider.cards.viewRuntimeConfig": "查看運行時配置", diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index 2f83753..c663416 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -5,6 +5,7 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes' import { HeroUIProvider } from '@heroui/react' import { init, platform } from '@renderer/utils/init' import '@renderer/assets/main.css' +import 'flag-icons/css/flag-icons.min.css' import App from '@renderer/App' import BaseErrorBoundary from './components/base/base-error-boundary' import { openDevTools, quitApp } from './utils/ipc' diff --git a/src/renderer/src/pages/mihomo.tsx b/src/renderer/src/pages/mihomo.tsx index 88b7f41..a8b9391 100644 --- a/src/renderer/src/pages/mihomo.tsx +++ b/src/renderer/src/pages/mihomo.tsx @@ -1033,7 +1033,9 @@ const Mihomo: React.FC = () => { onValueChange={(v) => { setExternalControllerInput(v) const result = isValidListenAddress(v) - setExternalControllerError(isValid(result) ? null : (getError(result) ?? '格式错误')) + setExternalControllerError( + isValid(result) ? null : (getError(result) ?? '格式错误') + ) }} /> @@ -1048,11 +1050,12 @@ const Mihomo: React.FC = () => { title={t('common.generateSecret')} variant="light" onPress={() => { - const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - const randomSecret = Array.from({ length: 8 }, () => - chars[Math.floor(Math.random() * chars.length)] - ).join(''); - setSecretInput(randomSecret); + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + const randomSecret = Array.from( + { length: 8 }, + () => chars[Math.floor(Math.random() * chars.length)] + ).join('') + setSecretInput(randomSecret) }} > @@ -1085,7 +1088,7 @@ const Mihomo: React.FC = () => { startContent={ +

+
+ + {/* 加载中 */} + {loading && !ipInfo && ( +
+ +
+ )} + + {/* 错误 */} + {error && ( +
+ {error} +
+ )} + + {/* IP 信息 */} + {ipInfo && ( +
+ {/* IP 地址高亮行(负 margin 贴边) */} +
+ {t('network.ipAddress')} +
+ + {hidden ? '••••••••••••••' : ipInfo.ip} + + + + + +
+
+ + {ipInfo.country && ( + + + {ipInfo.country} + + } + /> + )} + {ipInfo.region && } + {ipInfo.city && } + {ipInfo.timezone && } + {ipInfo.latitude != null && ipInfo.longitude != null && ( + + )} + {ipInfo.asn != null && } + {ipInfo.org && } + {ipInfo.isp && } + + {(ipInfo.isProxy !== undefined || ipInfo.isVPN !== undefined) && ( +
+ + {t('network.proxyDetection')} + +
+ {ipInfo.isProxy && ( + + Proxy + + )} + {ipInfo.isVPN && ( + + VPN + + )} + {!ipInfo.isProxy && !ipInfo.isVPN && ( + + {t('network.clean')} + + )} +
+
+ )} +
+ )} + + {!loading && !error && !ipInfo && ( +
{t('network.noData')}
+ )} +
+ + {/* 网络延迟卡片 */} +
+
+
+
+ +
+

{t('network.latency.title')}

+
+
+ {averageLatency !== null && ( + + {t('network.latency.average')}: {averageLatency}ms + + )} + +
+
+ +
+ {LATENCY_TARGETS.map((target) => { + const res = latencyResults[target.url] + return ( +
+ + {target.name} + +
+
+
+ + {!res || res.status === 'idle' ? ( + - + ) : res.status === 'pending' ? ( + + ) : res.status === 'success' ? ( + {res.latency}ms + ) : ( + {t('network.latency.timeout')} + )} + +
+ ) + })} +
+
+
+ + ) +} + +export default IPPage diff --git a/src/renderer/src/routes/index.tsx b/src/renderer/src/routes/index.tsx index 540b5da..5b55a60 100644 --- a/src/renderer/src/routes/index.tsx +++ b/src/renderer/src/routes/index.tsx @@ -1,4 +1,5 @@ import { Navigate } from 'react-router-dom' +import NetworkPage from '@renderer/pages/network' import Override from '@renderer/pages/override' import Proxies from '@renderer/pages/proxies' import Rules from '@renderer/pages/rules' @@ -14,6 +15,10 @@ import DNS from '@renderer/pages/dns' import Sniffer from '@renderer/pages/sniffer' import SubStore from '@renderer/pages/substore' const routes = [ + { + path: '/network', + element: + }, { path: '/mihomo', element: diff --git a/src/renderer/src/utils/ipc.ts b/src/renderer/src/utils/ipc.ts index a4a6956..8a13ea0 100644 --- a/src/renderer/src/utils/ipc.ts +++ b/src/renderer/src/utils/ipc.ts @@ -154,6 +154,8 @@ interface IpcApi { registerShortcut: (oldShortcut: string, newShortcut: string, action: string) => Promise // Misc getGistUrl: () => Promise + fetchIPInfo: (url: string) => Promise + measureLatency: (url: string) => Promise getImageDataURL: (url: string) => Promise relaunchApp: () => Promise quitApp: () => Promise @@ -306,6 +308,8 @@ export const { registerShortcut, // Misc getGistUrl, + fetchIPInfo, + measureLatency, getImageDataURL, relaunchApp, quitApp diff --git a/src/renderer/src/utils/validate.ts b/src/renderer/src/utils/validate.ts index b121282..e2f329e 100644 --- a/src/renderer/src/utils/validate.ts +++ b/src/renderer/src/utils/validate.ts @@ -17,7 +17,13 @@ const domainSuffixValidator = (value: string): boolean => { const domainKeywordValidator = (value: string): boolean => { // 域名关键字不能包含逗号和空格 - return value.length > 0 && validator.isWhitelisted(value, 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._') + return ( + value.length > 0 && + validator.isWhitelisted( + value, + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._' + ) + ) } const domainRegexValidator = (value: string): boolean => { @@ -179,7 +185,9 @@ const inUserValidator = (value: string): boolean => { if (value.length === 0) return false // 支持多个用户名(用 / 分隔) const users = value.split('/') - return users.every((user) => user.length > 0 && validator.isAlphanumeric(user, 'en-US', { ignore: '-_.' })) + return users.every( + (user) => user.length > 0 && validator.isAlphanumeric(user, 'en-US', { ignore: '-_.' }) + ) } // IN-NAME 验证器 - 入站名称验证 @@ -322,7 +330,10 @@ export const isValidListenAddress = (s: string | undefined): ValidationResult => } // 域名或主机名 (使用宽松的 FQDN 验证) - if (validator.isFQDN(host, { require_tld: false }) || validator.isAlphanumeric(host, 'en-US', { ignore: '-.' })) { + if ( + validator.isFQDN(host, { require_tld: false }) || + validator.isAlphanumeric(host, 'en-US', { ignore: '-.' }) + ) { return { ok: true } } @@ -367,9 +378,12 @@ export const isValidListenAddressFull = (s: string | undefined): ValidationResul } // 域名或主机名 (使用宽松的 FQDN 验证) - if (validator.isFQDN(host, { require_tld: false }) || validator.isAlphanumeric(host, 'en-US', { ignore: '-.' })) { + if ( + validator.isFQDN(host, { require_tld: false }) || + validator.isAlphanumeric(host, 'en-US', { ignore: '-.' }) + ) { return { ok: true } } return { ok: false, error: '主机名包含非法字符' } -} \ No newline at end of file +} diff --git a/src/shared/types.d.ts b/src/shared/types.d.ts index e1023cb..62edb93 100644 --- a/src/shared/types.d.ts +++ b/src/shared/types.d.ts @@ -260,6 +260,7 @@ interface IAppConfig { overrideCardStatus?: CardStatus profileCardStatus?: CardStatus proxyCardStatus?: CardStatus + networkCardStatus?: CardStatus resourceCardStatus?: CardStatus ruleCardStatus?: CardStatus sniffCardStatus?: CardStatus