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 —
const d = data as Record
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 (
Google {g?.reachable ? '可达' : '不可达'} ·{' '}
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
)
}
return —
}
function SnapshotSys({ data }: { data: unknown }) {
if (!data || typeof data !== 'object') return —
const d = data as Record
if ('cpu_percent' in d || 'memory' in d) {
const mem = d.memory as { percent?: number } | undefined
return (
CPU {typeof d.cpu_percent === 'number' ? `${d.cpu_percent.toFixed(1)}%` : '—'}
内存 {mem?.percent != null ? `${mem.percent.toFixed(1)}%` : '—'}
)
}
return —
}
export default function DeskScreen() {
const { bind } = useBind()
const [overview, setOverview] = useState(null)
const [proLive, setProLive] = useState(null)
const [proMode, setProMode] = useState(false)
const [netSnap, setNetSnap] = useState(null)
const [sysSnap, setSysSnap] = useState(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 (
void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
仪表盘
void load()}>
刷新
与桌面端同源:概览、Pro 实况、网络/负载快照。事件与客户端日志仅在桌面端 SQLite。App 仅远程控制 PC,手机不运行 FFmpeg。
{!bind && (
未绑定 PC:配对后显示频道摘要与转播状态。
)}
{!!err && bind ? {err} : null}
{overview?.relayValidationErrors && overview.relayValidationErrors.length > 0 ? (
{overview.relayValidationErrors.map((x, i) => (
{x}
))}
) : null}
{proMode && (
Pro 转播实况
{proLive?.youtubeProcess ?? '—'} · 状态 {proLive?.processStatus ?? '—'}{' '}
{proLive?.processOnline ? (
运行中
) : (
未在运行
)}
{(proLive?.relayRows?.length ? proLive.relayRows : []).map((row, i) => (
{row.channelName || '未命名频道'} · key …{row.youtubeKeySuffix || '—'}
主播 {row.anchor ?? '—'}
抖音 {row.douyinHint ?? '—'}
标题 {row.streamTitle ?? '—'}
已播时长 {fmtDur(row.durationSec)}
))}
{!proLive?.relayRows?.length ? 暂无实况 : null}
)}
网络快照
更新 {fmtTs(netSnap?.meta?.at)}
本机负载快照
更新 {fmtTs(sysSnap?.ts)}
直播与任务事件
完整事件表、用户操作与客户端运行日志由桌面端本地数据库维护;请在 Windows 客户端查看明细。
)
}
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 },
})