mirror of
https://gh.catmak.name/https://github.com/mihomo-party-org/mihomo-party
synced 2026-04-17 15:40:30 +08:00
Compare commits
5 Commits
58d9e564e5
...
c57741fcc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c57741fcc5 | ||
|
|
ff70058e8c | ||
|
|
25a3bbe557 | ||
|
|
5445a11ac8 | ||
|
|
85f9e4755a |
63
changelog.md
63
changelog.md
@ -1,3 +1,25 @@
|
|||||||
|
# 1.9.3
|
||||||
|
|
||||||
|
## 新功能 (Feat)
|
||||||
|
|
||||||
|
- 新增网络信息页卡片
|
||||||
|
- 更新下载新增进度显示和 SHA256 完整性校验
|
||||||
|
|
||||||
|
## 修复 (Fix)
|
||||||
|
|
||||||
|
- 修复更新软件时的超时问题
|
||||||
|
- 修复切换离开智能内核时未清理智能覆盖配置的问题
|
||||||
|
- 修复部分机场返回压缩包导致订阅导入失败的问题
|
||||||
|
- 修复悬浮窗无法关闭的问题
|
||||||
|
- 修复日志滚动延迟的问题
|
||||||
|
- 修复添加新订阅时只能重启生效的问题
|
||||||
|
- 修复进入代理列表页面时滚动位置未重置到顶部的问题
|
||||||
|
- 修复托盘流量图标闪烁问题
|
||||||
|
|
||||||
|
## 其他 (Chore)
|
||||||
|
|
||||||
|
- 更新依赖
|
||||||
|
|
||||||
# 1.9.2
|
# 1.9.2
|
||||||
|
|
||||||
## 新功能 (Feat)
|
## 新功能 (Feat)
|
||||||
@ -40,44 +62,3 @@
|
|||||||
- 修复 Windows 下以管理员重启开启 TUN 时因单实例锁冲突导致的闪退问题
|
- 修复 Windows 下以管理员重启开启 TUN 时因单实例锁冲突导致的闪退问题
|
||||||
- 修复托盘菜单开启 TUN 时管理员重启后继续执行导致的竞态问题
|
- 修复托盘菜单开启 TUN 时管理员重启后继续执行导致的竞态问题
|
||||||
- 修复关键资源文件复制失败时静默跳过导致内核启动异常的问题
|
- 修复关键资源文件复制失败时静默跳过导致内核启动异常的问题
|
||||||
|
|
||||||
# 1.9.0
|
|
||||||
|
|
||||||
## 新功能 (Feat)
|
|
||||||
|
|
||||||
- 支持隐藏不可用代理选项
|
|
||||||
- 支持禁用自动更新
|
|
||||||
- 支持交换任务栏点击行为
|
|
||||||
- 支持订阅导入时自动选择直连或代理
|
|
||||||
- 增加 WebDAV 证书忽略选项
|
|
||||||
- 增加 mrs ruleset 预览支持
|
|
||||||
- 增加认证令牌支持
|
|
||||||
- 增加详细错误提示并支持复制功能
|
|
||||||
- 托盘代理组样式支持子菜单模式
|
|
||||||
- 增加繁体中文(台湾)翻译
|
|
||||||
- 增加 HTML 检测和配置文件解析错误处理
|
|
||||||
- 将 sysproxy 迁移至 sysproxy-rs
|
|
||||||
|
|
||||||
## 修复 (Fix)
|
|
||||||
|
|
||||||
- 修复 Windows 旧 mihomo 进程导致的 EBUSY 错误
|
|
||||||
- 修复侧边栏卡片水平偏移
|
|
||||||
- macOS DNS 设置使用 helper 服务避免权限问题
|
|
||||||
- 修复首次启动时资源文件复制失败导致程序无法运行的问题
|
|
||||||
- 修复连接详情和日志无法选择的问题
|
|
||||||
- 修复 mixed-port 配置问题
|
|
||||||
- 空端口输入处理为 0
|
|
||||||
- 修复覆盖页面中缺失的占位符和错误处理
|
|
||||||
- 修复内核自重启时的竞态条件
|
|
||||||
- 修复端口值为 NaN 时的配置读写问题
|
|
||||||
- 修复 Smart Core 代理组名称替换精度问题
|
|
||||||
- 修复 profile/override 配置中 items 数组未定义导致的错误
|
|
||||||
- 修复 lite 模式下 geo 文件同步到 profile 工作目录
|
|
||||||
- 修复 Linux GNOME 桌面图标和启动器可见性问题
|
|
||||||
- 修复管理员重启时等待新进程启动
|
|
||||||
|
|
||||||
## 优化 (Optimize)
|
|
||||||
|
|
||||||
- 使用通知系统替换 alert() 弹窗
|
|
||||||
- 优化连接页面性能
|
|
||||||
|
|
||||||
|
|||||||
45
package.json
45
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mihomo-party",
|
"name": "mihomo-party",
|
||||||
"version": "1.9.2",
|
"version": "1.9.3",
|
||||||
"description": "Clash Party",
|
"description": "Clash Party",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
@ -36,22 +36,23 @@
|
|||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
"@types/plist": "^3.0.5",
|
"@types/plist": "^3.0.5",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.6",
|
||||||
"chokidar": "^5.0.0",
|
"chokidar": "^5.0.0",
|
||||||
"croner": "^9.1.0",
|
"croner": "^9.1.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"file-icon": "^6.0.0",
|
"file-icon": "^6.0.0",
|
||||||
"file-icon-info": "^1.1.1",
|
"file-icon-info": "^1.1.1",
|
||||||
"i18next": "^25.8.13",
|
"flag-icons": "^7.5.0",
|
||||||
|
"i18next": "^25.10.3",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"plist": "^3.1.0",
|
"plist": "^3.1.0",
|
||||||
"sysproxy-rs": "file:src\\native\\sysproxy",
|
"sysproxy-rs": "file:src\\native\\sysproxy",
|
||||||
"validator": "^13.15.26",
|
"validator": "^13.15.26",
|
||||||
"webdav": "^5.9.0",
|
"webdav": "^5.9.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.20.0",
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@ -60,23 +61,23 @@
|
|||||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||||
"@heroui/react": "^2.8.9",
|
"@heroui/react": "^2.8.10",
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.8",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.5.0",
|
||||||
"@types/pubsub-js": "^1.8.6",
|
"@types/pubsub-js": "^1.8.6",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/validator": "^13.15.10",
|
"@types/validator": "^13.15.10",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.56.1",
|
"@typescript-eslint/eslint-plugin": "^8.57.1",
|
||||||
"@typescript-eslint/parser": "^8.56.1",
|
"@typescript-eslint/parser": "^8.57.1",
|
||||||
"@vitejs/plugin-react": "^5.1.4",
|
"@vitejs/plugin-react": "^5.2.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cron-validator": "^1.4.0",
|
"cron-validator": "^1.4.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.20",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"electron": "37.10.0",
|
"electron": "37.10.0",
|
||||||
"electron-builder": "26.0.12",
|
"electron-builder": "26.0.12",
|
||||||
@ -89,26 +90,26 @@
|
|||||||
"form-data": "^4.0.5",
|
"form-data": "^4.0.5",
|
||||||
"framer-motion": "12.23.26",
|
"framer-motion": "12.23.26",
|
||||||
"lodash": "^4.17.23",
|
"lodash": "^4.17.23",
|
||||||
"meta-json-schema": "^1.19.20",
|
"meta-json-schema": "^1.19.21",
|
||||||
"monaco-yaml": "^5.4.1",
|
"monaco-yaml": "^5.4.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.7",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"pubsub-js": "^1.9.5",
|
"pubsub-js": "^1.9.5",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-error-boundary": "^6.1.1",
|
"react-error-boundary": "^6.1.1",
|
||||||
"react-i18next": "^16.5.4",
|
"react-i18next": "^16.6.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-monaco-editor": "^0.59.0",
|
"react-monaco-editor": "^0.59.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-virtuoso": "^4.18.1",
|
"react-virtuoso": "^4.18.3",
|
||||||
"swr": "^2.4.0",
|
"swr": "^2.4.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.2",
|
||||||
"tar": "^7.5.9",
|
"tar": "^7.5.12",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"types-pac": "^1.0.3",
|
"types-pac": "^1.0.3",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
4052
pnpm-lock.yaml
generated
4052
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -476,15 +476,11 @@ export async function copyEnv(
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'cmd': {
|
case 'cmd': {
|
||||||
clipboard.writeText(
|
clipboard.writeText(`set http_proxy=${proxyUrl}\r\nset https_proxy=${proxyUrl}`)
|
||||||
`set http_proxy=${proxyUrl}\r\nset https_proxy=${proxyUrl}`
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'powershell': {
|
case 'powershell': {
|
||||||
clipboard.writeText(
|
clipboard.writeText(`$env:HTTP_PROXY="${proxyUrl}"; $env:HTTPS_PROXY="${proxyUrl}"`)
|
||||||
`$env:HTTP_PROXY="${proxyUrl}"; $env:HTTPS_PROXY="${proxyUrl}"`
|
|
||||||
)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'fish': {
|
case 'fish': {
|
||||||
|
|||||||
@ -123,6 +123,7 @@ import { startMonitor } from '../resolve/trafficMonitor'
|
|||||||
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
|
import { closeFloatingWindow, showContextMenu, showFloatingWindow } from '../resolve/floatingWindow'
|
||||||
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
|
import { addProfileUpdater, removeProfileUpdater } from '../core/profileUpdater'
|
||||||
import { getImageDataURL } from './image'
|
import { getImageDataURL } from './image'
|
||||||
|
import { get as httpGet } from './chromeRequest'
|
||||||
import { getIconDataURL } from './icon'
|
import { getIconDataURL } from './icon'
|
||||||
import { getAppName } from './appName'
|
import { getAppName } from './appName'
|
||||||
import { logDir, rulePath } from './dirs'
|
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> {
|
async function changeLanguage(lng: string): Promise<void> {
|
||||||
await i18next.changeLanguage(lng)
|
await i18next.changeLanguage(lng)
|
||||||
ipcMain.emit('updateTrayMenu')
|
ipcMain.emit('updateTrayMenu')
|
||||||
@ -324,6 +340,8 @@ const asyncHandlers: Record<string, AsyncFn> = {
|
|||||||
showContextMenu,
|
showContextMenu,
|
||||||
// Misc
|
// Misc
|
||||||
getGistUrl,
|
getGistUrl,
|
||||||
|
fetchIPInfo,
|
||||||
|
measureLatency,
|
||||||
getImageDataURL,
|
getImageDataURL,
|
||||||
getIconDataURL,
|
getIconDataURL,
|
||||||
getAppName,
|
getAppName,
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export const defaultConfig: IAppConfig = {
|
|||||||
'dns',
|
'dns',
|
||||||
'sniff',
|
'sniff',
|
||||||
'log',
|
'log',
|
||||||
'substore'
|
'substore',
|
||||||
|
'network'
|
||||||
],
|
],
|
||||||
siderWidth: 250,
|
siderWidth: 250,
|
||||||
sysProxy: { enable: false, mode: 'manual' },
|
sysProxy: { enable: false, mode: 'manual' },
|
||||||
|
|||||||
@ -146,6 +146,8 @@ const validInvokeChannels = [
|
|||||||
'registerShortcut',
|
'registerShortcut',
|
||||||
// Misc
|
// Misc
|
||||||
'getGistUrl',
|
'getGistUrl',
|
||||||
|
'fetchIPInfo',
|
||||||
|
'measureLatency',
|
||||||
'getImageDataURL',
|
'getImageDataURL',
|
||||||
'getIconDataURL',
|
'getIconDataURL',
|
||||||
'getAppName',
|
'getAppName',
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import { applyTheme, setNativeTheme, setTitleBarOverlay } from '@renderer/utils/
|
|||||||
import { platform } from '@renderer/utils/init'
|
import { platform } from '@renderer/utils/init'
|
||||||
import { TitleBarOverlayOptions } from 'electron'
|
import { TitleBarOverlayOptions } from 'electron'
|
||||||
import SubStoreCard from '@renderer/components/sider/substore-card'
|
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 { createTourDriver, getDriver, startTourIfNeeded } from '@renderer/utils/tour'
|
||||||
import 'driver.js/dist/driver.css'
|
import 'driver.js/dist/driver.css'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@ -41,6 +42,29 @@ let navigate: NavigateFunction
|
|||||||
|
|
||||||
export { getDriver }
|
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 App: React.FC = () => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { appConfig, patchAppConfig } = useAppConfig()
|
const { appConfig, patchAppConfig } = useAppConfig()
|
||||||
@ -62,11 +86,12 @@ const App: React.FC = () => {
|
|||||||
'dns',
|
'dns',
|
||||||
'sniff',
|
'sniff',
|
||||||
'log',
|
'log',
|
||||||
'substore'
|
'substore',
|
||||||
|
'network'
|
||||||
]
|
]
|
||||||
} = appConfig || {}
|
} = appConfig || {}
|
||||||
const narrowWidth = platform === 'darwin' ? 70 : 60
|
const narrowWidth = platform === 'darwin' ? 70 : 60
|
||||||
const [order, setOrder] = useState(siderOrder)
|
const [order, setOrder] = useState(mergeSiderOrder(siderOrder))
|
||||||
const [siderWidthValue, setSiderWidthValue] = useState(siderWidth)
|
const [siderWidthValue, setSiderWidthValue] = useState(siderWidth)
|
||||||
const siderWidthValueRef = useRef(siderWidthValue)
|
const siderWidthValueRef = useRef(siderWidthValue)
|
||||||
const [resizing, setResizing] = useState(false)
|
const [resizing, setResizing] = useState(false)
|
||||||
@ -92,7 +117,7 @@ const App: React.FC = () => {
|
|||||||
}, [useWindowFrame])
|
}, [useWindowFrame])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setOrder(siderOrder)
|
setOrder(mergeSiderOrder(siderOrder))
|
||||||
setSiderWidthValue(siderWidth)
|
setSiderWidthValue(siderWidth)
|
||||||
}, [siderOrder, siderWidth])
|
}, [siderOrder, siderWidth])
|
||||||
|
|
||||||
@ -163,7 +188,8 @@ const App: React.FC = () => {
|
|||||||
rule: 'rules',
|
rule: 'rules',
|
||||||
resource: 'resources',
|
resource: 'resources',
|
||||||
override: 'override',
|
override: 'override',
|
||||||
substore: 'substore'
|
substore: 'substore',
|
||||||
|
network: 'network'
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
@ -179,7 +205,8 @@ const App: React.FC = () => {
|
|||||||
rule: RuleCard,
|
rule: RuleCard,
|
||||||
resource: ResourceCard,
|
resource: ResourceCard,
|
||||||
override: OverrideCard,
|
override: OverrideCard,
|
||||||
substore: SubStoreCard
|
substore: SubStoreCard,
|
||||||
|
network: NetworkCard
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1227,16 +1227,16 @@ const EditRulesModal: React.FC<Props> = (props) => {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label={t('profiles.editRules.payload')}
|
label={t('profiles.editRules.payload')}
|
||||||
placeholder={
|
placeholder={
|
||||||
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
|
getRuleExample(newRule.type) || t('profiles.editRules.payloadPlaceholder')
|
||||||
}
|
}
|
||||||
value={newRule.payload}
|
value={newRule.payload}
|
||||||
onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
|
onValueChange={(value) => setNewRule({ ...newRule, payload: value })}
|
||||||
isDisabled={newRule.type === 'MATCH'}
|
isDisabled={newRule.type === 'MATCH'}
|
||||||
className={`${newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`}
|
className={`${newRule.payload && newRule.type !== 'MATCH' && !isPayloadValid ? 'border-red-500 ring-1 ring-red-500 rounded-lg' : ''}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
label={t('profiles.editRules.proxy')}
|
label={t('profiles.editRules.proxy')}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const LocalBackupConfig: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleImport = async (): Promise<void> => {
|
const handleImport = async (): Promise<void> => {
|
||||||
onClose();
|
onClose()
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
try {
|
try {
|
||||||
const success = await importLocalBackup()
|
const success = await importLocalBackup()
|
||||||
|
|||||||
@ -18,7 +18,8 @@ const titleMap: Record<string, string> = {
|
|||||||
dnsCardStatus: 'sider.cards.dns',
|
dnsCardStatus: 'sider.cards.dns',
|
||||||
sniffCardStatus: 'sider.cards.sniff',
|
sniffCardStatus: 'sider.cards.sniff',
|
||||||
logCardStatus: 'sider.cards.logs',
|
logCardStatus: 'sider.cards.logs',
|
||||||
substoreCardStatus: 'sider.cards.substore'
|
substoreCardStatus: 'sider.cards.substore',
|
||||||
|
networkCardStatus: 'sider.cards.network'
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeMap: Record<string, string> = {
|
const sizeMap: Record<string, string> = {
|
||||||
@ -44,7 +45,8 @@ const SiderConfig: FC = () => {
|
|||||||
dnsCardStatus: appConfig?.dnsCardStatus || 'col-span-1',
|
dnsCardStatus: appConfig?.dnsCardStatus || 'col-span-1',
|
||||||
sniffCardStatus: appConfig?.sniffCardStatus || 'col-span-1',
|
sniffCardStatus: appConfig?.sniffCardStatus || 'col-span-1',
|
||||||
logCardStatus: appConfig?.logCardStatus || '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 (
|
return (
|
||||||
|
|||||||
@ -214,7 +214,7 @@ const ConnCard: React.FC<Props> = (props) => {
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...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 && (
|
{!hideConnectionCardWave && (
|
||||||
<div className="w-full h-full absolute top-0 left-0 pointer-events-none overflow-hidden rounded-[14px]">
|
<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}
|
ref={setNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...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">
|
<CardBody className="pb-1 pt-0 px-0">
|
||||||
<div className="flex justify-between">
|
<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.network')} 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.network')}
|
||||||
|
</h3>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default IPCard
|
||||||
@ -22,7 +22,10 @@ interface Props {
|
|||||||
const UpdaterModal: React.FC<Props> = (props) => {
|
const UpdaterModal: React.FC<Props> = (props) => {
|
||||||
const { version, changelog, onClose } = props
|
const { version, changelog, onClose } = props
|
||||||
const [downloading, setDownloading] = useState(false)
|
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()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
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="w-full bg-default-200 rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
className="bg-primary h-1.5 rounded-full transition-all duration-300"
|
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>
|
</div>
|
||||||
<p className="text-xs text-foreground-400 text-center">
|
<p className="text-xs text-foreground-400 text-center">
|
||||||
|
|||||||
@ -309,6 +309,29 @@
|
|||||||
"sider.cards.sniff": "Sniff OVRD",
|
"sider.cards.sniff": "Sniff OVRD",
|
||||||
"sider.cards.logs": "Logs",
|
"sider.cards.logs": "Logs",
|
||||||
"sider.cards.substore": "Sub-Store",
|
"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.config": "Runtime Config",
|
||||||
"sider.cards.emptyProfile": "Empty Profile",
|
"sider.cards.emptyProfile": "Empty Profile",
|
||||||
"sider.cards.viewRuntimeConfig": "View Runtime Config",
|
"sider.cards.viewRuntimeConfig": "View Runtime Config",
|
||||||
|
|||||||
@ -255,8 +255,8 @@
|
|||||||
"localBackup.import.button": "وارد کردن پشتیبان",
|
"localBackup.import.button": "وارد کردن پشتیبان",
|
||||||
"localBackup.import.confirm.title": "تایید وارد کردن",
|
"localBackup.import.confirm.title": "تایید وارد کردن",
|
||||||
"localBackup.import.confirm.body": "وارد کردن پشتیبان محلی، تمام پیکربندیهای فعلی را بازنویسی میکند. آیا مطمئن هستید که میخواهید ادامه دهید؟",
|
"localBackup.import.confirm.body": "وارد کردن پشتیبان محلی، تمام پیکربندیهای فعلی را بازنویسی میکند. آیا مطمئن هستید که میخواهید ادامه دهید؟",
|
||||||
"localBackup.notification.exportSuccess.title": "صادر کردن موفق",
|
"localBackup.notification.exportSuccess.title": "صادر کردن م<EFBFBD><EFBFBD>فق",
|
||||||
"localBackup.notification.exportSuccess.body": "پشتیبان محلی در مکان مشخص شده صادر شد",
|
"localBackup.notification.exportSuccess.body": "پشتیبان محلی در مکان <EFBFBD><EFBFBD>شخص شده صادر شد",
|
||||||
"localBackup.notification.importSuccess.title": "وارد کردن موفق",
|
"localBackup.notification.importSuccess.title": "وارد کردن موفق",
|
||||||
"localBackup.notification.importSuccess.body": "پشتیبان محلی با موفقیت وارد شد",
|
"localBackup.notification.importSuccess.body": "پشتیبان محلی با موفقیت وارد شد",
|
||||||
"shortcuts.title": "میانبرهای صفحه کلید",
|
"shortcuts.title": "میانبرهای صفحه کلید",
|
||||||
@ -284,6 +284,29 @@
|
|||||||
"sider.cards.sniff": "لغو بو کشیدن",
|
"sider.cards.sniff": "لغو بو کشیدن",
|
||||||
"sider.cards.logs": "گزارشها",
|
"sider.cards.logs": "گزارشها",
|
||||||
"sider.cards.substore": "ساب استور",
|
"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.config": "پیکربندی اجرا",
|
||||||
"sider.cards.emptyProfile": "پروفایل خالی",
|
"sider.cards.emptyProfile": "پروفایل خالی",
|
||||||
"sider.cards.viewRuntimeConfig": "مشاهده پیکربندی اجرا",
|
"sider.cards.viewRuntimeConfig": "مشاهده پیکربندی اجرا",
|
||||||
|
|||||||
@ -172,8 +172,8 @@
|
|||||||
"mihomo.smartCoreStrategy": "Режим стратегии",
|
"mihomo.smartCoreStrategy": "Режим стратегии",
|
||||||
"mihomo.smartCoreStrategyStickySession": "Липкие сессии",
|
"mihomo.smartCoreStrategyStickySession": "Липкие сессии",
|
||||||
"mihomo.smartCoreStrategyRoundRobin": "Круговой опрос",
|
"mihomo.smartCoreStrategyRoundRobin": "Круговой опрос",
|
||||||
"mihomo.smartCoreUseLightGBMTooltip": "Использовать предварительно обученную универсальную модель для быстрого улучшения выбора узлов, но может не подходить для вашей специфической сетевой среды",
|
"mihomo.smartCoreUseLightGBMTooltip": "Использовать предварительно обученную универсальную модель для быстрого улучшения выбора узлов, но может не подходить для вашей специфи<EFBFBD><EFBFBD>еско<EFBFBD><EFBFBD> сетевой среды",
|
||||||
"mihomo.smartCoreCollectDataTooltip": "Собирать данные о вашем сетевом использовании для обучения пользовательских моделей, более подходящих для вашей сетевой среды (отключите, если не знаете, как обучать модели)",
|
"mihomo.smartCoreCollectDataTooltip": "Собирать данны<EFBFBD><EFBFBD> о вашем сетевом использовании для обучения пользовательских моделей, более подходящих для вашей сетевой среды (отключите, если не знаете, как обучать модели)",
|
||||||
"mihomo.mixedPort": "Смешанный порт",
|
"mihomo.mixedPort": "Смешанный порт",
|
||||||
"mihomo.confirm": "Подтвердить",
|
"mihomo.confirm": "Подтвердить",
|
||||||
"mihomo.socksPort": "Порт Socks",
|
"mihomo.socksPort": "Порт Socks",
|
||||||
@ -286,6 +286,29 @@
|
|||||||
"sider.cards.sniff": "переопределение сниффинга",
|
"sider.cards.sniff": "переопределение сниффинга",
|
||||||
"sider.cards.logs": "Журналы",
|
"sider.cards.logs": "Журналы",
|
||||||
"sider.cards.substore": "Sub-Store",
|
"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.config": "Конфигурация",
|
||||||
"sider.cards.emptyProfile": "Пустой профиль",
|
"sider.cards.emptyProfile": "Пустой профиль",
|
||||||
"sider.cards.viewRuntimeConfig": "Просмотр текущей конфигурации",
|
"sider.cards.viewRuntimeConfig": "Просмотр текущей конфигурации",
|
||||||
|
|||||||
@ -309,6 +309,29 @@
|
|||||||
"sider.cards.sniff": "嗅探覆写",
|
"sider.cards.sniff": "嗅探覆写",
|
||||||
"sider.cards.logs": "日志",
|
"sider.cards.logs": "日志",
|
||||||
"sider.cards.substore": "Sub-Store",
|
"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.config": "运行时配置",
|
||||||
"sider.cards.emptyProfile": "空白配置",
|
"sider.cards.emptyProfile": "空白配置",
|
||||||
"sider.cards.viewRuntimeConfig": "查看运行时配置",
|
"sider.cards.viewRuntimeConfig": "查看运行时配置",
|
||||||
|
|||||||
@ -309,6 +309,29 @@
|
|||||||
"sider.cards.sniff": "嗅探覆寫",
|
"sider.cards.sniff": "嗅探覆寫",
|
||||||
"sider.cards.logs": "日誌",
|
"sider.cards.logs": "日誌",
|
||||||
"sider.cards.substore": "Sub-Store",
|
"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.config": "運行時配置",
|
||||||
"sider.cards.emptyProfile": "空白配置",
|
"sider.cards.emptyProfile": "空白配置",
|
||||||
"sider.cards.viewRuntimeConfig": "查看運行時配置",
|
"sider.cards.viewRuntimeConfig": "查看運行時配置",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { ThemeProvider as NextThemesProvider } from 'next-themes'
|
|||||||
import { HeroUIProvider } from '@heroui/react'
|
import { HeroUIProvider } from '@heroui/react'
|
||||||
import { init, platform } from '@renderer/utils/init'
|
import { init, platform } from '@renderer/utils/init'
|
||||||
import '@renderer/assets/main.css'
|
import '@renderer/assets/main.css'
|
||||||
|
import 'flag-icons/css/flag-icons.min.css'
|
||||||
import App from '@renderer/App'
|
import App from '@renderer/App'
|
||||||
import BaseErrorBoundary from './components/base/base-error-boundary'
|
import BaseErrorBoundary from './components/base/base-error-boundary'
|
||||||
import { openDevTools, quitApp } from './utils/ipc'
|
import { openDevTools, quitApp } from './utils/ipc'
|
||||||
|
|||||||
@ -1033,7 +1033,9 @@ const Mihomo: React.FC = () => {
|
|||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
setExternalControllerInput(v)
|
setExternalControllerInput(v)
|
||||||
const result = isValidListenAddress(v)
|
const result = isValidListenAddress(v)
|
||||||
setExternalControllerError(isValid(result) ? null : (getError(result) ?? '格式错误'))
|
setExternalControllerError(
|
||||||
|
isValid(result) ? null : (getError(result) ?? '格式错误')
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -1048,11 +1050,12 @@ const Mihomo: React.FC = () => {
|
|||||||
title={t('common.generateSecret')}
|
title={t('common.generateSecret')}
|
||||||
variant="light"
|
variant="light"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
const randomSecret = Array.from({ length: 8 }, () =>
|
const randomSecret = Array.from(
|
||||||
chars[Math.floor(Math.random() * chars.length)]
|
{ length: 8 },
|
||||||
).join('');
|
() => chars[Math.floor(Math.random() * chars.length)]
|
||||||
setSecretInput(randomSecret);
|
).join('')
|
||||||
|
setSecretInput(randomSecret)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IoMdRefresh className="text-lg" />
|
<IoMdRefresh className="text-lg" />
|
||||||
@ -1085,7 +1088,7 @@ const Mihomo: React.FC = () => {
|
|||||||
startContent={
|
startContent={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSecretVisible(prev => !prev)}
|
onClick={() => setIsSecretVisible((prev) => !prev)}
|
||||||
className="text-gray-500 hover:text-gray-700"
|
className="text-gray-500 hover:text-gray-700"
|
||||||
>
|
>
|
||||||
{isSecretVisible ? (
|
{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 { Navigate } from 'react-router-dom'
|
||||||
|
import NetworkPage from '@renderer/pages/network'
|
||||||
import Override from '@renderer/pages/override'
|
import Override from '@renderer/pages/override'
|
||||||
import Proxies from '@renderer/pages/proxies'
|
import Proxies from '@renderer/pages/proxies'
|
||||||
import Rules from '@renderer/pages/rules'
|
import Rules from '@renderer/pages/rules'
|
||||||
@ -14,6 +15,10 @@ import DNS from '@renderer/pages/dns'
|
|||||||
import Sniffer from '@renderer/pages/sniffer'
|
import Sniffer from '@renderer/pages/sniffer'
|
||||||
import SubStore from '@renderer/pages/substore'
|
import SubStore from '@renderer/pages/substore'
|
||||||
const routes = [
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/network',
|
||||||
|
element: <NetworkPage />
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/mihomo',
|
path: '/mihomo',
|
||||||
element: <Mihomo />
|
element: <Mihomo />
|
||||||
|
|||||||
@ -154,6 +154,8 @@ interface IpcApi {
|
|||||||
registerShortcut: (oldShortcut: string, newShortcut: string, action: string) => Promise<boolean>
|
registerShortcut: (oldShortcut: string, newShortcut: string, action: string) => Promise<boolean>
|
||||||
// Misc
|
// Misc
|
||||||
getGistUrl: () => Promise<string>
|
getGistUrl: () => Promise<string>
|
||||||
|
fetchIPInfo: (url: string) => Promise<unknown>
|
||||||
|
measureLatency: (url: string) => Promise<number | null>
|
||||||
getImageDataURL: (url: string) => Promise<string>
|
getImageDataURL: (url: string) => Promise<string>
|
||||||
relaunchApp: () => Promise<void>
|
relaunchApp: () => Promise<void>
|
||||||
quitApp: () => Promise<void>
|
quitApp: () => Promise<void>
|
||||||
@ -306,6 +308,8 @@ export const {
|
|||||||
registerShortcut,
|
registerShortcut,
|
||||||
// Misc
|
// Misc
|
||||||
getGistUrl,
|
getGistUrl,
|
||||||
|
fetchIPInfo,
|
||||||
|
measureLatency,
|
||||||
getImageDataURL,
|
getImageDataURL,
|
||||||
relaunchApp,
|
relaunchApp,
|
||||||
quitApp
|
quitApp
|
||||||
|
|||||||
@ -17,7 +17,13 @@ const domainSuffixValidator = (value: string): boolean => {
|
|||||||
|
|
||||||
const domainKeywordValidator = (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 => {
|
const domainRegexValidator = (value: string): boolean => {
|
||||||
@ -179,7 +185,9 @@ const inUserValidator = (value: string): boolean => {
|
|||||||
if (value.length === 0) return false
|
if (value.length === 0) return false
|
||||||
// 支持多个用户名(用 / 分隔)
|
// 支持多个用户名(用 / 分隔)
|
||||||
const users = value.split('/')
|
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 验证器 - 入站名称验证
|
// IN-NAME 验证器 - 入站名称验证
|
||||||
@ -322,7 +330,10 @@ export const isValidListenAddress = (s: string | undefined): ValidationResult =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 域名或主机名 (使用宽松的 FQDN 验证)
|
// 域名或主机名 (使用宽松的 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: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,7 +378,10 @@ export const isValidListenAddressFull = (s: string | undefined): ValidationResul
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 域名或主机名 (使用宽松的 FQDN 验证)
|
// 域名或主机名 (使用宽松的 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: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
src/shared/types.d.ts
vendored
1
src/shared/types.d.ts
vendored
@ -260,6 +260,7 @@ interface IAppConfig {
|
|||||||
overrideCardStatus?: CardStatus
|
overrideCardStatus?: CardStatus
|
||||||
profileCardStatus?: CardStatus
|
profileCardStatus?: CardStatus
|
||||||
proxyCardStatus?: CardStatus
|
proxyCardStatus?: CardStatus
|
||||||
|
networkCardStatus?: CardStatus
|
||||||
resourceCardStatus?: CardStatus
|
resourceCardStatus?: CardStatus
|
||||||
ruleCardStatus?: CardStatus
|
ruleCardStatus?: CardStatus
|
||||||
sniffCardStatus?: CardStatus
|
sniffCardStatus?: CardStatus
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user