424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
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 },
|
||
})
|