209 lines
7.7 KiB
TypeScript
209 lines
7.7 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react'
|
||
import { View, Text, ScrollView, StyleSheet, RefreshControl } from 'react-native'
|
||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||
|
||
import { useBind } from '@/context/BindContext'
|
||
import { apiFetch } from '@/lib/api'
|
||
import type { SystemMetricsPayload } from '@/lib/pcTypes'
|
||
import { theme } from '@/constants/theme'
|
||
|
||
function fmtBytes(n: number | undefined): string {
|
||
if (n == null || Number.isNaN(n)) return '—'
|
||
if (n < 1024) return `${n} B`
|
||
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KB`
|
||
if (n < 1024 ** 3) return `${(n / 1024 ** 2).toFixed(1)} MB`
|
||
return `${(n / 1024 ** 3).toFixed(2)} GB`
|
||
}
|
||
|
||
export default function SystemScreen() {
|
||
const { bind } = useBind()
|
||
const [data, setData] = useState<SystemMetricsPayload | null>(null)
|
||
const [err, setErr] = useState<string | null>(null)
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
const load = useCallback(async () => {
|
||
if (!bind) {
|
||
setData(null)
|
||
setErr(null)
|
||
return
|
||
}
|
||
try {
|
||
const res = await apiFetch(bind, '/system_metrics')
|
||
const json = (await res.json()) as SystemMetricsPayload
|
||
if (!res.ok) {
|
||
setErr(json.error || `HTTP ${res.status}`)
|
||
return
|
||
}
|
||
setErr(null)
|
||
setData(json)
|
||
} catch (e) {
|
||
setErr(e instanceof Error ? e.message : String(e))
|
||
}
|
||
}, [bind])
|
||
|
||
useEffect(() => {
|
||
void load()
|
||
}, [load])
|
||
|
||
useEffect(() => {
|
||
if (!bind) return
|
||
const id = setInterval(() => void load(), 2000)
|
||
return () => clearInterval(id)
|
||
}, [bind, load])
|
||
|
||
const onRefresh = useCallback(async () => {
|
||
setRefreshing(true)
|
||
await load()
|
||
setRefreshing(false)
|
||
}, [load])
|
||
|
||
const m = data?.memory
|
||
const net = data?.network
|
||
|
||
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}>本机负载与网速(与桌面端「系统」页同源)。开机启动等仅在 Windows 客户端可设置。</Text>
|
||
|
||
{!bind && (
|
||
<View style={styles.banner}>
|
||
<Text style={styles.bannerText}>未绑定 PC:配对后每 2 秒刷新指标。</Text>
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.card}>
|
||
<Text style={styles.cardTitle}>本机负载与网速</Text>
|
||
<Text style={styles.desc}>
|
||
网速为全机网卡合计估算;下方列出检测到的 ffmpeg 进程。
|
||
</Text>
|
||
{err && bind ? (
|
||
<Text style={styles.err}>{err}</Text>
|
||
) : null}
|
||
|
||
<View style={styles.metricsGrid}>
|
||
<View style={styles.block}>
|
||
<Text style={styles.blockTitle}>CPU</Text>
|
||
<Text style={styles.big}>{data?.cpu_percent != null ? `${data.cpu_percent.toFixed(1)}%` : '—'}</Text>
|
||
<Text style={styles.sub}>逻辑处理器 {data?.cpu_count_logical ?? '—'} 个</Text>
|
||
</View>
|
||
<View style={styles.block}>
|
||
<Text style={styles.blockTitle}>内存</Text>
|
||
<Text style={styles.big}>{m?.percent != null ? `${m.percent.toFixed(1)}%` : '—'}</Text>
|
||
<Text style={styles.sub}>
|
||
{fmtBytes(m?.used)} / {fmtBytes(m?.total)}(可用 {fmtBytes(m?.available)})
|
||
</Text>
|
||
</View>
|
||
<View style={styles.block}>
|
||
<Text style={styles.blockTitle}>全机网速</Text>
|
||
<Text style={styles.rate}>
|
||
↑ {net?.up_mbps != null ? `${net.up_mbps.toFixed(2)} Mbps` : '—'}
|
||
</Text>
|
||
<Text style={styles.rate}>
|
||
↓ {net?.down_mbps != null ? `${net.down_mbps.toFixed(2)} Mbps` : '—'}
|
||
</Text>
|
||
<Text style={styles.sub}>含所有网卡收发合计</Text>
|
||
</View>
|
||
</View>
|
||
|
||
{data?.swap && data.swap.total != null && data.swap.total > 0 ? (
|
||
<Text style={styles.swap}>
|
||
交换分区 {data.swap.percent != null ? `${data.swap.percent.toFixed(0)}%` : '—'} ·{' '}
|
||
{fmtBytes(data.swap.used)} / {fmtBytes(data.swap.total)}
|
||
</Text>
|
||
) : null}
|
||
|
||
{data?.disks && data.disks.length > 0 ? (
|
||
<View style={styles.diskSec}>
|
||
<Text style={styles.diskTitle}>磁盘空间</Text>
|
||
{data.disks.slice(0, 8).map((d, i) => (
|
||
<Text key={i} style={styles.diskLine}>
|
||
<Text style={styles.diskMp}>{d.mountpoint}</Text> ·{' '}
|
||
{d.percent != null ? `${d.percent.toFixed(0)}%` : '—'} 已用 · 剩余 {fmtBytes(d.free)}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
|
||
{data?.ffmpeg_processes && data.ffmpeg_processes.length > 0 ? (
|
||
<View style={styles.ffSec}>
|
||
<Text style={styles.diskTitle}>ffmpeg 进程</Text>
|
||
{data.ffmpeg_processes.map((p, i) => (
|
||
<Text key={i} style={styles.ffLine}>
|
||
PID {p.pid} · {p.name ?? '—'} · {fmtBytes(p.rss)}
|
||
</Text>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
</View>
|
||
|
||
<View style={styles.cardMuted}>
|
||
<Text style={styles.cardTitle}>客户端与系统集成</Text>
|
||
<Text style={styles.muted}>开机启动、路径与 Electron 集成仅在桌面客户端可用。</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.22)',
|
||
marginBottom: 14,
|
||
},
|
||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||
card: {
|
||
padding: 14,
|
||
borderRadius: 12,
|
||
backgroundColor: theme.bgCard,
|
||
borderWidth: 1,
|
||
borderColor: 'rgba(124,92,255,0.22)',
|
||
marginBottom: 12,
|
||
},
|
||
cardMuted: {
|
||
padding: 14,
|
||
borderRadius: 12,
|
||
backgroundColor: theme.bgElevated,
|
||
borderWidth: 1,
|
||
borderColor: theme.border,
|
||
},
|
||
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 },
|
||
desc: { fontSize: 13, color: theme.muted, marginBottom: 12, lineHeight: 20 },
|
||
err: { color: theme.danger, marginBottom: 10, fontSize: 13 },
|
||
metricsGrid: { gap: 12 },
|
||
block: {
|
||
padding: 12,
|
||
borderRadius: 10,
|
||
backgroundColor: theme.bgElevated,
|
||
borderWidth: 1,
|
||
borderColor: theme.border,
|
||
},
|
||
blockTitle: { fontSize: 11, fontWeight: '700', color: theme.muted, marginBottom: 6 },
|
||
big: { fontSize: 22, fontWeight: '700', color: theme.text },
|
||
sub: { fontSize: 11, color: theme.muted, marginTop: 4, lineHeight: 16 },
|
||
rate: { fontSize: 15, fontWeight: '600', color: theme.accent, fontFamily: 'monospace' },
|
||
swap: { fontSize: 12, color: theme.muted, marginTop: 10, marginBottom: 8 },
|
||
diskSec: { marginTop: 8 },
|
||
diskTitle: { fontSize: 13, fontWeight: '600', color: theme.text, marginBottom: 8 },
|
||
diskLine: { fontSize: 12, color: theme.muted, marginBottom: 6 },
|
||
diskMp: { fontFamily: 'monospace', color: theme.text },
|
||
ffSec: { marginTop: 12 },
|
||
ffLine: { fontSize: 12, color: theme.muted, marginBottom: 4, fontFamily: 'monospace' },
|
||
muted: { fontSize: 13, color: theme.muted, lineHeight: 20 },
|
||
})
|