feat: add network info page & card with IP display, auto-merge missing sider order entries

This commit is contained in:
Memory 2026-03-22 11:35:14 +08:00 committed by GitHub
parent 58d9e564e5
commit 85f9e4755a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 814 additions and 49 deletions

View File

@ -80,4 +80,3 @@
- 使用通知系统替换 alert() 弹窗
- 优化连接页面性能

View File

@ -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",

8
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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': {

View File

@ -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<string | null> {
}
}
async function fetchIPInfo(url: string): Promise<unknown> {
const res = await httpGet<unknown>(url, { timeout: 10000, responseType: 'json' })
return res.data
}
async function measureLatency(url: string): Promise<number | null> {
try {
const t0 = Date.now()
await httpGet<unknown>(url, { timeout: 5000, responseType: 'text' })
return Date.now() - t0
} catch {
return null
}
}
async function changeLanguage(lng: string): Promise<void> {
await i18next.changeLanguage(lng)
ipcMain.emit('updateTrayMenu')
@ -324,6 +340,8 @@ const asyncHandlers: Record<string, AsyncFn> = {
showContextMenu,
// Misc
getGistUrl,
fetchIPInfo,
measureLatency,
getImageDataURL,
getIconDataURL,
getAppName,

View File

@ -42,7 +42,8 @@ export const defaultConfig: IAppConfig = {
'dns',
'sniff',
'log',
'substore'
'substore',
'network'
],
siderWidth: 250,
sysProxy: { enable: false, mode: 'manual' },

View File

@ -146,6 +146,8 @@ const validInvokeChannels = [
'registerShortcut',
// Misc
'getGistUrl',
'fetchIPInfo',
'measureLatency',
'getImageDataURL',
'getIconDataURL',
'getAppName',

View File

@ -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 (

View File

@ -1226,17 +1226,17 @@ const EditRulesModal: React.FC<Props> = (props) => {
<SelectItem key={type}>{type}</SelectItem>
))}
</Select>
<Input
label={t('profiles.editRules.payload')}
placeholder={
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
}
value={newRule.payload}
onValueChange={(value) => 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' : ''}`}
/>
<Input
label={t('profiles.editRules.payload')}
placeholder={
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
}
value={newRule.payload}
onValueChange={(value) => 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' : ''}`}
/>
<Autocomplete
label={t('profiles.editRules.proxy')}

View File

@ -30,7 +30,7 @@ const LocalBackupConfig: React.FC = () => {
}
const handleImport = async (): Promise<void> => {
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')
})

View File

@ -18,7 +18,8 @@ const titleMap: Record<string, string> = {
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<string, string> = {
@ -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 (

View File

@ -214,7 +214,7 @@ const ConnCard: React.FC<Props> = (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 && (
<div className="w-full h-full absolute top-0 left-0 pointer-events-none overflow-hidden rounded-[14px]">
@ -263,7 +263,7 @@ const ConnCard: React.FC<Props> = (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' : ''}`}`}
>
<CardBody className="pb-1 pt-0 px-0">
<div className="flex justify-between">

View File

@ -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> = (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 (
<div className={`${networkCardStatus} flex justify-center`}>
<Tooltip content={t('sider.cards.ip')} placement="right">
<Button
size="sm"
isIconOnly
color={match ? 'primary' : 'default'}
variant={match ? 'solid' : 'light'}
onPress={() => {
navigate('/network')
}}
>
<IoGlobeOutline className="text-[20px]" />
</Button>
</Tooltip>
</div>
)
}
return (
<div
style={{
position: 'relative',
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 'calc(infinity)' : undefined
}}
className={networkCardStatus}
>
<Card
fullWidth
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' : ''}`}`}
>
<CardBody className="pb-1 pt-0 px-0">
<div className="flex justify-between">
<Button
isIconOnly
className="bg-transparent pointer-events-none"
variant="flat"
color="default"
>
<IoGlobeOutline
color="default"
className={`${match ? 'text-primary-foreground' : 'text-foreground'} text-[24px] font-bold`}
/>
</Button>
</div>
</CardBody>
<CardFooter className="pt-1">
<h3
className={`text-md font-bold text-ellipsis whitespace-nowrap overflow-hidden ${match ? 'text-primary-foreground' : 'text-foreground'}`}
>
{t('sider.cards.ip')}
</h3>
</CardFooter>
</Card>
</div>
)
}
export default IPCard

View File

@ -22,7 +22,10 @@ interface Props {
const UpdaterModal: React.FC<Props> = (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> = (props) => {
<div className="w-full bg-default-200 rounded-full h-1.5">
<div
className="bg-primary h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress.status === 'verifying' ? 100 : (progress.percent ?? 0)}%` }}
style={{
width: `${progress.status === 'verifying' ? 100 : (progress.percent ?? 0)}%`
}}
/>
</div>
<p className="text-xs text-foreground-400 text-center">

View File

@ -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",

View File

@ -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": "صادر کردن م<EFBFBD><EFBFBD>فق",
"localBackup.notification.exportSuccess.body": "پشتیبان محلی در مکان <EFBFBD><EFBFBD>شخص شده صادر شد",
"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": "مشاهده پیکربندی اجرا",

View File

@ -172,8 +172,8 @@
"mihomo.smartCoreStrategy": "Режим стратегии",
"mihomo.smartCoreStrategyStickySession": "Липкие сессии",
"mihomo.smartCoreStrategyRoundRobin": "Круговой опрос",
"mihomo.smartCoreUseLightGBMTooltip": "Использовать предварительно обученную универсальную модель для быстрого улучшения выбора узлов, но может не подходить для вашей специфической сетевой среды",
"mihomo.smartCoreCollectDataTooltip": "Собирать данные о вашем сетевом использовании для обучения пользовательских моделей, более подходящих для вашей сетевой среды (отключите, если не знаете, как обучать модели)",
"mihomo.smartCoreUseLightGBMTooltip": "Использовать предварительно обученную универсальную модель для быстрого улучшения выбора узлов, но может не подходить для вашей специфи<EFBFBD><EFBFBD>еско<EFBFBD><EFBFBD> сетевой среды",
"mihomo.smartCoreCollectDataTooltip": "Собирать данны<EFBFBD><EFBFBD> о вашем сетевом использовании для обучения пользовательских моделей, более подходящих для вашей сетевой среды (отключите, если не знаете, как обучать модели)",
"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": "Просмотр текущей конфигурации",

View File

@ -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": "查看运行时配置",

View File

@ -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": "查看運行時配置",

View File

@ -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'

View File

@ -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) ?? '格式错误')
)
}}
/>
</Tooltip>
@ -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)
}}
>
<IoMdRefresh className="text-lg" />
@ -1085,7 +1088,7 @@ const Mihomo: React.FC = () => {
startContent={
<button
type="button"
onClick={() => setIsSecretVisible(prev => !prev)}
onClick={() => setIsSecretVisible((prev) => !prev)}
className="text-gray-500 hover:text-gray-700"
>
{isSecretVisible ? (

View File

@ -0,0 +1,465 @@
import BasePage from '@renderer/components/base/base-page'
import React, { useState, useEffect, useCallback } from 'react'
import { Button, Select, SelectItem, Chip, Tooltip } from '@heroui/react'
import {
IoRefresh,
IoCopyOutline,
IoCheckmark,
IoEyeOutline,
IoEyeOffOutline
} from 'react-icons/io5'
import { IoMdGlobe, IoMdPulse } from 'react-icons/io'
import { useTranslation } from 'react-i18next'
import { fetchIPInfo, measureLatency } from '@renderer/utils/ipc'
type IPProvider = 'ip.sb' | 'ipwho.is' | 'ipapi.is'
interface IPInfo {
ip: string
country?: string
countryCode?: string
city?: string
region?: string
asn?: number
org?: string
isp?: string
isProxy?: boolean
isVPN?: boolean
timezone?: string
latitude?: number
longitude?: number
}
const IP_ENDPOINTS: Record<IPProvider, string> = {
'ip.sb': 'https://api.ip.sb/geoip',
'ipwho.is': 'https://ipwho.is/',
'ipapi.is': 'https://api.ipapi.is/'
}
const CountryFlag: React.FC<{ code?: string; className?: string }> = ({ code, className }) => {
if (!code || code.length !== 2) return null
return (
<span
className={`fi fi-${code.toLowerCase()} rounded-sm ${className ?? ''}`}
style={{ fontSize: '1rem', lineHeight: 1 }}
/>
)
}
function parseProvider(provider: IPProvider, data: Record<string, unknown>): IPInfo {
if (provider === 'ip.sb') {
return {
ip: data.ip as string,
country: data.country as string | undefined,
countryCode: data.country_code as string | undefined,
city: data.city as string | undefined,
asn: data.asn as number | undefined,
org: data.asn_organization as string | undefined,
latitude: data.latitude as number | undefined,
longitude: data.longitude as number | undefined
}
}
if (provider === 'ipwho.is') {
const conn = data.connection as Record<string, unknown> | undefined
const tz = data.timezone as Record<string, unknown> | undefined
return {
ip: data.ip as string,
country: data.country as string | undefined,
countryCode: data.country_code as string | undefined,
city: data.city as string | undefined,
region: data.region as string | undefined,
asn: conn?.asn as number | undefined,
org: conn?.org as string | undefined,
isp: conn?.isp as string | undefined,
timezone: tz?.id as string | undefined,
latitude: data.latitude as number | undefined,
longitude: data.longitude as number | undefined
}
}
const loc = data.location as Record<string, unknown> | undefined
const asn = data.asn as Record<string, unknown> | undefined
return {
ip: data.ip as string,
country: loc?.country as string | undefined,
countryCode: loc?.country_code as string | undefined,
city: loc?.city as string | undefined,
region: loc?.state as string | undefined,
asn: asn?.asn as number | undefined,
org: asn?.org as string | undefined,
isProxy: data.is_proxy as boolean | undefined,
isVPN: data.is_vpn as boolean | undefined,
timezone: loc?.timezone as string | undefined,
latitude: loc?.latitude as number | undefined,
longitude: loc?.longitude as number | undefined
}
}
// ─── Latency ───────────────────────────────────────────────────────────────
type LatencyStatus = 'idle' | 'pending' | 'success' | 'error'
interface LatencyResult {
latency: number | null
status: LatencyStatus
}
const LATENCY_TARGETS = [
{ name: 'Google', url: 'https://www.google.com/generate_204' },
{ name: 'Cloudflare', url: 'https://www.cloudflare.com/cdn-cgi/trace' },
{ name: 'GitHub', url: 'https://github.com' }
]
function latencyColor(latency: number | null): string {
if (latency === null) return ''
if (latency < 100) return 'text-success'
if (latency < 300) return 'text-warning'
return 'text-danger'
}
function latencyBarColor(latency: number | null): string {
if (latency === null) return 'bg-foreground/20'
if (latency < 100) return 'bg-success'
if (latency < 300) return 'bg-warning'
return 'bg-danger'
}
// ─────────────────────────────────────────────────────────────────────────────
const providers: { value: IPProvider; label: string }[] = [
{ value: 'ip.sb', label: 'IP.SB' },
{ value: 'ipwho.is', label: 'ipwho.is' },
{ value: 'ipapi.is', label: 'ipapi.is' }
]
interface InfoRowProps {
label: string
value: React.ReactNode
mono?: boolean
}
const InfoRow: React.FC<InfoRowProps> = ({ label, value, mono }) => (
<div className="flex items-center justify-between gap-3">
<span className="shrink-0 text-[13px] text-foreground/60">{label}</span>
<span
className={`overflow-hidden text-right text-[13px] font-medium text-ellipsis whitespace-nowrap ${mono ? 'font-mono' : ''}`}
>
{value}
</span>
</div>
)
const IPPage: React.FC = () => {
const { t } = useTranslation()
const [provider, setProvider] = useState<IPProvider>('ip.sb')
const [ipInfo, setIpInfo] = useState<IPInfo | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [hidden, setHidden] = useState(false)
// latency state: map from url -> result
const [latencyResults, setLatencyResults] = useState<Record<string, LatencyResult>>({})
const [testingLatency, setTestingLatency] = useState(false)
const testAllLatencies = useCallback(async () => {
setTestingLatency(true)
// mark all as pending first
setLatencyResults(
Object.fromEntries(
LATENCY_TARGETS.map((t) => [t.url, { latency: null, status: 'pending' as LatencyStatus }])
)
)
await Promise.all(
LATENCY_TARGETS.map(async (target) => {
try {
const latency = await measureLatency(target.url)
setLatencyResults((prev) => ({
...prev,
[target.url]: { latency, status: latency !== null ? 'success' : 'error' }
}))
} catch {
setLatencyResults((prev) => ({
...prev,
[target.url]: { latency: null, status: 'error' }
}))
}
})
)
setTestingLatency(false)
}, [])
useEffect(() => {
testAllLatencies()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const averageLatency = (() => {
const successes = LATENCY_TARGETS.map((t) => latencyResults[t.url]).filter(
(r) => r?.status === 'success' && r.latency !== null
)
if (successes.length === 0) return null
return Math.round(successes.reduce((acc, r) => acc + (r!.latency ?? 0), 0) / successes.length)
})()
const fetchIP = useCallback(
async (p?: IPProvider) => {
const target = p ?? provider
setLoading(true)
setError(null)
try {
const data = await fetchIPInfo(IP_ENDPOINTS[target])
setIpInfo(parseProvider(target, data as Record<string, unknown>))
if (p) setProvider(p)
} catch (e) {
setError(e instanceof Error ? e.message : t('network.fetchFailed'))
setIpInfo(null)
} finally {
setLoading(false)
}
},
[provider, t]
)
useEffect(() => {
fetchIP()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleCopy = useCallback(() => {
if (!ipInfo?.ip) return
navigator.clipboard.writeText(ipInfo.ip)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [ipInfo?.ip])
return (
<BasePage title={t('network.title')}>
<div className="m-2 flex flex-col gap-4">
{/* 当前 IP 卡片 */}
<div className="rounded-xl border border-foreground/10 bg-content1 p-4 shadow-sm">
{/* 卡片 Header */}
<div className="mb-3.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
<IoMdGlobe size={18} />
</div>
<h3 className="text-[15px] font-semibold">{t('network.ipCard.title')}</h3>
</div>
<div className="flex items-center gap-1.5">
<Select
size="sm"
className="w-28"
selectedKeys={[provider]}
onSelectionChange={(keys) => {
const val = Array.from(keys)[0] as IPProvider
if (val) fetchIP(val)
}}
>
{providers.map((p) => (
<SelectItem key={p.value}>{p.label}</SelectItem>
))}
</Select>
<Button
size="sm"
isIconOnly
variant="light"
isLoading={loading}
onPress={() => fetchIP()}
className="h-7 w-7 min-w-0"
>
<IoRefresh size={16} />
</Button>
</div>
</div>
{/* 加载中 */}
{loading && !ipInfo && (
<div className="flex justify-center py-6">
<span className="h-6 w-6 animate-spin rounded-full border-2 border-foreground/10 border-t-primary" />
</div>
)}
{/* 错误 */}
{error && (
<div className="rounded-lg border border-danger/20 bg-danger/10 p-3 text-[13px] text-danger">
{error}
</div>
)}
{/* IP 信息 */}
{ipInfo && (
<div className="flex flex-col gap-2.5">
{/* IP 地址高亮行(负 margin 贴边) */}
<div className="-mx-1 -mt-1 mb-1 flex items-center justify-between gap-3 rounded-lg border border-primary/20 bg-primary/8 px-2.5 py-2">
<span className="shrink-0 text-[13px] text-foreground/60">{t('network.ipAddress')}</span>
<div className="flex items-center gap-1.5">
<span className="overflow-hidden text-right font-mono text-[13px] font-semibold text-primary text-ellipsis whitespace-nowrap">
{hidden ? '••••••••••••••' : ipInfo.ip}
</span>
<button
onClick={() => setHidden((h) => !h)}
className="shrink-0 text-primary/60 hover:text-primary transition-colors"
>
{hidden ? <IoEyeOffOutline size={14} /> : <IoEyeOutline size={14} />}
</button>
<Tooltip content={copied ? t('network.copied') : t('network.copy')}>
<button
onClick={handleCopy}
className="shrink-0 text-primary/60 hover:text-primary transition-colors"
>
{copied ? <IoCheckmark size={14} /> : <IoCopyOutline size={14} />}
</button>
</Tooltip>
</div>
</div>
{ipInfo.country && (
<InfoRow
label={t('network.country')}
value={
<span className="flex items-center justify-end gap-1.5">
<CountryFlag code={ipInfo.countryCode} />
<span>{ipInfo.country}</span>
</span>
}
/>
)}
{ipInfo.region && <InfoRow label={t('network.region')} value={ipInfo.region} />}
{ipInfo.city && <InfoRow label={t('network.city')} value={ipInfo.city} />}
{ipInfo.timezone && <InfoRow label={t('network.timezone')} value={ipInfo.timezone} />}
{ipInfo.latitude != null && ipInfo.longitude != null && (
<InfoRow
label={t('network.coordinates')}
value={`${ipInfo.latitude.toFixed(4)}, ${ipInfo.longitude.toFixed(4)}`}
mono
/>
)}
{ipInfo.asn != null && <InfoRow label="ASN" value={`AS${ipInfo.asn}`} mono />}
{ipInfo.org && <InfoRow label={t('network.organization')} value={ipInfo.org} />}
{ipInfo.isp && <InfoRow label="ISP" value={ipInfo.isp} />}
{(ipInfo.isProxy !== undefined || ipInfo.isVPN !== undefined) && (
<div className="flex items-center justify-between gap-3">
<span className="shrink-0 text-[13px] text-foreground/60">
{t('network.proxyDetection')}
</span>
<div className="flex gap-1">
{ipInfo.isProxy && (
<Chip
size="sm"
color="warning"
variant="flat"
classNames={{ content: 'text-[11px] font-semibold uppercase' }}
>
Proxy
</Chip>
)}
{ipInfo.isVPN && (
<Chip
size="sm"
color="warning"
variant="flat"
classNames={{ content: 'text-[11px] font-semibold uppercase' }}
>
VPN
</Chip>
)}
{!ipInfo.isProxy && !ipInfo.isVPN && (
<Chip
size="sm"
color="success"
variant="flat"
classNames={{ content: 'text-[11px] font-semibold uppercase' }}
>
{t('network.clean')}
</Chip>
)}
</div>
</div>
)}
</div>
)}
{!loading && !error && !ipInfo && (
<div className="py-6 text-center text-sm text-foreground/50">{t('network.noData')}</div>
)}
</div>
{/* 网络延迟卡片 */}
<div className="rounded-xl border border-foreground/10 bg-content1 p-4 shadow-sm">
<div className="mb-3.5 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/15 text-primary">
<IoMdPulse size={18} />
</div>
<h3 className="text-[15px] font-semibold">{t('network.latency.title')}</h3>
</div>
<div className="flex items-center gap-2">
{averageLatency !== null && (
<span
className={`rounded-md px-2 py-1 text-xs font-semibold ${
averageLatency < 100
? 'bg-success/15 text-success'
: averageLatency < 300
? 'bg-warning/15 text-warning'
: 'bg-danger/15 text-danger'
}`}
>
{t('network.latency.average')}: {averageLatency}ms
</span>
)}
<Button
size="sm"
isIconOnly
variant="light"
isLoading={testingLatency}
isDisabled={testingLatency}
onPress={testAllLatencies}
className="h-7 w-7 min-w-0"
>
<IoRefresh size={16} />
</Button>
</div>
</div>
<div className="flex flex-col gap-3">
{LATENCY_TARGETS.map((target) => {
const res = latencyResults[target.url]
return (
<div key={target.url} className="flex items-center gap-3">
<span className="w-20 shrink-0 overflow-hidden text-[13px] text-ellipsis whitespace-nowrap">
{target.name}
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-foreground/10">
<div
className={`h-full rounded-full transition-[width] duration-500 ease-out ${latencyBarColor(res?.latency ?? null)}`}
style={{
width:
res?.status === 'success' && res.latency !== null
? `${Math.min((res.latency / 500) * 100, 100)}%`
: '0%'
}}
/>
</div>
<span className="w-16 shrink-0 text-right font-mono text-[13px]">
{!res || res.status === 'idle' ? (
<span className="text-foreground/40">-</span>
) : res.status === 'pending' ? (
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-foreground/10 border-t-primary" />
) : res.status === 'success' ? (
<span className={latencyColor(res.latency)}>{res.latency}ms</span>
) : (
<span className="text-danger">{t('network.latency.timeout')}</span>
)}
</span>
</div>
)
})}
</div>
</div>
</div>
</BasePage>
)
}
export default IPPage

View File

@ -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: <NetworkPage />
},
{
path: '/mihomo',
element: <Mihomo />

View File

@ -154,6 +154,8 @@ interface IpcApi {
registerShortcut: (oldShortcut: string, newShortcut: string, action: string) => Promise<boolean>
// Misc
getGistUrl: () => Promise<string>
fetchIPInfo: (url: string) => Promise<unknown>
measureLatency: (url: string) => Promise<number | null>
getImageDataURL: (url: string) => Promise<string>
relaunchApp: () => Promise<void>
quitApp: () => Promise<void>
@ -306,6 +308,8 @@ export const {
registerShortcut,
// Misc
getGistUrl,
fetchIPInfo,
measureLatency,
getImageDataURL,
relaunchApp,
quitApp

View File

@ -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: '主机名包含非法字符' }
}
}

View File

@ -260,6 +260,7 @@ interface IAppConfig {
overrideCardStatus?: CardStatus
profileCardStatus?: CardStatus
proxyCardStatus?: CardStatus
networkCardStatus?: CardStatus
resourceCardStatus?: CardStatus
ruleCardStatus?: CardStatus
sniffCardStatus?: CardStatus