Files
2026-03-25 12:29:03 -05:00

424 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = <Text style={styles.badgePending}></Text>
} else if (ok) {
badge = <Text style={styles.badgeOk}>访</Text>
} else if (variant === 'google' && onSolution) {
badge = (
<Pressable onPress={onSolution}>
<Text style={styles.badgeLink}></Text>
</Pressable>
)
} else {
badge = <Text style={styles.badgeBad}>访</Text>
}
return (
<View style={styles.siteCard}>
<View style={styles.siteHead}>
<Text style={styles.siteTitle}>{title}</Text>
{badge}
</View>
<View style={[styles.kvCol, loading && styles.dim]}>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>
{loading
? '—'
: site.latency_ms != null && site.latency_ms >= 0
? `${site.latency_ms.toFixed(0)} ms`
: '—'}
</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>{loading ? '—' : fmtMbps(speed.download_mbps)}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>{loading ? '—' : fmtMbps(speed.upload_mbps)}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}> IP</Text>
<Text style={[styles.v, styles.mono]}>{loading ? '—' : site.resolved_ip ?? '—'}</Text>
</View>
<View style={styles.kvRow}>
<Text style={styles.k}></Text>
<Text style={styles.v}>
{loading ? '—' : site.country ?? '—'}
{!loading && site.country_code ? ` (${site.country_code})` : ''}
</Text>
</View>
</View>
{!loading && (site.error || site.geo_error) ? (
<Text style={styles.siteErr}>
{[site.error, site.geo_error].filter(Boolean).join(' · ')}
</Text>
) : null}
</View>
)
}
export default function NetworkScreen() {
const { bind } = useBind()
const [data, setData] = useState<NetworkPayload | null>(null)
const [err, setErr] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [proxy, setProxy] = useState<ProxyState>({ 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 (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}></Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC Google / </Text>
</View>
)}
<View style={styles.cardNet}>
<View style={styles.cardHead}>
<Text style={styles.cardTitle}></Text>
<Pressable style={styles.btnSec} onPress={() => void loadNet()} disabled={!bind || loading}>
<Text style={styles.btnSecText}>{loading ? '检测中…' : '重新检测'}</Text>
</Pressable>
</View>
{err && bind ? <Text style={styles.err}>{err}</Text> : null}
<View style={styles.siteRow}>
<SiteCard
title="Google海外参考"
site={view.google}
speed={view.speed.overseas}
loading={loading && !!bind}
variant="google"
onSolution={openSolution}
/>
<SiteCard
title="抖音(国内参考)"
site={view.douyin}
speed={view.speed.domestic}
loading={loading && !!bind}
variant="douyin"
/>
</View>
{view.routes?.note ? (
<Text style={styles.routesNote}>{view.routes.note}</Text>
) : null}
</View>
<View style={styles.cardProxy}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.desc}>
FFmpeg /
</Text>
<View style={styles.rowBetween}>
<Text style={styles.label}>使</Text>
<Switch
value={proxy.enabled}
disabled={!bind}
onValueChange={(v) => setProxy((p) => ({ ...p, enabled: v }))}
/>
</View>
<Text style={styles.label}>HTTP </Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxy.http}
editable={!!bind}
onChangeText={(t) => setProxy((p) => ({ ...p, http: t }))}
placeholder="http://127.0.0.1:7890"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.label}>SOCKS5</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxy.socks}
editable={!!bind}
onChangeText={(t) => setProxy((p) => ({ ...p, socks: t }))}
placeholder="socks5://127.0.0.1:7890"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<View style={styles.row}>
<Pressable
style={[styles.btnPri, (!bind || savingProxy) && styles.btnDisabled]}
disabled={!bind || savingProxy}
onPress={() => void saveProxy()}
>
<Text style={styles.btnPriText}>{savingProxy ? '保存中…' : '保存'}</Text>
</Pressable>
<Pressable style={styles.btnSec} disabled={!bind} onPress={() => void loadProxy()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
{!!proxyMsg && <Text style={proxyMsg.includes('已保存') ? styles.ok : styles.err}>{proxyMsg}</Text>}
</View>
</ScrollView>
</SafeAreaView>
)
}
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 },
})