mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2026-04-12 23:50:31 +08:00
feat: add network info page & card with IP display, auto-merge missing sider order entries
This commit is contained in:
parent
58d9e564e5
commit
85f9e4755a
@ -80,4 +80,3 @@
|
||||
|
||||
- 使用通知系统替换 alert() 弹窗
|
||||
- 优化连接页面性能
|
||||
|
||||
|
||||
@ -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
8
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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': {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -42,7 +42,8 @@ export const defaultConfig: IAppConfig = {
|
||||
'dns',
|
||||
'sniff',
|
||||
'log',
|
||||
'substore'
|
||||
'substore',
|
||||
'network'
|
||||
],
|
||||
siderWidth: 250,
|
||||
sysProxy: { enable: false, mode: 'manual' },
|
||||
|
||||
@ -146,6 +146,8 @@ const validInvokeChannels = [
|
||||
'registerShortcut',
|
||||
// Misc
|
||||
'getGistUrl',
|
||||
'fetchIPInfo',
|
||||
'measureLatency',
|
||||
'getImageDataURL',
|
||||
'getIconDataURL',
|
||||
'getAppName',
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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')}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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">
|
||||
|
||||
98
src/renderer/src/components/sider/network-card.tsx
Normal file
98
src/renderer/src/components/sider/network-card.tsx
Normal 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
|
||||
@ -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">
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "مشاهده پیکربندی اجرا",
|
||||
|
||||
@ -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": "Просмотр текущей конфигурации",
|
||||
|
||||
@ -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": "查看运行时配置",
|
||||
|
||||
@ -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": "查看運行時配置",
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
465
src/renderer/src/pages/network.tsx
Normal file
465
src/renderer/src/pages/network.tsx
Normal 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
|
||||
@ -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 />
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: '主机名包含非法字符' }
|
||||
}
|
||||
}
|
||||
|
||||
1
src/shared/types.d.ts
vendored
1
src/shared/types.d.ts
vendored
@ -260,6 +260,7 @@ interface IAppConfig {
|
||||
overrideCardStatus?: CardStatus
|
||||
profileCardStatus?: CardStatus
|
||||
proxyCardStatus?: CardStatus
|
||||
networkCardStatus?: CardStatus
|
||||
resourceCardStatus?: CardStatus
|
||||
ruleCardStatus?: CardStatus
|
||||
sniffCardStatus?: CardStatus
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user