301 lines
11 KiB
TypeScript
301 lines
11 KiB
TypeScript
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 实况、网络/负载快照。事件与客户端日志仅在桌面端 SQLite。App 仅远程控制 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 },
|
||
})
|