Files
2026-03-26 01:59:04 -05:00

301 lines
11 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 } from 'react'
import { useFocusEffect } from 'expo-router'
import {
View,
Text,
ScrollView,
StyleSheet,
RefreshControl,
Pressable,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import type { ClientOverview, NetworkPayload, RelayLivePayload, SystemMetricsPayload } from '@/lib/pcTypes'
import { getMultiChannelPro } from '@/lib/storage'
import { theme } from '@/constants/theme'
function fmtTs(ts: unknown): string {
if (typeof ts !== 'number') return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
function fmtDur(sec: number | null | undefined): string {
if (sec == null || Number.isNaN(sec)) return '—'
const s = Math.floor(sec)
const h = Math.floor(s / 3600)
const m = Math.floor((s % 3600) / 60)
const r = s % 60
if (h > 0) return `${h}h${m}m${r}s`
if (m > 0) return `${m}m${r}s`
return `${r}s`
}
function SnapshotNet({ data }: { data: unknown }) {
if (!data || typeof data !== 'object') return <Text style={styles.muted}></Text>
const d = data as Record<string, unknown>
if ('google' in d && 'douyin' in d) {
const g = d.google as { reachable?: boolean; latency_ms?: number; country?: string }
const y = d.douyin as { reachable?: boolean; latency_ms?: number; country?: string }
return (
<View style={styles.snapGrid}>
<Text style={styles.snapLine}>
Google {g?.reachable ? '可达' : '不可达'} ·{' '}
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
</Text>
<Text style={styles.snapLine}>
{y?.reachable ? '可达' : '不可达'} ·{' '}
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
</Text>
</View>
)
}
return <Text style={styles.muted}></Text>
}
function SnapshotSys({ data }: { data: unknown }) {
if (!data || typeof data !== 'object') return <Text style={styles.muted}></Text>
const d = data as Record<string, unknown>
if ('cpu_percent' in d || 'memory' in d) {
const mem = d.memory as { percent?: number } | undefined
return (
<View style={styles.snapGrid}>
<Text style={styles.snapLine}>
CPU {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
</Text>
<Text style={styles.snapLine}> {mem?.percent != null ? `${mem.percent.toFixed(1)}%` : '—'}</Text>
</View>
)
}
return <Text style={styles.muted}></Text>
}
export default function DeskScreen() {
const { bind } = useBind()
const [overview, setOverview] = useState<ClientOverview | null>(null)
const [proLive, setProLive] = useState<RelayLivePayload | null>(null)
const [proMode, setProMode] = useState(false)
const [netSnap, setNetSnap] = useState<NetworkPayload | null>(null)
const [sysSnap, setSysSnap] = useState<SystemMetricsPayload | null>(null)
const [err, setErr] = useState('')
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setOverview(null)
setProLive(null)
setNetSnap(null)
setSysSnap(null)
setErr('')
return
}
setErr('')
try {
const r = await apiFetch(bind, '/client/overview')
if (r.ok) setOverview((await r.json()) as ClientOverview)
else setOverview(null)
const multi = await getMultiChannelPro()
setProMode(multi)
if (multi) {
const lr = await apiFetch(bind, '/relay_pro/live_status')
if (lr.ok) setProLive((await lr.json()) as RelayLivePayload)
else setProLive(null)
} else {
setProLive(null)
}
const nr = await apiFetch(bind, '/network_status')
if (nr.ok) setNetSnap((await nr.json()) as NetworkPayload)
else setNetSnap(null)
const sr = await apiFetch(bind, '/system_metrics')
if (sr.ok) setSysSnap((await sr.json()) as SystemMetricsPayload)
else setSysSnap(null)
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
}
}, [bind])
useFocusEffect(
useCallback(() => {
void load()
}, [load]),
)
useEffect(() => {
if (!bind || !proMode) return
const id = setInterval(() => void load(), 12000)
return () => clearInterval(id)
}, [bind, proMode, load])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
<View style={styles.head}>
<Text style={styles.h1}></Text>
<Pressable style={styles.btnSec} disabled={!bind} onPress={() => void load()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
<Text style={styles.lead}>
Pro / SQLiteApp PC FFmpeg
</Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
{!!err && bind ? <Text style={styles.err}>{err}</Text> : null}
{overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 ? (
<View style={styles.warnBox}>
{overview.relayValidationErrors.map((x, i) => (
<Text key={i} style={styles.warnText}>
{x}
</Text>
))}
</View>
) : null}
{proMode && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Pro </Text>
<Text style={styles.meta}>
{proLive?.youtubeProcess ?? '—'} · {proLive?.processStatus ?? '—'}{' '}
{proLive?.processOnline ? (
<Text style={styles.tagOn}></Text>
) : (
<Text style={styles.tagOff}></Text>
)}
</Text>
{(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => (
<View key={`${row.channelId ?? i}`} style={styles.liveRow}>
<Text style={styles.liveTitle}>
{row.channelName || '未命名频道'} · key {row.youtubeKeySuffix || '—'}
</Text>
<Text style={styles.liveLine}> {row.anchor ?? '—'}</Text>
<Text style={styles.liveLine} numberOfLines={2}>
{row.douyinHint ?? '—'}
</Text>
<Text style={styles.liveLine} numberOfLines={2}>
{row.streamTitle ?? '—'}
</Text>
<Text style={styles.liveLine}> {fmtDur(row.durationSec)}</Text>
</View>
))}
{!proLive?.relayRows?.length ? <Text style={styles.muted}></Text> : null}
</View>
)}
<View style={styles.rowCards}>
<View style={[styles.miniCard, { flex: 1 }]}>
<Text style={styles.miniTitle}></Text>
<Text style={styles.miniMeta}> {fmtTs(netSnap?.meta?.at)}</Text>
<SnapshotNet data={netSnap as unknown} />
</View>
<View style={[styles.miniCard, { flex: 1 }]}>
<Text style={styles.miniTitle}></Text>
<Text style={styles.miniMeta}> {fmtTs(sysSnap?.ts)}</Text>
<SnapshotSys data={sysSnap as unknown} />
</View>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.muted}>
Windows
</Text>
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 16, paddingBottom: 40 },
head: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 },
h1: { fontSize: 20, fontWeight: '700', color: theme.text },
lead: { fontSize: 14, color: theme.muted, marginBottom: 14, lineHeight: 22 },
banner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(91,157,255,0.1)',
borderWidth: 1,
borderColor: 'rgba(91,157,255,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
btnSec: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600' },
err: { color: theme.danger, marginBottom: 10 },
warnBox: {
padding: 10,
borderRadius: 8,
backgroundColor: 'rgba(245,101,101,0.1)',
marginBottom: 12,
},
warnText: { color: theme.danger, fontSize: 13, marginBottom: 4 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(110,168,254,0.25)',
marginBottom: 12,
},
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 10 },
meta: { fontSize: 13, color: theme.muted, marginBottom: 8 },
tagOn: { color: theme.success, fontWeight: '700' },
tagOff: { color: theme.warn, fontWeight: '700' },
liveRow: {
paddingVertical: 10,
borderTopWidth: 1,
borderTopColor: theme.border,
},
liveTitle: { fontSize: 14, fontWeight: '600', color: theme.text, marginBottom: 4 },
liveLine: { fontSize: 12, color: theme.muted, marginTop: 2 },
rowCards: { flexDirection: 'row', gap: 10, marginBottom: 12 },
miniCard: {
padding: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
minWidth: 0,
},
miniTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 4 },
miniMeta: { fontSize: 11, color: theme.muted, marginBottom: 8 },
snapGrid: { gap: 6 },
snapLine: { fontSize: 12, color: theme.text, lineHeight: 18 },
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
})