import { useCallback, useEffect, useState, type ReactNode } from 'react' import { View, Text, ScrollView, StyleSheet, RefreshControl, Pressable, TextInput, Switch, Linking, } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { useBind } from '@/context/BindContext' import { apiFetch } from '@/lib/api' import type { NetworkPayload, SiteInfo, SpeedBlock } from '@/lib/pcTypes' import { theme } from '@/constants/theme' const EMPTY_SITE: SiteInfo = { host: '', reachable: false, latency_ms: -1, error: null, resolved_ip: null, country: null, country_code: null, geo_error: null, } const EMPTY_SPEED: SpeedBlock = { label: '', download_mbps: null, download_error: null, download_bytes_sampled: 0, upload_mbps: null, upload_error: null, upload_target: null, } const DEFAULT_NET: NetworkPayload = { google: EMPTY_SITE, douyin: EMPTY_SITE, routes: { different: null, note: '' }, speed: { overseas: EMPTY_SPEED, domestic: EMPTY_SPEED }, } function fmtMbps(v: number | null | undefined): string { if (v == null || Number.isNaN(v)) return '—' return `${v.toFixed(2)} Mbps` } type ProxyState = { enabled: boolean; http: string; socks: string } function SiteCard({ title, site, speed, loading, variant, onSolution, }: { title: string site: SiteInfo speed: SpeedBlock loading: boolean variant: 'google' | 'douyin' onSolution?: () => void }) { const ok = site.reachable let badge: ReactNode if (loading) { badge = 检测中 } else if (ok) { badge = 可访问 } else if (variant === 'google' && onSolution) { badge = ( 查看解决方案 ) } else { badge = 不可访问 } return ( {title} {badge} 延迟 {loading ? '—' : site.latency_ms != null && site.latency_ms >= 0 ? `${site.latency_ms.toFixed(0)} ms` : '—'} 下行(估算) {loading ? '—' : fmtMbps(speed.download_mbps)} 上行(估算) {loading ? '—' : fmtMbps(speed.upload_mbps)} 解析 IP {loading ? '—' : site.resolved_ip ?? '—'} 地区(参考) {loading ? '—' : site.country ?? '—'} {!loading && site.country_code ? ` (${site.country_code})` : ''} {!loading && (site.error || site.geo_error) ? ( {[site.error, site.geo_error].filter(Boolean).join(' · ')} ) : null} ) } export default function NetworkScreen() { const { bind } = useBind() const [data, setData] = useState(null) const [err, setErr] = useState(null) const [loading, setLoading] = useState(false) const [proxy, setProxy] = useState({ enabled: false, http: '', socks: '' }) const [proxyMsg, setProxyMsg] = useState('') const [savingProxy, setSavingProxy] = useState(false) const [refreshing, setRefreshing] = useState(false) const loadNet = useCallback(async () => { if (!bind) { setErr(null) setData(null) return } setErr(null) setLoading(true) try { const r = await apiFetch(bind, '/network_status') const json = (await r.json()) as NetworkPayload & { error?: string } if (!r.ok) { setErr(json.error || `请求失败 ${r.status}`) setData(null) return } setData(json) } catch (e) { setErr(e instanceof Error ? e.message : String(e)) setData(null) } finally { setLoading(false) } }, [bind]) const loadProxy = useCallback(async () => { if (!bind) return setProxyMsg('') try { const pr = await apiFetch(bind, '/proxy_config') if (pr.ok) { const p = (await pr.json()) as ProxyState setProxy({ enabled: !!p.enabled, http: String(p.http || ''), socks: String(p.socks || ''), }) } } catch { /* ignore */ } }, [bind]) const saveProxy = useCallback(async () => { if (!bind) { setProxyMsg('请先配对') return } setSavingProxy(true) setProxyMsg('') try { const res = await apiFetch(bind, '/proxy_config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(proxy), }) if (!res.ok) throw new Error(String(res.status)) setProxyMsg('已保存。重启直播任务后生效。') } catch (e) { setProxyMsg(e instanceof Error ? e.message : String(e)) } finally { setSavingProxy(false) } }, [bind, proxy]) const loadAll = useCallback(async () => { await loadNet() await loadProxy() }, [loadNet, loadProxy]) useEffect(() => { void loadAll() }, [loadAll]) useEffect(() => { if (!bind) return const id = setInterval(() => void loadNet(), 45000) return () => clearInterval(id) }, [bind, loadNet]) const onRefresh = useCallback(async () => { setRefreshing(true) await loadAll() setRefreshing(false) }, [loadAll]) const view = data ?? DEFAULT_NET const openSolution = useCallback(() => { void Linking.openURL('https://nomadro.com/') }, []) return ( void onRefresh()} tintColor={theme.accent} /> ) : undefined } > 网络 连通与测速(与桌面端「网络仪表盘」同源)。 {!bind && ( 未绑定 PC:配对后可检测 Google / 抖音 与测速。 )} 网络仪表盘 void loadNet()} disabled={!bind || loading}> {loading ? '检测中…' : '重新检测'} {err && bind ? {err} : null} {view.routes?.note ? ( {view.routes.note} ) : null} 录制专用代理 仅对本软件拉起的 FFmpeg / 录制进程生效,不会修改系统代理。与桌面端「录制专用代理」一致。 启用仅录制进程使用的代理 setProxy((p) => ({ ...p, enabled: v }))} /> HTTP 代理 setProxy((p) => ({ ...p, http: t }))} placeholder="http://127.0.0.1:7890" placeholderTextColor={theme.muted} autoCapitalize="none" /> SOCKS5 setProxy((p) => ({ ...p, socks: t }))} placeholder="socks5://127.0.0.1:7890" placeholderTextColor={theme.muted} autoCapitalize="none" /> void saveProxy()} > {savingProxy ? '保存中…' : '保存'} void loadProxy()}> 重新读取 {!!proxyMsg && {proxyMsg}} ) } const styles = StyleSheet.create({ safe: { flex: 1, backgroundColor: theme.bg }, scroll: { padding: 16, paddingBottom: 40 }, h1: { fontSize: 20, fontWeight: '700', color: theme.text, marginBottom: 6 }, lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 }, banner: { padding: 12, borderRadius: 10, backgroundColor: 'rgba(124,92,255,0.08)', borderWidth: 1, borderColor: 'rgba(124,92,255,0.25)', marginBottom: 14, }, bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 }, cardNet: { padding: 14, borderRadius: 12, backgroundColor: theme.bgCard, borderWidth: 1, borderColor: 'rgba(124,92,255,0.28)', marginBottom: 14, }, cardProxy: { padding: 14, borderRadius: 12, backgroundColor: theme.bgCard, borderWidth: 1, borderColor: 'rgba(61,214,140,0.22)', marginBottom: 14, }, cardHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text }, btnSec: { paddingVertical: 8, paddingHorizontal: 12, borderRadius: 8, borderWidth: 1, borderColor: theme.border, backgroundColor: theme.bgElevated, }, btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 }, btnPri: { paddingVertical: 10, paddingHorizontal: 16, borderRadius: 10, backgroundColor: '#3d7eff', }, btnPriText: { color: '#fff', fontWeight: '700' }, btnDisabled: { opacity: 0.5 }, err: { color: theme.danger, marginBottom: 10, fontSize: 13 }, ok: { color: theme.success, marginTop: 8, fontSize: 13 }, siteRow: { gap: 12 }, siteCard: { padding: 12, borderRadius: 10, backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border, marginBottom: 4, }, siteHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }, siteTitle: { fontSize: 14, fontWeight: '600', color: theme.text, flex: 1 }, badgePending: { fontSize: 11, fontWeight: '700', color: theme.muted }, badgeOk: { fontSize: 11, fontWeight: '700', color: theme.success }, badgeBad: { fontSize: 11, fontWeight: '700', color: theme.danger }, badgeLink: { fontSize: 11, fontWeight: '700', color: theme.accent, textDecorationLine: 'underline' }, kvCol: { gap: 6 }, kvRow: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 8 }, dim: { opacity: 0.72 }, k: { fontSize: 12, color: theme.muted, fontWeight: '600', marginTop: 4 }, v: { fontSize: 13, color: theme.text }, mono: { fontFamily: 'monospace', fontSize: 12 }, siteErr: { fontSize: 11, color: theme.warn, marginTop: 8, lineHeight: 16 }, routesNote: { fontSize: 12, color: theme.muted, marginTop: 10, lineHeight: 18 }, desc: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 12 }, rowBetween: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }, label: { fontSize: 12, color: theme.muted, marginBottom: 6 }, input: { borderWidth: 1, borderColor: theme.border, borderRadius: 10, padding: 10, color: theme.text, marginBottom: 12, backgroundColor: theme.bg, }, inputDisabled: { opacity: 0.6 }, row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap', marginTop: 4 }, })