From 5f1b9db2dc144397c4b01cfe84783173b8edb45e Mon Sep 17 00:00:00 2001 From: eric Date: Wed, 8 Jul 2026 20:09:40 -0500 Subject: [PATCH] =?UTF-8?q?=E7=84=95=E6=96=B0=E7=BD=91=E7=BB=9C/=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E9=A1=B5=20UI=EF=BC=8C=E5=B9=B6=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E5=87=BA=E5=8F=A3=E7=9A=84=E5=88=86=E6=B5=81?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E4=B8=8E=20Nomadro=20=E5=9C=BA=E6=99=AF?= =?UTF-8?q?=E6=8E=A8=E8=8D=90=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 简化分流文案与手动检测交互,系统页对齐管线/芯片/ScoreRing 设计语言;README 同步更新。 Co-authored-by: Cursor --- README.md | 90 ++-- electron/src/renderer/src/App.css | 14 + electron/src/renderer/src/App.tsx | 2 +- .../src/renderer/src/NetworkDashboard.tsx | 198 ++++--- electron/src/renderer/src/SystemMetrics.tsx | 485 +++++++++++++----- .../components/network/IpDiagnosticsBoard.tsx | 218 ++++++++ .../src/components/network/IpProfilePanel.tsx | 159 +++--- .../src/components/network/NetworkIcons.tsx | 10 + .../network/NetworkInsightOffer.tsx | 97 ++++ .../components/network/NetworkPipeline.tsx | 2 +- .../src/components/system/SystemIcons.tsx | 51 ++ .../src/components/system/SystemPipeline.tsx | 69 +++ electron/src/renderer/src/main.tsx | 1 + electron/src/renderer/src/network-polish.css | 280 ++++++++++ electron/src/renderer/src/system-polish.css | 357 +++++++++++++ electron/src/shared/monetizationLinks.ts | 23 + electron/src/shared/networkOfferLogic.ts | 43 ++ web2_ip_profile.py | 8 +- web2_network.py | 144 +++++- web2_network_routes.py | 2 +- 20 files changed, 1909 insertions(+), 344 deletions(-) create mode 100644 electron/src/renderer/src/components/network/IpDiagnosticsBoard.tsx create mode 100644 electron/src/renderer/src/components/network/NetworkInsightOffer.tsx create mode 100644 electron/src/renderer/src/components/system/SystemIcons.tsx create mode 100644 electron/src/renderer/src/components/system/SystemPipeline.tsx create mode 100644 electron/src/renderer/src/system-polish.css create mode 100644 electron/src/shared/monetizationLinks.ts create mode 100644 electron/src/shared/networkOfferLogic.ts diff --git a/README.md b/README.md index df7ac18..5444caa 100644 --- a/README.md +++ b/README.md @@ -118,21 +118,19 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload | 控制面 | `web2.py` | HTTP API | | 运行时诊断 | `web2_process_runtime.py` | `business_status`、FFmpeg 速度/帧数 | | 质量守卫 | `web2_quality_guard.py` | 推流质量评分 | -| 网络测速 | `web2_network.py` | 境内外可达性、上下行测速、分流判断 | -| IP 画像 | `web2_ip_profile.py` | 出口 IP 类型(住宅/机房/代理)、洁净度评分 | +| 网络测速 | `web2_network.py` | 境内外可达性、上下行测速、分流判断(基于本机出口 IP) | +| 网络路由 | `web2_network_routes.py` | `/network_status`、`/ip_profile`、`/ip_quality` 路由与缓存 | +| IP 画像 | `web2_ip_profile.py` | 出口 IP 类型(住宅/机房/代理)、洁净度评分、分流检测 | | IP 质量 | `web2_ip_quality.py` | 代理/VPN/机房风险、流媒体解锁探测 | -| 功能开关 | `web2_feature_flags.py` | 摄像头/拉流 API 与 worker 显隐 | +| 功能开关 | `web2_feature_flags.py`, `electron/src/shared/featureFlags.ts` | 摄像头/拉流 UI 与 API 显隐 | +| youLIVE | `electron/src/youlive/` | YouTube 监听、弹幕、账号、桌面通知 | +| 主进程 | `electron/src/main/` | 运行时打包、IPC、本地 DB | +| 界面 | `electron/src/renderer/` | React SPA | | 转播历史 | `web2_relay_log.py` | 会话 SQLite、日志导入 | | 配置治理 | `web2_config_governance.py` | 保存前备份、恢复 | | 多频道 | `web2_relay_pro.py` | `youtube_channels.json`、`config/relay_pro/` | | 摄像头 | `web2_camera.py`, `camera_youtube.py` | DirectShow 采集、NVENC 推流 | | 拉流 | `web2_pull_stream.py`, `pull_stream_youtube.py` | RTMP/SRT/HLS/FLV/WHEP 拉流转推 | -| IP 画像 | `web2_ip_profile.py` | 出口 IP 类型、住宅/机房判定、WebRTC 泄露联动 | -| IP 质量 | `web2_ip_quality.py` | 风险评分、流媒体解锁探测(参考 xykt/IPQuality) | -| 功能开关 | `web2_feature_flags.py`, `electron/src/shared/featureFlags.ts` | 摄像头/拉流 UI 与 API 显隐 | -| youLIVE | `electron/src/youlive/` | YouTube 监听、弹幕、账号、桌面通知 | -| 主进程 | `electron/src/main/` | 运行时打包、IPC、本地 DB | -| 界面 | `electron/src/renderer/` | React SPA | --- @@ -145,12 +143,13 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload │ ├── src/preload/ # contextBridge │ ├── src/renderer/ # UI(直播 / 摄像头 / 拉流 / 网络 / 系统) │ │ ├── components/worker/ # 业务工作台(链路条 / 清单 / 指标) -│ │ └── components/network/ # 网络页(链路条 / IP 画像 / IP 质量 / 测速图) -│ ├── src/shared/ # 前后端共享常量(功能开关 featureFlags) +│ │ └── components/network/ # 网络页(链路条 / IP 诊断 / 场景推荐 / 测速图) +│ ├── src/shared/ # 前后端共享(featureFlags、monetizationLinks) │ ├── src/youlive/ # 内嵌 youLIVE(主进程服务 + 共享类型) │ └── scripts/ # 离线运行时、图标 ├── web2.py # FastAPI 入口 -├── web2_*.py # 后端子模块(含 ip_profile / ip_quality / feature_flags) +├── web2_network_routes.py # 网络 / IP 相关 API 路由与缓存 +├── web2_*.py # 其他后端子模块 ├── third_party/IPQuality/ # IP 质量检测脚本参考(ip.sh) ├── config/ # 运行配置 ├── backup_config/__web__/ # 配置自动备份 @@ -174,13 +173,15 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload | **摄像头** | 本机采集直推:设备预览、采集/编码配置、独立控制台(进程 `camera_youtube`) | | **拉流** | 外部流转推:RTMP/SRT/HLS/FLV/WHEP 拉流、转码或流复制(进程 `pull_stream_youtube`) | | **网络** | 链路诊断、境内外测速、IP 画像与质量、WebRTC 泄露检测、FFmpeg 代理 | -| **系统** | CPU/内存/磁盘/GPU、开机启动、路径、会员登录限制开关 | +| **系统** | 本机硬件与资源占用;手动「开始检测」,快照优先 | **直播** 页面向抖音无人转播业务,含 Pro 多频道、三层链路诊断、转播历史等。 **摄像头** / **拉流** 页各自独立 UI 与工作流,不复用直播页布局;侧栏深链可直达(见下表)。默认构建中这两项为**隐藏**状态,见 [功能开关](#功能开关)。 -**网络** 页采用链路管线布局:源站 / 分流 / YouTube / 上行四段可视化,配合实时测速曲线、境内外带宽对比、代理设置,以及 IP 画像与 IP 质量面板(见 [网络诊断 / IP 画像 / 代理](#网络诊断--ip-画像--代理))。 +**网络** 页采用链路管线布局:源站 / 分流 / YouTube / 上行四段可视化;进入页面先读本地快照,**仅点击右上角「开始检测」** 才触发完整测速与 IP 检测(见 [网络诊断 / IP 画像 / 代理](#网络诊断--ip-画像--代理))。 + +**系统** 页与网络页同一交互与视觉语言:**进入读快照,右上角「开始检测」**;含资源管线、快检芯片、ScoreRing 与磁盘列表。 | 深链参数 | 页面 | 说明 | |----------|------|------| @@ -210,14 +211,23 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload **网络** 页面向转播前的出口与链路自检,含: -- **链路快检**:国内源站(抖音)/ Google / 线路分流 / 海外上行 / 录制代理五项状态芯片 -- **NetworkPipeline**:源站 → 分流 → YouTube → 上行四段链路条,按可达性与测速结果着色 -- **境内外站点卡**:Google 与抖音的延迟、上下行测速、解析 IP 与地区 -- **IP 出口画像**(`GET /ip_profile`):本机默认出口与海外出口的类型(住宅/机房/代理/移动)、洁净度评分、WebRTC 本地泄露参考 -- **IP 质量检测**(`GET /ip_quality`):代理/VPN/Tor/机房风险因子、Abuser 评分、YouTube/Netflix 等流媒体解锁状态 +- **开始检测**:右上角唯一操作按钮;检测中显示「检测中…」,完成后仍显示「开始检测」 +- **链路快检**:国内源站 / Google / 分流 / 海外上行 / 录制代理五项状态芯片 +- **NetworkPipeline**:源站 → 分流 → YouTube → 上行;分流结论为 **已分流 / 未分流 / 待确认** +- **境内外站点卡**:Google 与抖音延迟、上下行测速;Google 不可达时可跳转海外节点方案 +- **场景推荐**:Google 不通 → VPS 教程;IP 质量差 → 静态住宅 IP(外链 [Nomadro](https://nomadro.com/)) +- **出口 IP**(`GET /ip_profile`):当前出口单卡片 + IP 诊断板(`IpDiagnosticsBoard`) +- **IP 质量**(`GET /ip_quality`):风险因子与流媒体解锁状态 - **测速趋势图**:`NetworkSpeedChart` 记录近期上下行采样 -- **FFmpeg 代理**:录制/推流专用 HTTP/SOCKS 代理,与系统代理设置联动 -- **自动刷新**:约 45s 静默轮询;WebRTC 检测完成后自动刷新 IP 画像 +- **FFmpeg 代理**:页面底部 `ProxySettings`,与 Windows 系统代理独立 +- **本地快照**:进入页面读 workspace 缓存;分流逻辑过旧时提示重新检测 + +**系统** 页资源自检,含: + +- **SystemPipeline**:CPU → 内存 → 显卡 → 磁盘四段渐变管线,流动箭头与 shimmer 动效 +- **资源快检芯片**:emoji + 状态标记(CPU / 内存 / NVENC / 磁盘) +- **ScoreRing 卡片**:CPU / 内存环形进度 + 进度条(管线/芯片已展示摘要,此处给明细) +- **本机信息卡**:6 项 emoji 卡片,hover 光晕动画 > 摄像头 / 拉流功能默认在导航中**隐藏**(见 [功能开关](#功能开关)),代码与 API 仍保留,可按需开启。 @@ -264,10 +274,10 @@ uvicorn web2:app --host 127.0.0.1 --port 8001 --reload | 拉流 | `GET /pull_stream/protocols` | 协议预设与示例地址 | | 历史 | `GET /youtube/relay/sessions` | 转播会话 | | 历史 | `POST /youtube/relay/sessions/import` | 从 PM2 日志导入 | -| 网络 | `GET /network_status` `GET/POST /proxy_config` | 测速与代理 | -| 网络 | `GET /ip_profile` | 出口 IP 画像(可选 `client_tz`、`client_lang`、`webrtc_leak`) | -| 网络 | `GET /ip_quality` | IP 质量与流媒体解锁探测 | -| 系统 | `GET /system_metrics` `GET /host/gpu_status` | 主机、磁盘、NVENC | +| 网络 | `GET /network_status` `GET/POST /proxy_config` | 测速与代理(`force_refresh=1` 强制重检) | +| 网络 | `GET /ip_profile` | 出口 IP 画像(`client_tz`、`client_lang`、`webrtc_leak`、`force_refresh`) | +| 网络 | `GET /ip_quality` | IP 质量与流媒体解锁(`force_refresh`) | +| 系统 | `GET /system_metrics` `GET /host/gpu_status` | 主机、磁盘、NVENC(`force_refresh`) | | OAuth | `GET /youtube/oauth/*` | Google 拉取串流密钥(UI 可关) | **可选鉴权**:`config/web2_api_token.txt` 或 `WEB2_API_TOKEN` 存在时,除 `/health` 外需: @@ -338,13 +348,22 @@ Electron 主进程自动注入 API 鉴权头。 **网络** 页帮助在开播前确认「抖音源站可达 + 海外出口可用 + 上行带宽足够」。 +### 检测方式 + +| 行为 | 说明 | +|------|------| +| 进入页面 | 读取 Electron workspace 本地快照(若有),**不自动发起网络请求** | +| 点击「开始检测」 | 并行请求 `network_status`、`ip_profile`、`ip_quality`、`proxy_config`,均带 `force_refresh=1` | +| 后端缓存 | `web2_network_routes.py`:网络 300s、IP 画像 300s、IP 质量 600s;同 key 并发请求合并 | + ### 链路诊断(`GET /network_status`) | 检测项 | 说明 | |--------|------| | 国内源站 | 抖音域名可达性与延迟 | | Google 出口 | YouTube 推流路径可达性 | -| 线路分流 | 对比境内外解析 IP,判断代理/TUN 是否生效 | +| 分流 | 基于**本机默认出口 IP** 与海外采样 IP、Google/抖音可达性判断(`meta.logic_version=2`),结论:**已分流 / 未分流** | +| 运营商 | 本机宽带 ISP 单独展示,不混在分流文案中 | | 海外测速 | 参考上下行 Mbps(测速失败不代表无法推流) | | 录制代理 | `config/ffmpeg_proxy.ini` 是否已配置 | @@ -352,8 +371,8 @@ Electron 主进程自动注入 API 鉴权头。 参考 whoer / ping0.cc 维度,聚合 ip-api.com、ping0.cc 等数据源: -- **默认出口**与**海外出口**(经 Google 探测路径采样)的类型标签 -- 住宅 / 机房 / 代理 / 移动判定与洁净度评分 +- **当前出口**单卡片:IP、类型(住宅/机房/代理/移动)、洁净度评分 +- 默认出口与海外采样 IP 对比 → 分流检测项(**两路 IP 相同 / 不同**) - 浏览器侧 **WebRTC 本地候选**检测(`useWebRtcLeak`),结果通过 `webrtc_leak` 参数回传后端 ### IP 质量检测(`GET /ip_quality`) @@ -364,6 +383,17 @@ Electron 主进程自动注入 API 鉴权头。 - 多源 Abuser / IPQS 评分 - YouTube、TikTok、Netflix、Disney+ 等流媒体解锁状态 +### 场景推荐与外链 + +根据检测结果在页面内展示一条诊断式建议(非弹窗): + +| 条件 | 推荐 | +|------|------| +| Google 不可达 | 境外 VPS 自建节点、YouTube 单独走代理 | +| Google 可达且 IP 质量偏低 | 静态住宅 ISP 出口 | + +教程与延伸阅读跳转 [Nomadro](https://nomadro.com/),URL 带 `utm_source=wuren-live` 与 `topic` 参数;逻辑见 `electron/src/shared/monetizationLinks.ts`、`networkOfferLogic.ts`。 + ### FFmpeg 代理 `GET/POST /proxy_config` 读写 `config/ffmpeg_proxy.ini`,供 `youtube2` 等 worker 拉流时使用,与 Windows 系统代理设置独立。 @@ -538,8 +568,10 @@ pnpm dist | 拉流连接失败 | 核对协议与 URL;SRT 检查 caller/streamid;WebRTC 建议经 SRS 转 RTMP | | 拉流有画面 YouTube 无 | 流复制模式下源可能非 H.264+AAC,改转码模式 | | 目标端监听无数据 | 直播页确认 `youtubeWatchUrl` 已填写;youLIVE 桌面通知已开启 | -| Google 不可达 / 分流未生效 | **网络** 页查看链路快检;配置 TUN/规则代理;填写 FFmpeg 代理 | +| Google 不可达 / 未分流 | **网络** 页点「开始检测」;配置 TUN/规则或 FFmpeg 代理;排障提示链至 Nomadro 教程 | +| 分流显示旧长文案 | 后端规则已更新,点「开始检测」刷新;旧快照含「本机宽带…」会被自动判定过期 | | IP 画像显示机房/代理 | 更换住宅出口或调整代理节点;关注 WebRTC 泄露提示 | +| 网络/系统一直「检测中」 | 勿依赖自动轮询;手动点「开始检测」;查 8001 后端是否响应 | | IP 质量检测超时 | 外网 API 偶发慢;稍后刷新;检查本机防火墙 | | 摄像头/拉流入口不见 | 默认功能开关为关,见 [功能开关](#功能开关) | | 配置误改 | 直播页 **历史备份** 或 `POST /config/restore` | diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 8b40583..d59358f 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -2568,6 +2568,20 @@ button.net-badge { color: var(--text-muted); } +.net-routes__isp { + display: flex; + align-items: center; + gap: 6px; + margin: 8px 0 0; + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; + line-height: 1.45; + color: var(--text); + background: rgba(99, 102, 241, 0.08); + border: 1px solid rgba(99, 102, 241, 0.18); +} + .net-speed__title { margin: 0 0 10px; font-size: 13px; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 2dfa307..8111e2e 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -131,7 +131,7 @@ const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ { id: 'camera', label: '摄像头', hint: '本机采集直推 YouTube' }, { id: 'pull', label: '拉流', hint: 'RTMP / SRT / HLS 拉流转推' }, { id: 'network', label: '网络', hint: '链路检测、分流判断与录制代理' }, - { id: 'system', label: '系统', hint: '硬件与系统信息' }, + { id: 'system', label: '系统', hint: '硬件与资源占用' }, ] /** 侧栏导航(受功能开关过滤) */ diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx index 22703f1..cd250ab 100644 --- a/electron/src/renderer/src/NetworkDashboard.tsx +++ b/electron/src/renderer/src/NetworkDashboard.tsx @@ -3,7 +3,6 @@ import { apiFetch, apiUrl } from './api' import { saveWorkspaceSnapshot, loadWorkspaceSnapshot } from './workspace' import ProxySettings from './ProxySettings' import { type FlowStep } from './components/worker/WorkerFlowStrip' -import LivePulse from './components/ui/LivePulse' import MetricBar from './components/ui/MetricBar' import NetSpeedBars from './components/ui/NetSpeedBars' import NetworkPipeline from './components/network/NetworkPipeline' @@ -22,6 +21,9 @@ import { IconTip, IconUpload, } from './components/network/NetworkIcons' +import NetworkInsightOffer, { NomadroDocLink } from './components/network/NetworkInsightOffer' +import { openNomadro } from '@shared/monetizationLinks' +import { resolveNetworkOffer } from '@shared/networkOfferLogic' type SiteInfo = { host: string @@ -47,12 +49,48 @@ type SpeedBlock = { type NetworkPayload = { google: SiteInfo douyin: SiteInfo - routes: { different: boolean | null; note: string } + routes: { + different: boolean | null + note: string + isp?: string | null + country?: string | null + country_code?: string | null + } + egress?: { + default?: { + ip?: string + country?: string + country_code?: string + isp?: string + org?: string + city?: string + error?: string | null + } + overseas_sample?: { + ip?: string + country?: string + isp?: string + org?: string + error?: string | null + } + } speed: { overseas: SpeedBlock; domestic: SpeedBlock } - meta?: { at: number; download_cap_bytes: number; upload_bytes: number } + meta?: { at: number; download_cap_bytes: number; upload_bytes: number; logic_version?: number } error?: string } +const NETWORK_LOGIC_VERSION = 2 + +function isStaleNetworkCache(net: NetworkPayload | null | undefined): boolean { + if (!net) return true + if ((net.meta?.logic_version ?? 0) < NETWORK_LOGIC_VERSION) return true + if (!net.egress?.default) return true + const note = net.routes?.note || '' + if (note.includes('未能查询到双方 IP 国家') || note.includes('依据解析 IP 的国家码')) return true + if (note.includes('本机宽带') || note.includes('海外采样')) return true + return false +} + type ProxyCfg = { enabled: boolean; http: string; socks: string } const EMPTY_SITE: SiteInfo = { @@ -79,10 +117,18 @@ const EMPTY_SPEED: SpeedBlock = { const DEFAULT_NETWORK: NetworkPayload = { google: EMPTY_SITE, douyin: EMPTY_SITE, - routes: { different: null, note: '' }, + routes: { different: null, note: '', isp: null, country: null, country_code: null }, speed: { overseas: EMPTY_SPEED, domestic: EMPTY_SPEED }, } +function fmtIspLine(routes: NetworkPayload['routes'], egress?: NetworkPayload['egress']): string | null { + const isp = routes.isp || egress?.default?.isp || egress?.default?.org + const country = routes.country || egress?.default?.country + if (!isp && !country) return null + if (isp && country) return `${isp} · ${country}` + return isp || country || null +} + function fmtMbps(v: number | null | undefined): string { if (v == null || Number.isNaN(v)) return '—' return `${v.toFixed(2)} Mbps` @@ -124,7 +170,14 @@ function networkFlowSteps(view: NetworkPayload, loading: boolean): FlowStep[] { let route: FlowStep['state'] = 'pending' if (view.routes.different === true) route = 'active' else if (view.routes.different === false) route = 'warn' - else route = 'idle' + else if (view.routes.note) route = 'idle' + + const routeTitle = + view.routes.different === true + ? '已分流' + : view.routes.different === false + ? '未分流' + : view.routes.note || '待确认' const youtube: FlowStep['state'] = view.google.reachable ? 'active' : 'error' const up = view.speed.overseas.upload_mbps @@ -138,12 +191,7 @@ function networkFlowSteps(view: NetworkPayload, loading: boolean): FlowStep[] { { id: 'route', label: '分流', - title: - view.routes.different === true - ? '境内外已区分' - : view.routes.different === false - ? '可能未分流' - : '待确认', + title: routeTitle, state: route, }, { id: 'youtube', label: 'YouTube', title: view.google.reachable ? 'Google 出口可达' : '出口不可达', state: youtube }, @@ -151,13 +199,6 @@ function networkFlowSteps(view: NetworkPayload, loading: boolean): FlowStep[] { ] } -function overallStatus(view: NetworkPayload, loading: boolean): 'ready' | 'warn' | 'bad' { - if (loading) return 'warn' - if (!view.google.reachable) return 'bad' - if (!view.douyin.reachable || view.routes.different === false) return 'warn' - return 'ready' -} - type StatusChip = { id: string; label: string; ok: boolean; hint?: string } function NetworkStatusChips({ items, loading }: { items: StatusChip[]; loading: boolean }) { @@ -211,7 +252,7 @@ function SiteCard({ } else if (variant === 'google' && onSolutionClick) { badge = ( ) } else { @@ -404,9 +445,11 @@ export default function NetworkDashboard() { loadWorkspaceSnapshot('ip_quality'), ]) if (cancelled) return - if (net) { + if (net && !isStaleNetworkCache(net)) { setData(net) setHasCachedData(true) + } else if (net && isStaleNetworkCache(net)) { + setErr('规则已更新,请点「重新检测」') } if (profile) setIpProfile(profile) if (quality) setIpQuality(quality) @@ -430,8 +473,8 @@ export default function NetworkDashboard() { }, []) const view = data ?? DEFAULT_NETWORK - const status = overallStatus(view, loading) const flowSteps = useMemo(() => networkFlowSteps(view, loading), [view, loading]) + const ispLine = fmtIspLine(view.routes, view.egress) const proxyConfigured = proxyCfg.enabled && (proxyCfg.http.trim().length > 0 || proxyCfg.socks.trim().length > 0) @@ -447,16 +490,16 @@ export default function NetworkDashboard() { id: 'google', label: 'Google', ok: view.google.reachable, - hint: view.google.error || '需代理或 TUN 分流', + hint: view.google.error || '需开代理', }, { id: 'route', - label: '线路分流', + label: '分流', ok: view.routes.different === true, hint: view.routes.different === false - ? '两路出口可能走同一节点' - : '请在代理软件中确认规则', + ? '国内与 YouTube 同出口' + : view.routes.note || '待确认', }, { id: 'upload', @@ -474,36 +517,27 @@ export default function NetworkDashboard() { [view, proxyConfigured], ) - const openSolution = useCallback(() => { - const open = window.recorderConsole?.openExternal - const url = 'https://nomadro.com/' - if (open) void open(url) - else void window.open(url, '_blank', 'noopener,noreferrer') + const openVpsGuide = useCallback(() => { + void openNomadro('vps', 'google-card') }, []) - const openProxySettings = useCallback(() => { - void window.appHost?.openSystemSettings?.('proxy') - }, []) + const networkOffer = useMemo( + () => + resolveNetworkOffer({ + googleReachable: view.google.reachable, + loading: loading || refreshing, + hasData: hasCachedData, + view: { ipProfile, ipQuality }, + }), + [view.google.reachable, loading, refreshing, hasCachedData, ipProfile, ipQuality], + ) const routesPill = view.routes.different == null - ? ['net-routes__pill--unk', '分流未知'] + ? ['net-routes__pill--unk', '未知'] : view.routes.different - ? ['net-routes__pill--yes', '境内外出口已区分'] - : ['net-routes__pill--no', '境内外出口可能未区分'] - - const statusLabel = - loading || refreshing - ? '检测中' - : !hasCachedData - ? '待检测' - : status === 'ready' - ? '网络就绪' - : status === 'warn' - ? '需关注' - : '出口异常' - - const pulseTone = status === 'ready' ? 'live' : status === 'warn' ? 'warn' : 'idle' + ? ['net-routes__pill--yes', '已分流'] + : ['net-routes__pill--no', '未分流'] return (
@@ -522,19 +556,11 @@ export default function NetworkDashboard() { 网络与代理

- 链路检测 · 住宅 IP · 录制代理 + {ispLine ? `本机宽带 ${ispLine}` : '链路检测 · 住宅 IP · 录制代理'}

- - - {statusLabel} - -
@@ -554,6 +577,8 @@ export default function NetworkDashboard() { + {networkOffer ? : null} + {err && (

{err} @@ -575,7 +600,7 @@ export default function NetworkDashboard() { loading={loading} variant="google" brandIcon={} - onSolutionClick={openSolution} + onSolutionClick={openVpsGuide} />

- 线路判断 + 分流 {routesPill[1]}
@@ -621,7 +646,12 @@ export default function NetworkDashboard() { 海外 -

{loading ? '检测中…' : view.routes.note || '暂无线路说明'}

+

{loading ? '检测中…' : view.routes.note || '—'}

+ {ispLine && !loading ? ( +

+ 📡 运营商识别:{ispLine} +

+ ) : null} @@ -635,18 +665,42 @@ export default function NetworkDashboard() {
  • - 🛡️ - Google 不可达时检查 TUN/规则是否覆盖 YouTube RTMPS。 + 🛰️ + + Google 不通先查代理规则;仍不行可看{' '} + + VPS 教程 + + 。 + +
  • +
  • + 🏠 + + 出口指纹偏机房时,推流更易触发风控,可了解{' '} + + 静态住宅 IP + + 方案。 +
  • 🔌 - 录制代理仅影响 FFmpeg,不改 Windows 系统代理。 -
  • -
  • - 🔀 - 线路未分流时拉源与推流易争抢出口导致卡顿。 + + 录制代理仅影响 FFmpeg,不改 Windows 系统代理;更多配置见{' '} + + 网络专题 + + 。 +
+

+ 完整教程、博客与更新通知在{' '} + + Nomadro 学习中心 + +

diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx index e083e98..5ddcc2a 100644 --- a/electron/src/renderer/src/SystemMetrics.tsx +++ b/electron/src/renderer/src/SystemMetrics.tsx @@ -1,7 +1,12 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { apiFetch, apiUrl } from './api' import { saveWorkspaceSnapshot, loadWorkspaceSnapshot } from './workspace' import MetricBar, { toneFromPercent } from './components/ui/MetricBar' +import ScoreRing from './components/ui/ScoreRing' +import { IconMonitor, IconRefresh } from './components/network/NetworkIcons' +import SystemPipeline from './components/system/SystemPipeline' +import { IconCpu, IconDisk, IconGpu, IconRam } from './components/system/SystemIcons' +import type { FlowStep } from './components/worker/WorkerFlowStrip' type Metrics = { ts?: number @@ -39,6 +44,15 @@ type Metrics = { error?: string } +type StatusChip = { id: string; emoji: string; label: string; ok: boolean; hint?: string } + +type SpecItem = { + emoji: string + label: string + value: string + icon: (props: { size?: number }) => ReactNode +} + function fmtBytes(n: number | undefined): string { if (n == null || Number.isNaN(n)) return '—' if (n < 1024) return `${n} B` @@ -49,8 +63,7 @@ function fmtBytes(n: number | undefined): string { function fmtOsName(sys: Metrics['system']): string { if (!sys?.os) return '—' - const parts = [sys.os, sys.os_release].filter(Boolean) - return parts.join(' ') || '—' + return [sys.os, sys.os_release].filter(Boolean).join(' ') || '—' } function fmtUptime(bootUnix: number | null | undefined): string { @@ -59,21 +72,106 @@ function fmtUptime(bootUnix: number | null | undefined): string { const d = Math.floor(sec / 86400) const h = Math.floor((sec % 86400) / 3600) const min = Math.floor((sec % 3600) / 60) - return d > 0 ? `${d} 天 ${h} 小时` : `${h} 小时 ${min} 分` + return d > 0 ? `${d}天${h}时` : `${h}时${min}分` } -function gpuLabel(gpu?: { nvidia_detected?: boolean; nvenc_available?: boolean }): string { +function shortProcessor(processor?: string | null, logical?: number): string { + if (!processor) return logical != null ? `${logical} 核` : '—' + const s = processor.trim() + if (s.length <= 36) return s + return `${s.slice(0, 34)}…` +} + +function gpuShort(gpu?: { nvidia_detected?: boolean; nvenc_available?: boolean }): string { if (!gpu) return '—' - if (gpu.nvenc_available) return 'NVIDIA · NVENC 可用' - if (gpu.nvidia_detected) return 'NVIDIA · 无 NVENC' - return '未检测到 NVIDIA GPU' + if (gpu.nvenc_available) return 'NVENC 可用' + if (gpu.nvidia_detected) return '无 NVENC' + return '无 NVIDIA' } -function InfoRow({ label, value }: { label: string; value: string }) { +function pctTone(pct: number | null | undefined): 'ok' | 'warn' | 'bad' | 'neutral' { + if (pct == null) return 'neutral' + if (pct >= 90) return 'bad' + if (pct >= 75) return 'warn' + return 'ok' +} + +function systemFlowSteps(data: Metrics | null, loading: boolean): FlowStep[] { + if (loading && !data) { + return [ + { id: 'cpu', label: 'CPU', title: '检测中…', state: 'pending' }, + { id: 'mem', label: '内存', title: '检测中…', state: 'pending' }, + { id: 'gpu', label: '显卡', title: '检测中…', state: 'pending' }, + { id: 'disk', label: '磁盘', title: '检测中…', state: 'pending' }, + ] + } + const cpu = data?.cpu_percent + const mem = data?.memory?.percent + const gpu = data?.hardware?.gpu + const worstDisk = data?.disks?.reduce((m, d) => (d.percent != null && d.percent > m ? d.percent : m), 0) ?? 0 + + const cpuState: FlowStep['state'] = + cpu == null ? 'idle' : cpu >= 85 ? 'warn' : 'active' + const memState: FlowStep['state'] = + mem == null ? 'idle' : mem >= 90 ? 'warn' : 'active' + let gpuState: FlowStep['state'] = 'idle' + if (gpu?.nvenc_available) gpuState = 'active' + else if (gpu?.nvidia_detected) gpuState = 'warn' + else if (gpu) gpuState = 'error' + const diskState: FlowStep['state'] = + worstDisk >= 90 ? 'warn' : worstDisk > 0 ? 'active' : 'idle' + + return [ + { + id: 'cpu', + label: 'CPU', + title: cpu != null ? `${cpu.toFixed(0)}%` : '—', + state: cpuState, + }, + { + id: 'mem', + label: '内存', + title: mem != null ? `${mem.toFixed(0)}%` : '—', + state: memState, + }, + { + id: 'gpu', + label: '显卡', + title: gpuShort(gpu), + state: gpuState, + }, + { + id: 'disk', + label: '磁盘', + title: worstDisk > 0 ? `最高 ${worstDisk.toFixed(0)}%` : '—', + state: diskState, + }, + ] +} + +function SystemStatusChips({ items, loading }: { items: StatusChip[]; loading: boolean }) { + if (loading) { + return

资源检测中…

+ } return ( -
-
{label}
-
{value}
+
+ {items.map((item, i) => ( + + + {item.emoji} + + + {item.ok ? '✓' : '○'} + + {item.label} + + ))}
) } @@ -129,134 +227,269 @@ export default function SystemMetrics() { const hw = data?.hardware const mem = data?.memory const cpuFreq = hw?.cpu_freq + const worstDisk = data?.disks?.reduce( + (max, d) => (d.percent != null && d.percent > max ? d.percent : max), + 0, + ) + + const lead = useMemo(() => { + if (!hasCachedData) return '本机硬件与资源占用' + const parts = [ + sys?.hostname ? `🖥️ ${sys.hostname}` : null, + fmtOsName(sys), + sys?.boot_time_unix != null ? `⏱ ${fmtUptime(sys.boot_time_unix)}` : null, + ] + return parts.filter(Boolean).join(' · ') || '本机硬件与资源占用' + }, [hasCachedData, sys]) + + const flowSteps = useMemo( + () => systemFlowSteps(data, refreshing && !hasCachedData), + [data, refreshing, hasCachedData], + ) + + const statusChips = useMemo(() => { + if (!hasCachedData) return [] + const cpu = data?.cpu_percent + const memPct = mem?.percent + const gpu = hw?.gpu + return [ + { + id: 'cpu', + emoji: '🧠', + label: 'CPU', + ok: cpu == null || cpu < 85, + hint: cpu != null ? `${cpu.toFixed(0)}%` : undefined, + }, + { + id: 'mem', + emoji: '💾', + label: '内存', + ok: memPct == null || memPct < 90, + hint: memPct != null ? `${memPct.toFixed(0)}%` : undefined, + }, + { + id: 'gpu', + emoji: '🎬', + label: 'NVENC', + ok: !!gpu?.nvenc_available, + hint: gpuShort(gpu), + }, + { + id: 'disk', + emoji: '📀', + label: '磁盘', + ok: worstDisk == null || worstDisk < 90, + hint: worstDisk != null && worstDisk > 0 ? `最高 ${worstDisk.toFixed(0)}%` : undefined, + }, + ] + }, [hasCachedData, data?.cpu_percent, mem?.percent, hw?.gpu, worstDisk]) + + const specItems = useMemo(() => { + const loading = refreshing && !sys + return [ + { emoji: '🪟', label: '系统', value: loading ? '检测中…' : fmtOsName(sys), icon: IconMonitor }, + { + emoji: '⚙️', + label: '处理器', + value: loading ? '检测中…' : shortProcessor(sys?.processor, hw?.cpu_logical), + icon: IconCpu, + }, + { + emoji: '🧮', + label: '核心', + value: + hw?.cpu_physical != null && hw?.cpu_logical != null + ? `${hw.cpu_physical} 物理 / ${hw.cpu_logical} 逻辑` + : '—', + icon: IconCpu, + }, + { emoji: '🎮', label: '显卡', value: gpuShort(hw?.gpu), icon: IconGpu }, + { emoji: '💿', label: '内存', value: fmtBytes(hw?.memory_total ?? mem?.total), icon: IconRam }, + { emoji: '🏗️', label: '架构', value: sys?.machine || '—', icon: IconDisk }, + ] + }, [refreshing, sys, hw, mem]) + + const showBody = hasCachedData || refreshing + const bodyLoading = refreshing return (
-
-
+
+ + +
+ +
+
+ + +
-

- - 🖥️ - - 硬件与系统 +

+ 系统

-

本机操作系统、处理器、显卡与资源占用

-
-
- ⏱ 运行 {fmtUptime(sys?.boot_time_unix)} - +

{lead}

- - {err ? ( -

- {err} -

- ) : null} - -
-
-

系统信息

-
- - - - - -
-
- -
-

硬件信息

-
- - - - - -
-
+
+
+
-

资源占用

-
-
-
CPU
-
- {data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'} -
- -
逻辑处理器 {hw?.cpu_logical ?? '—'} 个
-
-
-
内存
-
{mem?.percent != null ? `${mem.percent.toFixed(1)}%` : '—'}
- -
- {fmtBytes(mem?.used)} / {fmtBytes(mem?.total)}(可用 {fmtBytes(mem?.available)}) -
-
+ {showBody ? : null} + + {statusChips.length > 0 ? ( + + ) : null} + + {err ? ( +

+ {err} +

+ ) : null} + + {!showBody ? ( +
+ + 🖥️ + +

点击「开始检测」查看本机资源

+ ) : ( +
+
+
+

+ 📋 本机信息 +

+
    + {specItems.map((item, i) => { + const Icon = item.icon + return ( +
  • + + + {item.emoji} + + + + +
    + {item.label} + {item.value} +
    +
  • + ) + })} +
+
- {data?.swap && data.swap.total != null && data.swap.total > 0 ? ( -
- 交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '} - {fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)} -
- ) : null} - - {data?.disks && data.disks.length > 0 ? ( -
-
磁盘空间
-
    - {data.disks.slice(0, 8).map((d) => ( -
  • - - {d.mountpoint} - {d.fstype ? ` (${d.fstype})` : ''} +
    +
    + +
    + + 🧠 -
    - -
    - - {d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)} +

    CPU

    + +
    + +

    + {cpuFreq?.max_mhz ? `⚡ 最高 ${cpuFreq.max_mhz} MHz` : `逻辑核 ${hw?.cpu_logical ?? '—'}`} +

    +
    + +
    + +
    + + 💾 -
  • - ))} -
+

内存

+ +
+ +

+ {fmtBytes(mem?.used)} / {fmtBytes(mem?.total)} + {mem?.available != null ? ` · 可用 ${fmtBytes(mem.available)}` : ''} +

+ +
+ + {data?.swap && data.swap.total != null && data.swap.total > 0 ? ( +
+

+ 🔁 交换分区 +

+

+ {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} · {fmtBytes(data.swap.used)} /{' '} + {fmtBytes(data.swap.total)} +

+ +
+ ) : null} + + {data?.disks && data.disks.length > 0 ? ( +
+

+ 📀 磁盘 +

+
    + {data.disks.slice(0, 8).map((d, i) => ( +
  • + + + {d.mountpoint || '—'} + + + + {d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} · 余 {fmtBytes(d.free)} + +
  • + ))} +
+
+ ) : null}
- ) : null} -
+
+ )} ) } diff --git a/electron/src/renderer/src/components/network/IpDiagnosticsBoard.tsx b/electron/src/renderer/src/components/network/IpDiagnosticsBoard.tsx new file mode 100644 index 0000000..730b482 --- /dev/null +++ b/electron/src/renderer/src/components/network/IpDiagnosticsBoard.tsx @@ -0,0 +1,218 @@ +import MetricBar from '../ui/MetricBar' +import ScoreRing from '../ui/ScoreRing' +import LivePulse from '../ui/LivePulse' +import { IconRoute, IconShield, IconTip } from './NetworkIcons' + +function qualityBarTone(score: number): 'ok' | 'warn' | 'bad' | 'neutral' { + if (score >= 70) return 'ok' + if (score >= 45) return 'warn' + return 'bad' +} + +export type IpCheck = { id: string; label: string; ok: boolean; hint?: string } + +type CheckMeta = { + emoji: string + title: string + tip: string + tone: 'bad' | 'warn' +} + +const CHECK_META: Record = { + not_datacenter: { + emoji: '🏢', + title: '机房 IP', + tip: '推流易被识别为机房段,建议换住宅或 ISP 出口', + tone: 'bad', + }, + residential_like: { + emoji: '🏠', + title: '住宅宽带', + tip: '当前不像家庭宽带,静态住宅线路推流更稳', + tone: 'warn', + }, + not_proxy: { + emoji: '🛡️', + title: '代理标记', + tip: '出口被识别为代理/VPN,可能影响平台信任', + tone: 'bad', + }, + native_ip: { + emoji: '🌍', + title: '原生 IP', + tip: '疑似广播或非原生段,部分平台风控更严', + tone: 'warn', + }, + quality_ok: { + emoji: '📊', + title: 'IP 洁净度', + tip: '评分偏低,建议更换更干净的出口', + tone: 'warn', + }, + split_exit: { + emoji: '🔀', + title: '分流', + tip: '拉流和推流应走不同出口', + tone: 'warn', + }, + tz_match: { + emoji: '🕐', + title: '时区一致', + tip: '系统时区与 IP 归属不一致,可能被判定异常', + tone: 'warn', + }, + lang_soft: { + emoji: '🌐', + title: '语言一致', + tip: '浏览器语言与 IP 地区不匹配,仅作参考', + tone: 'warn', + }, + webrtc: { + emoji: '📡', + title: 'WebRTC', + tip: '检测到本地网卡候选地址,可能泄露内网信息', + tone: 'bad', + }, +} + +const PASS_LABELS: Record = { + not_datacenter: '非机房', + residential_like: '住宅宽带', + not_proxy: '无代理', + native_ip: '原生 IP', + quality_ok: '洁净度 OK', + split_exit: '已分流', + tz_match: '时区 OK', + lang_soft: '语言 OK', + webrtc: 'WebRTC 安全', +} + +function parseQualityScore(hint?: string, fallback?: number): number | null { + if (fallback != null && Number.isFinite(fallback)) return fallback + if (!hint) return null + const m = hint.match(/(\d+(?:\.\d+)?)/) + return m ? Number(m[1]) : null +} + +function readinessTone(pct: number): 'ok' | 'warn' | 'bad' | 'neutral' { + if (pct >= 80) return 'ok' + if (pct >= 50) return 'warn' + return 'bad' +} + +type Props = { + checks: IpCheck[] + qualityScore?: number | null +} + +export default function IpDiagnosticsBoard({ checks, qualityScore }: Props) { + if (!checks.length) return null + + const passed = checks.filter((c) => c.ok) + const failed = checks.filter((c) => !c.ok) + const passPct = Math.round((passed.length / checks.length) * 100) + const ringTone = readinessTone(passPct) + + return ( +
+
+
+

+ 推流就绪度 +

+

+ {failed.length === 0 ? ( + <> + + ✅ + {' '} + 出口检查全部通过 + + ) : ( + <> + {failed.length} 项需关注 · {passed.length}/{checks.length} 已通过 + + )} +

+ +
+ +
+ + {failed.length > 0 ? ( +
+
+ 需关注 +
+
    + {failed.map((check, idx) => { + const meta = CHECK_META[check.id] ?? { + emoji: '⚠️', + title: check.label, + tip: check.hint || '建议检查代理与出口配置', + tone: 'warn' as const, + } + const qScore = + check.id === 'quality_ok' ? parseQualityScore(check.hint, qualityScore ?? undefined) : null + + return ( +
  • + + {meta.emoji} + +
    + {meta.title} +

    {meta.tip}

    + {qScore != null ? ( + + ) : null} + {check.id === 'split_exit' ? ( + + YouTube 单独走代理 + + ) : null} +
    + + ○ + +
  • + ) + })} +
+
+ ) : null} + + {passed.length > 0 ? ( +
+
已通过 {passed.length} 项
+
+ {passed.map((check) => ( + + + ✓ + + {PASS_LABELS[check.id] ?? check.label} + + ))} +
+
+ ) : null} +
+ ) +} diff --git a/electron/src/renderer/src/components/network/IpProfilePanel.tsx b/electron/src/renderer/src/components/network/IpProfilePanel.tsx index 6e5f006..3ea4e1a 100644 --- a/electron/src/renderer/src/components/network/IpProfilePanel.tsx +++ b/electron/src/renderer/src/components/network/IpProfilePanel.tsx @@ -1,5 +1,6 @@ import ScoreRing from '../ui/ScoreRing' import { IconGlobe, IconShield } from './NetworkIcons' +import IpDiagnosticsBoard from './IpDiagnosticsBoard' export type IpExitProfile = { label?: string @@ -61,71 +62,17 @@ function scoreTone(score: number): 'ok' | 'warn' | 'bad' | 'neutral' { return 'bad' } -function ExitCard({ data, accent }: { data: IpExitProfile; accent: 'default' | 'overseas' }) { - const loading = !data.ip && !data.error - const score = data.quality_score ?? 0 - - return ( -
-
- - {typeEmoji(data.ip_type)} - -
-

{data.label || '出口'}

-

{loading ? '检测中…' : data.ip || '—'}

-
- {!loading && data.quality_score != null ? ( - - ) : null} -
- - {!loading && ( - <> -
- - {data.ip_type_label || '未知'} - - {data.native_label ? ( - - {data.native_label} - - ) : null} - {data.proxy ? 代理 : null} - {data.hosting ? 机房 : null} -
- -
-
-
归属
-
{[data.country, data.city].filter(Boolean).join(' · ') || '—'}
-
-
-
运营商
-
{data.isp || data.org || '—'}
-
-
-
ASN
-
{data.asn || data.ping0?.asn || '—'}
-
-
-
洁净度
-
{data.quality_label || '—'}
-
-
- - {data.ping0?.location ? ( -

- ping0 - {data.ping0.location} -

- ) : null} - - )} - - {data.error ?

{data.error}

: null} -
- ) +function routeHint( + primary?: IpExitProfile | null, + overseas?: IpExitProfile | null, +): { tone: 'ok' | 'warn' | 'neutral'; text: string } | null { + const a = primary?.ip?.trim() + const b = overseas?.ip?.trim() + if (!a) return null + if (!b || a === b) { + return { tone: 'neutral', text: '未分流' } + } + return { tone: 'ok', text: '已分流' } } type Props = { @@ -134,21 +81,26 @@ type Props = { error?: string | null } -/** 双出口卡片 + 未通过项摘要(不再占用侧栏清单) */ +/** 单卡片展示出口 IP,分流信息一行说明 */ export default function IpProfilePanel({ data, loading, error }: Props) { + const exit = data?.default_exit const headline = data?.summary?.headline const residential = data?.summary?.residential_like + const score = exit?.quality_score ?? 0 + const showExit = !!exit?.ip || !!exit?.error + const route = routeHint(exit, data?.overseas_exit) + const checks = data?.checks ?? [] return (

- 静态住宅 IP + 出口 IP

-

默认出口与海外采样对比(参考 whoer / ping0)

+

{headline || '检测当前公网出口是否适合推流'}

- {headline ? ( + {showExit && !loading ? ( {residential ? '偏住宅' : '需关注'} @@ -164,25 +116,60 @@ export default function IpProfilePanel({ data, loading, error }: Props) {

)} -
- - -
+
+
+ + {typeEmoji(exit?.ip_type)} + +
+

当前出口

+

+ {loading && !showExit ? '检测中…' : exit?.ip || '—'} +

+
+ {showExit && exit?.quality_score != null ? ( + + ) : null} +
- {!loading && (data?.checks ?? []).some((c) => !c.ok) ? ( -
    - {(data?.checks ?? []) - .filter((c) => !c.ok) - .map((c) => ( -
  • - {c.label} - {c.hint ? ` — ${c.hint}` : ''} -
  • - ))} -
+ {showExit && !loading && ( + <> +
+ + {exit?.ip_type_label || '未知'} + + {exit?.native_label ? ( + + {exit.native_label} + + ) : null} + {exit?.proxy ? 代理 : null} + {exit?.hosting ? 机房 : null} +
+ +
+
+
归属
+
{[exit?.country, exit?.city].filter(Boolean).join(' · ') || '—'}
+
+
+
运营商
+
{exit?.isp || exit?.org || '—'}
+
+
+ + {route ? ( +

{route.text}

+ ) : null} + + )} + + {exit?.error ?

{exit.error}

: null} +
+ + {!loading && checks.length > 0 ? ( + ) : null} - - {data?.meta?.disclaimer ?

{data.meta.disclaimer}

: null}
) } diff --git a/electron/src/renderer/src/components/network/NetworkIcons.tsx b/electron/src/renderer/src/components/network/NetworkIcons.tsx index 2089cbf..8033e77 100644 --- a/electron/src/renderer/src/components/network/NetworkIcons.tsx +++ b/electron/src/renderer/src/components/network/NetworkIcons.tsx @@ -122,3 +122,13 @@ export function IconTip({ className, size = 18 }: IconProps) { ) } + +export function IconMonitor({ className, size = 20 }: IconProps) { + const p = base(size) + return ( + + + + + ) +} diff --git a/electron/src/renderer/src/components/network/NetworkInsightOffer.tsx b/electron/src/renderer/src/components/network/NetworkInsightOffer.tsx new file mode 100644 index 0000000..4d6a6ba --- /dev/null +++ b/electron/src/renderer/src/components/network/NetworkInsightOffer.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react' +import { openNomadro, type NomadroTopic } from '@shared/monetizationLinks' +import type { NetworkOfferKind } from '@shared/networkOfferLogic' +import { IconGlobe, IconRoute } from './NetworkIcons' + +type OfferConfig = { + topic: NomadroTopic + campaign: string + title: string + tip: string + cta: string + tone: 'bad' | 'warn' + Icon: typeof IconRoute +} + +const OFFERS: Record = { + vps_node: { + topic: 'vps', + campaign: 'google-blocked', + title: 'Google 出口不可达', + tip: '境外 VPS 自建节点,YouTube 单独走代理。', + cta: 'VPS 选型与搭建', + tone: 'bad', + Icon: IconRoute, + }, + residential_ip: { + topic: 'residential', + campaign: 'ip-quality-low', + title: '出口指纹偏机房', + tip: 'Google 虽可达,静态住宅 ISP 段推流更稳、风控更低。', + cta: '静态住宅 IP 方案', + tone: 'warn', + Icon: IconGlobe, + }, +} + +type Props = { + kind: NetworkOfferKind +} + +export default function NetworkInsightOffer({ kind }: Props) { + const offer = OFFERS[kind] + const Icon = offer.Icon + + return ( +
+
+ + + +
+ + {offer.title} + +

{offer.tip}

+ +
+ + ○ + +
+
+ ) +} + +export function NomadroDocLink({ + topic = 'docs', + campaign, + children, + className = '', +}: { + topic?: NomadroTopic + campaign?: string + children: ReactNode + className?: string +}) { + return ( + + ) +} diff --git a/electron/src/renderer/src/components/network/NetworkPipeline.tsx b/electron/src/renderer/src/components/network/NetworkPipeline.tsx index 849087d..3690c0c 100644 --- a/electron/src/renderer/src/components/network/NetworkPipeline.tsx +++ b/electron/src/renderer/src/components/network/NetworkPipeline.tsx @@ -58,7 +58,7 @@ function IconUploadStep() { const STEP_META = [ { id: 'source', tone: 'source', icon: IconSource, fallback: '国内源站' }, - { id: 'route', tone: 'route', icon: IconRouter, fallback: '线路分流' }, + { id: 'route', tone: 'route', icon: IconRouter, fallback: '分流' }, { id: 'youtube', tone: 'youtube', icon: IconYoutube, fallback: 'YouTube' }, { id: 'upload', tone: 'upload', icon: IconUploadStep, fallback: '上行推流' }, ] as const diff --git a/electron/src/renderer/src/components/system/SystemIcons.tsx b/electron/src/renderer/src/components/system/SystemIcons.tsx new file mode 100644 index 0000000..867634d --- /dev/null +++ b/electron/src/renderer/src/components/system/SystemIcons.tsx @@ -0,0 +1,51 @@ +type IconProps = { className?: string; size?: number } + +function base(size: number) { + return { width: size, height: size, viewBox: '0 0 24 24', fill: 'none' as const, 'aria-hidden': true } +} + +export function IconCpu({ className, size = 22 }: IconProps) { + const p = base(size) + return ( + + + + + + ) +} + +export function IconRam({ className, size = 22 }: IconProps) { + const p = base(size) + return ( + + + + + + ) +} + +export function IconGpu({ className, size = 22 }: IconProps) { + const p = base(size) + return ( + + + + + + + + ) +} + +export function IconDisk({ className, size = 22 }: IconProps) { + const p = base(size) + return ( + + + + + + ) +} diff --git a/electron/src/renderer/src/components/system/SystemPipeline.tsx b/electron/src/renderer/src/components/system/SystemPipeline.tsx new file mode 100644 index 0000000..59f6818 --- /dev/null +++ b/electron/src/renderer/src/components/system/SystemPipeline.tsx @@ -0,0 +1,69 @@ +import type { FlowStep } from '../worker/WorkerFlowStrip' +import { IconCpu, IconDisk, IconGpu, IconRam } from './SystemIcons' + +function FlowArrow({ active }: { active: boolean }) { + return ( +
+ + + + + + +
+ ) +} + +const STEP_META = [ + { id: 'cpu', tone: 'cpu', emoji: '🧠', icon: IconCpu, fallback: 'CPU' }, + { id: 'mem', tone: 'mem', emoji: '💾', icon: IconRam, fallback: '内存' }, + { id: 'gpu', tone: 'gpu', emoji: '🎮', icon: IconGpu, fallback: '显卡' }, + { id: 'disk', tone: 'disk', emoji: '📀', icon: IconDisk, fallback: '磁盘' }, +] as const + +type Props = { + steps: FlowStep[] + loading?: boolean +} + +export default function SystemPipeline({ steps, loading = false }: Props) { + const byId = Object.fromEntries(steps.map((s) => [s.id, s])) as Record + + return ( +
+ {STEP_META.map((meta, idx) => { + const step = byId[meta.id] + const state = step?.state ?? 'idle' + const active = state === 'active' || state === 'pending' + const Icon = meta.icon + return ( +
+ {idx > 0 ? : null} +
+
+ + {meta.emoji} + + +
+
+ {step?.label ?? meta.fallback} + {step?.title ?? '—'} +
+ +
+
+ ) + })} +
+ ) +} diff --git a/electron/src/renderer/src/main.tsx b/electron/src/renderer/src/main.tsx index 4f1b25e..fd17b7c 100644 --- a/electron/src/renderer/src/main.tsx +++ b/electron/src/renderer/src/main.tsx @@ -5,6 +5,7 @@ import App from './App' import './App.css' import './ui-polish.css' import './network-polish.css' +import './system-polish.css' const root = document.getElementById('root') if (root) { diff --git a/electron/src/renderer/src/network-polish.css b/electron/src/renderer/src/network-polish.css index 6fe56f5..132919b 100644 --- a/electron/src/renderer/src/network-polish.css +++ b/electron/src/renderer/src/network-polish.css @@ -645,6 +645,36 @@ word-break: break-word; } +.ip-exit-card__grid--compact { + grid-template-columns: 1fr 1fr; +} + +.ip-route-hint { + margin: 10px 0 0; + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; + line-height: 1.45; +} + +.ip-route-hint--neutral { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.06); +} + +.ip-route-hint--ok { + color: var(--ok); + background: var(--ok-soft); + border: 1px solid rgba(72, 187, 120, 0.25); +} + +.ip-route-hint--warn { + color: var(--warn); + background: var(--warn-soft); + border: 1px solid rgba(246, 173, 85, 0.25); +} + .ip-exit-card__ping0 { margin: 10px 0 0; font-size: 11px; @@ -681,6 +711,178 @@ border: 1px solid rgba(246, 173, 85, 0.25); } +.ip-diag { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ip-diag__summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 12px; +} + +.ip-diag__summary-main { + flex: 1; + min-width: 0; +} + +.ip-diag__title { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0; + font-size: 13px; + font-weight: 700; + color: var(--text); +} + +.ip-diag__subtitle { + display: flex; + align-items: center; + gap: 6px; + margin: 4px 0 8px; + font-size: 12px; + color: var(--text-muted); +} + +.ip-diag__emoji { + font-size: 14px; +} + +.ip-diag__bar { + max-width: 280px; +} + +.ip-diag__section + .ip-diag__section { + margin-top: 12px; +} + +.ip-diag__section-title { + display: inline-flex; + align-items: center; + gap: 5px; + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--warn); +} + +.ip-diag__section-title--ok { + color: var(--success); +} + +.ip-diag__grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.ip-diag-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 8px; + align-items: start; + padding: 10px 10px 10px 8px; + border-radius: 12px; + border: 1px solid rgba(246, 173, 85, 0.22); + background: linear-gradient(145deg, rgba(246, 173, 85, 0.08), rgba(255, 255, 255, 0.02)); + animation: ip-diag-in 0.45s ease both; +} + +.ip-diag-card--bad { + border-color: rgba(245, 101, 101, 0.28); + background: linear-gradient(145deg, rgba(245, 101, 101, 0.1), rgba(255, 255, 255, 0.02)); +} + +.ip-diag-card__emoji { + font-size: 20px; + line-height: 1; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.25)); +} + +.ip-diag-card__body { + min-width: 0; +} + +.ip-diag-card__title { + display: block; + font-size: 12px; + font-weight: 700; + color: var(--text); +} + +.ip-diag-card__tip { + margin: 3px 0 0; + font-size: 11px; + line-height: 1.45; + color: var(--text-muted); +} + +.ip-diag-card__metric { + margin-top: 8px; +} + +.ip-diag-card__inline { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 6px; + font-size: 10px; + color: var(--accent); +} + +.ip-diag-card__mark { + font-size: 11px; + line-height: 1; + color: var(--warn); + opacity: 0.85; +} + +.ip-diag-card--bad .ip-diag-card__mark { + color: var(--danger); +} + +.ip-diag__chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.ip-diag-chip { + animation: ip-diag-chip-in 0.35s ease both; +} + +@keyframes ip-diag-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes ip-diag-chip-in { + from { + opacity: 0; + transform: scale(0.92); + } + to { + opacity: 1; + transform: scale(1); + } +} + .ip-profile-issues li + li { margin-top: 4px; } @@ -1372,6 +1574,84 @@ line-height: 1.45; } +/* 场景化推荐 — 复用 ip-diag-card 语言,单行紧凑 */ +.net-insight-strip { + margin: 0 0 2px; + animation: ip-diag-in 0.4s ease both; +} + +.net-insight-strip__card { + margin: 0; +} + +.net-insight-strip__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 10px; + flex-shrink: 0; +} + +.net-insight-strip__icon--bad { + color: var(--danger); + background: rgba(245, 101, 101, 0.12); +} + +.net-insight-strip__icon--warn { + color: var(--warn); + background: rgba(246, 173, 85, 0.12); +} + +.ip-diag-card__link { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 6px; + padding: 0; + border: none; + background: none; + font: inherit; + font-size: 11px; + font-weight: 600; + color: var(--accent); + cursor: pointer; + transition: color 0.15s ease, gap 0.15s ease; +} + +.ip-diag-card__link:hover { + color: color-mix(in srgb, var(--accent) 85%, white); + gap: 6px; +} + +.net-nomadro-link { + display: inline; + padding: 0; + margin: 0; + border: none; + background: none; + font: inherit; + color: rgba(140, 175, 255, 0.95); + text-decoration: underline; + text-decoration-color: rgba(140, 175, 255, 0.35); + text-underline-offset: 2px; + cursor: pointer; +} + +.net-nomadro-link:hover { + color: rgba(170, 200, 255, 1); + text-decoration-color: rgba(170, 200, 255, 0.55); +} + +.net-tips__hub { + margin: 14px 0 0; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + font-size: 12px; + color: rgba(160, 175, 210, 0.72); +} + @media (max-width: 960px) { .net-split-row { grid-template-columns: 1fr; diff --git a/electron/src/renderer/src/system-polish.css b/electron/src/renderer/src/system-polish.css new file mode 100644 index 0000000..7db6f06 --- /dev/null +++ b/electron/src/renderer/src/system-polish.css @@ -0,0 +1,357 @@ +/* —— 系统页:对齐网络页视觉语言 —— */ + +.feature-page--system { + position: relative; + isolation: isolate; + max-width: 960px; + gap: 0.85rem; +} + +.sys-page__bg { + position: absolute; + inset: -12px -8px 0; + z-index: -1; + pointer-events: none; + overflow: hidden; + border-radius: 18px; +} + +.sys-page__orb { + position: absolute; + border-radius: 50%; + filter: blur(48px); + opacity: 0.5; + animation: net-orb-float 10s ease-in-out infinite; +} + +.sys-page__orb--a { + width: 220px; + height: 220px; + top: -40px; + right: 8%; + background: rgba(124, 156, 255, 0.2); +} + +.sys-page__orb--b { + width: 180px; + height: 180px; + top: 100px; + left: -24px; + background: rgba(167, 139, 250, 0.14); + animation-delay: -4s; +} + +.sys-page__title-pulse { + animation: sys-icon-pulse 2.8s ease-in-out infinite; +} + +@keyframes sys-icon-pulse { + 0%, + 100% { + transform: scale(1); + filter: drop-shadow(0 0 0 transparent); + } + 50% { + transform: scale(1.04); + filter: drop-shadow(0 0 12px rgba(124, 156, 255, 0.35)); + } +} + +.sys-page > .net-pipeline { + margin-bottom: 2px; + animation: ui-fade-slide-up 0.42s var(--ease) both; +} + +/* 管线渐变 */ +.net-pipeline__step--cpu { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); +} + +.net-pipeline__step--mem { + background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); +} + +.net-pipeline__step--gpu { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); +} + +.net-pipeline__step--disk { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); +} + +.sys-pipeline__icon { + position: relative; +} + +.sys-pipeline__emoji { + position: absolute; + top: -4px; + right: -4px; + font-size: 11px; + line-height: 1; + filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.35)); +} + +.sys-pipeline.is-loading .sys-pipeline__step { + opacity: 0.88; +} + +.sys-status-chip { + animation: ip-diag-chip-in 0.35s ease both; +} + +.sys-status-chip__emoji { + font-size: 12px; + line-height: 1; +} + +.sys-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + margin: 8px 0 0; + padding: 36px 16px; + text-align: center; + font-size: 13px; + color: var(--text-muted); + border-radius: 14px; + border: 1px dashed var(--border-subtle); + background: rgba(255, 255, 255, 0.02); + animation: ui-fade-slide-up 0.4s var(--ease) both; +} + +.sys-empty__icon { + font-size: 2rem; + animation: sys-icon-pulse 2.4s ease-in-out infinite; +} + +.sys-layout.is-refreshing .sys-metric.is-loading::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.06) 50%, transparent 65%); + transform: translateX(-120%); + animation: ui-shimmer 1.8s ease-in-out infinite; + pointer-events: none; +} + +.net-block--sys { + border-color: rgba(124, 156, 255, 0.2); + background: linear-gradient(165deg, rgba(124, 156, 255, 0.05), var(--bg-card-elevated) 55%); + animation: ui-fade-slide-up 0.45s var(--ease) both; +} + +.net-block--sys-metrics { + border-color: rgba(167, 139, 250, 0.22); + animation: ui-fade-slide-up 0.5s var(--ease) both; +} + +.sys-spec-cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.sys-spec-card { + position: relative; + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 10px; + align-items: start; + padding: 10px 12px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: linear-gradient(145deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.01)); + overflow: hidden; + transition: transform 0.22s var(--ease), border-color 0.22s var(--ease); + animation: ip-diag-in 0.45s ease both; +} + +.sys-spec-card:hover { + transform: translateY(-2px); + border-color: rgba(124, 156, 255, 0.28); +} + +.sys-spec-card__glow { + position: absolute; + inset: 0; + background: linear-gradient(105deg, transparent 40%, rgba(255, 255, 255, 0.12) 50%, transparent 60%); + transform: translateX(-120%); + pointer-events: none; + opacity: 0; + transition: opacity 0.25s ease; +} + +.sys-spec-card:hover .sys-spec-card__glow { + opacity: 1; + animation: ui-shimmer 2.4s ease-in-out infinite; +} + +.sys-spec-card__emoji { + grid-row: span 2; + font-size: 22px; + line-height: 1; + filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.25)); +} + +.sys-spec-card__icon { + display: none; +} + +.sys-spec-card__label { + display: block; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); +} + +.sys-spec-card__value { + display: block; + font-size: 12px; + font-weight: 650; + line-height: 1.4; + color: var(--text); + word-break: break-word; +} + +.sys-metrics-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.sys-metric { + position: relative; + padding: 14px 16px; + border-radius: 14px; + border: 1px solid var(--border-card); + background: var(--bg-card-elevated); + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease); + animation: ip-diag-in 0.48s ease both; +} + +.sys-metric:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.sys-metric--cpu { + border-color: rgba(91, 157, 255, 0.24); +} + +.sys-metric--mem { + border-color: rgba(167, 139, 250, 0.26); +} + +.sys-metric__glow { + position: absolute; + inset: -1px; + border-radius: inherit; + padding: 1px; + background: linear-gradient(120deg, rgba(91, 157, 255, 0.3), rgba(167, 139, 250, 0.2), transparent 70%); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0; + transition: opacity 0.3s var(--ease); +} + +.sys-metric:hover .sys-metric__glow { + opacity: 0.7; +} + +.sys-metric__head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; +} + +.sys-metric__emoji { + font-size: 18px; + line-height: 1; +} + +.sys-metric__label { + flex: 1; + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.sys-metric__sub { + margin: 8px 0 0; + font-size: 11px; + line-height: 1.4; + color: var(--text-muted); +} + +.sys-swap-block .net-block__desc { + margin-bottom: 8px; +} + +.sys-disk-list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.sys-disk-list li { + display: grid; + grid-template-columns: minmax(88px, 0.5fr) minmax(0, 1fr) auto; + gap: 10px; + align-items: center; + font-size: 12px; + color: var(--text-muted); + animation: ip-diag-in 0.4s ease both; +} + +.sys-disk-list__mp { + display: inline-flex; + align-items: center; + gap: 5px; + font-weight: 600; + color: var(--text); +} + +.sys-disk-list__pct { + white-space: nowrap; +} + +@media (max-width: 820px) { + .sys-spec-cards { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sys-metrics-row { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .sys-spec-cards { + grid-template-columns: 1fr; + } + + .sys-disk-list li { + grid-template-columns: 1fr; + gap: 6px; + } +} diff --git a/electron/src/shared/monetizationLinks.ts b/electron/src/shared/monetizationLinks.ts new file mode 100644 index 0000000..90e6e7b --- /dev/null +++ b/electron/src/shared/monetizationLinks.ts @@ -0,0 +1,23 @@ +/** Nomadro 站外内容枢纽:教程 / 博客 / AdSense / 课程 / 公众号 */ + +export type NomadroTopic = 'home' | 'vps' | 'residential' | 'docs' | 'network' + +const NOMADRO_BASE = 'https://nomadro.com/' + +export function nomadroUrl(topic: NomadroTopic = 'home', campaign?: string): string { + const u = new URL(NOMADRO_BASE) + u.searchParams.set('utm_source', 'wuren-live') + u.searchParams.set('utm_medium', 'app') + if (topic !== 'home') u.searchParams.set('topic', topic) + if (campaign) u.searchParams.set('utm_campaign', campaign) + return u.toString() +} + +export async function openNomadro(topic: NomadroTopic = 'home', campaign?: string): Promise { + const url = nomadroUrl(topic, campaign) + const open = ( + window as Window & { recorderConsole?: { openExternal?: (u: string) => Promise } } + ).recorderConsole?.openExternal + if (open) await open(url) + else window.open(url, '_blank', 'noopener,noreferrer') +} diff --git a/electron/src/shared/networkOfferLogic.ts b/electron/src/shared/networkOfferLogic.ts new file mode 100644 index 0000000..3537c14 --- /dev/null +++ b/electron/src/shared/networkOfferLogic.ts @@ -0,0 +1,43 @@ +/** 网络页场景化推荐:Google 不可达 → VPS;可达但 IP 差 → 静态住宅 */ + +export type NetworkOfferKind = 'vps_node' | 'residential_ip' + +export type NetworkOfferView = { + ipProfile?: { + summary?: { residential_like?: boolean } + default_exit?: { + quality_score?: number + ip_type?: string + proxy?: boolean + hosting?: boolean + } + } | null + ipQuality?: { + summary?: { proxy_like?: boolean; hosting_like?: boolean } + } | null +} + +export function isPoorEgressQuality(view: NetworkOfferView): boolean { + const exit = view.ipProfile?.default_exit + if (view.ipProfile?.summary?.residential_like === false) return true + if (view.ipQuality?.summary?.proxy_like || view.ipQuality?.summary?.hosting_like) return true + const score = exit?.quality_score + if (score != null && score < 70) return true + const t = exit?.ip_type + if (t === 'datacenter' || t === 'proxy') return true + if (exit?.hosting || exit?.proxy) return true + return false +} + +export function resolveNetworkOffer(params: { + googleReachable: boolean + loading: boolean + hasData: boolean + view: NetworkOfferView +}): NetworkOfferKind | null { + const { googleReachable, loading, hasData, view } = params + if (loading || !hasData) return null + if (!googleReachable) return 'vps_node' + if (googleReachable && isPoorEgressQuality(view)) return 'residential_ip' + return null +} diff --git a/web2_ip_profile.py b/web2_ip_profile.py index 6db317b..c60174f 100644 --- a/web2_ip_profile.py +++ b/web2_ip_profile.py @@ -333,9 +333,9 @@ def _build_checks( split = d_ip != o_ip add( "split_exit", - "默认出口与海外采样出口不同", + "分流", split, - "分流可能正常" if split else "两路出口相同,可能未分流", + "两路 IP 不同" if split else "两路 IP 相同", ) if client_tz and d.get("country_code"): @@ -401,8 +401,8 @@ async def run_ip_profile( if overseas_ip: overseas_api = await _ip_api_lookup(client, overseas_ip) - default_exit = _build_exit_profile("默认出口", default_api, ping0) - overseas_exit = _build_exit_profile("海外采样出口", overseas_api) + default_exit = _build_exit_profile("当前出口", default_api, ping0) + overseas_exit = _build_exit_profile("海外线路", overseas_api) checks = _build_checks( default_exit, diff --git a/web2_network.py b/web2_network.py index dfa18bb..e57ce6b 100644 --- a/web2_network.py +++ b/web2_network.py @@ -30,12 +30,18 @@ DOMESTIC_UPLOAD_URLS = ( "https://www.163.com/", ) -IP_API_TEMPLATE = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,message,query" +IP_API_TEMPLATE = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city,isp,org,as,asname,message,query" +IP_API_SELF = ( + "http://ip-api.com/json/?fields=status,message,query,country,countryCode," + "regionName,city,isp,org,as,asname,proxy,hosting,mobile" +) +IPIFY_URL = "https://api.ipify.org?format=json" DOWNLOAD_CAP_BYTES = 600_000 UPLOAD_BYTES = 256 * 1024 CONNECT_TIMEOUT = 8.0 READ_TIMEOUT = 25.0 +NETWORK_LOGIC_VERSION = 2 def _is_probably_china_country(code: Optional[str]) -> bool: @@ -59,28 +65,109 @@ async def _resolve_ipv4(host: str) -> tuple[Optional[str], Optional[str]]: return await asyncio.to_thread(_sync) -async def _country_for_ip(client: httpx.AsyncClient, ip: str) -> dict[str, Any]: - if not ip: - return {"country": None, "country_code": None, "error": "no ip"} +async def _geo_for_ip(client: httpx.AsyncClient, ip: Optional[str] = None) -> dict[str, Any]: + url = IP_API_SELF if not ip else IP_API_TEMPLATE.format(ip=ip) try: - r = await client.get(IP_API_TEMPLATE.format(ip=ip), timeout=6.0) + r = await client.get(url, timeout=8.0) r.raise_for_status() data = r.json() except Exception as e: - return {"country": None, "country_code": None, "error": str(e)} + return {"error": str(e)} if data.get("status") != "success": return { - "country": None, - "country_code": None, "error": data.get("message") or "lookup failed", + "ip": ip or data.get("query"), } return { + "ip": data.get("query") or ip, "country": data.get("country"), "country_code": data.get("countryCode"), + "region": data.get("regionName"), + "city": data.get("city"), + "isp": data.get("isp"), + "org": data.get("org"), + "asn": data.get("as"), + "asname": data.get("asname"), "error": None, } +async def _country_for_ip(client: httpx.AsyncClient, ip: str) -> dict[str, Any]: + geo = await _geo_for_ip(client, ip) + return { + "country": geo.get("country"), + "country_code": geo.get("country_code"), + "error": geo.get("error"), + } + + +async def _probe_default_egress(client: httpx.AsyncClient) -> dict[str, Any]: + """本机默认出口:优先 ip-api 直连,失败则用 ipify + 反查。""" + geo = await _geo_for_ip(client, None) + if geo.get("ip") and not geo.get("error"): + return geo + fallback = await _sample_overseas_ip(client) + if fallback.get("ip") and not fallback.get("error"): + return fallback + return geo if geo.get("ip") else fallback + + +async def _sample_overseas_ip(client: httpx.AsyncClient) -> dict[str, Any]: + try: + r = await client.get(IPIFY_URL, timeout=8.0) + r.raise_for_status() + ip = (r.json().get("ip") or "").strip() + if not ip: + return {"error": "empty ip"} + return await _geo_for_ip(client, ip) + except Exception as e: + return {"error": str(e)} + + +def _format_isp_line(geo: dict[str, Any]) -> str: + isp = (geo.get("isp") or geo.get("org") or "").strip() + country = (geo.get("country") or "").strip() + city = (geo.get("city") or "").strip() + loc = " · ".join(x for x in (country, city) if x) + if isp and loc: + return f"{isp}({loc})" + return isp or loc or "未知运营商" + + +def _compute_routes( + egress: dict[str, Any], + overseas: dict[str, Any], + *, + google_ok: bool, + douyin_ok: bool, +) -> tuple[Optional[bool], str]: + ip = egress.get("ip") + o_ip = overseas.get("ip") + cc = (egress.get("country_code") or "").upper() + + if egress.get("error") and not ip: + if google_ok and douyin_ok: + return None, "运营商未识别" + return None, "出口未识别,请重检" + + if ip and o_ip and ip != o_ip: + return True, "已分流" + + cn = _is_probably_china_country(cc) + if google_ok and douyin_ok: + if cn: + return True, "已分流" + return False, "未分流" + + if douyin_ok and not google_ok: + return False, "Google 不通" + + if google_ok and not douyin_ok: + return False, "抖音异常" + + return False, "均不可达" + + async def _latency_get(client: httpx.AsyncClient, url: str) -> tuple[bool, float, Optional[str]]: t0 = time.monotonic() try: @@ -147,7 +234,7 @@ async def _try_domestic_upload(client: httpx.AsyncClient) -> tuple[Optional[floa async def run_network_dashboard() -> dict[str, Any]: """ 返回结构供前端仪表盘使用。 - routes_different:基于解析 IP 的国家码;若 API 失败则给 likely / unknown。 + routes_different:基于本机出口 IP/运营商 + Google/抖音可达性判断,不再用 CDN 解析 IP 比国家。 """ timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) limits = httpx.Limits(max_keepalive_connections=4, max_connections=8) @@ -170,42 +257,43 @@ async def run_network_dashboard() -> dict[str, Any]: g_country_task = _country_for_ip(client, g_ip) if g_ip else _empty_geo() d_country_task = _country_for_ip(client, d_ip) if d_ip else _empty_geo() + egress_task = _probe_default_egress(client) + overseas_task = _sample_overseas_ip(client) ( (g_ok, g_ms, g_err), (d_ok, d_ms, d_err), g_geo, d_geo, + egress, + overseas, ) = await asyncio.gather( _latency_get(client, GOOGLE_CHECK_URL), _latency_get(client, DOUYIN_CHECK_URL), g_country_task, d_country_task, + egress_task, + overseas_task, ) if not isinstance(g_geo, dict): g_geo = {"country": None, "country_code": None, "error": None} if not isinstance(d_geo, dict): d_geo = {"country": None, "country_code": None, "error": None} + if not isinstance(egress, dict): + egress = {"error": "egress probe failed"} + if not isinstance(overseas, dict): + overseas = {"error": "overseas probe failed"} g_code = g_geo.get("country_code") d_code = d_geo.get("country_code") - routes_different: Optional[bool] = None - routes_note = "" - if g_code and d_code: - routes_different = _is_probably_china_country(g_code) != _is_probably_china_country(d_code) - routes_note = "依据解析 IP 的国家码:海外站点与国内站点出口不一致" if routes_different else ( - "依据解析 IP:两国别相同或未区分境内外(若您期望分流,请检查代理/路由)" - ) - else: - routes_note = "未能查询到双方 IP 国家,无法严格判断线路是否分流" - if g_ip and d_ip and g_ip != d_ip: - routes_note += ";两站点解析 IP 不同,通常走不同目标。" - if g_res_err: - routes_note += f" Google DNS: {g_res_err}" - if d_res_err: - routes_note += f" 抖音 DNS: {d_res_err}" + routes_different, routes_note = _compute_routes( + egress, + overseas, + google_ok=g_ok, + douyin_ok=d_ok, + ) # 测速(并行) od_mbps, od_err, od_bytes = await _measure_download_mbps(client, GOOGLE_DOWNLOAD_URL) @@ -238,6 +326,13 @@ async def run_network_dashboard() -> dict[str, Any]: "routes": { "different": routes_different, "note": routes_note.strip(), + "isp": egress.get("isp") or egress.get("org"), + "country": egress.get("country"), + "country_code": egress.get("country_code"), + }, + "egress": { + "default": egress, + "overseas_sample": overseas, }, "speed": { "overseas": { @@ -263,5 +358,6 @@ async def run_network_dashboard() -> dict[str, Any]: "at": time.time(), "download_cap_bytes": DOWNLOAD_CAP_BYTES, "upload_bytes": UPLOAD_BYTES, + "logic_version": NETWORK_LOGIC_VERSION, }, } diff --git a/web2_network_routes.py b/web2_network_routes.py index 15d50bb..65ad2f3 100644 --- a/web2_network_routes.py +++ b/web2_network_routes.py @@ -124,7 +124,7 @@ async def _get_ip_quality_cached(*, force_refresh: bool) -> dict[str, Any]: async def _get_network_status_cached(*, force_refresh: bool) -> dict[str, Any]: return await _cached_fetch( - "network", + "network_v2", ttl=_NETWORK_TTL_SECONDS, force_refresh=force_refresh, fetcher=run_network_dashboard,