This commit is contained in:
eric
2026-03-25 12:29:03 -05:00
parent fce2c49b62
commit cc2f30b6e2
180 changed files with 20523 additions and 0 deletions

72
app/(tabs)/_layout.tsx Normal file
View File

@@ -0,0 +1,72 @@
import React from 'react'
import { Tabs } from 'expo-router'
import { Ionicons } from '@expo/vector-icons'
import Colors from '@/constants/Colors'
import { useColorScheme } from '@/components/useColorScheme'
import { useClientOnlyValue } from '@/components/useClientOnlyValue'
export default function TabLayout() {
const colorScheme = useColorScheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme].tint,
tabBarStyle: {
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
borderTopColor: 'rgba(255,255,255,0.08)',
},
headerStyle: {
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
},
headerTintColor: Colors[colorScheme].text,
headerShown: useClientOnlyValue(false, true),
}}
>
<Tabs.Screen
name="index"
options={{
title: '直播',
tabBarIcon: ({ color }) => <Ionicons name="radio-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="desk"
options={{
title: '仪表盘',
tabBarIcon: ({ color }) => <Ionicons name="grid-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="network"
options={{
title: '网络',
tabBarIcon: ({ color }) => <Ionicons name="wifi-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="bind"
options={{
title: '远程',
tabBarIcon: ({ color }) => <Ionicons name="phone-portrait-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="system"
options={{
title: '系统',
tabBarIcon: ({ color }) => <Ionicons name="speedometer-outline" size={22} color={color} />,
}}
/>
<Tabs.Screen
name="wechat"
options={{
href: null,
title: '微信',
tabBarIcon: ({ color }) => <Ionicons name="chatbubbles-outline" size={22} color={color} />,
}}
/>
</Tabs>
)
}

366
app/(tabs)/bind.tsx Normal file
View File

@@ -0,0 +1,366 @@
import { useCallback, useRef, useState } from 'react'
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
StyleSheet,
Linking,
Alert,
Platform,
} from 'react-native'
import { CameraView, useCameraPermissions } from 'expo-camera'
import * as Clipboard from 'expo-clipboard'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView } from 'react-native-safe-area-context'
import { Ionicons } from '@expo/vector-icons'
import { useBind } from '@/context/BindContext'
import { DEFAULT_EASYTIER_PEER_URLS, parseBindPayload, type BindPayload } from '@/lib/types'
import {
checkVpnPermissionGranted,
isExpoEasyTierVpnAvailable,
saveAndPrepareTunnel,
} from '@/lib/easyTierVpn'
import { theme } from '@/constants/theme'
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
const DEFAULT_BOOTSTRAP_PEER = DEFAULT_EASYTIER_PEER_URLS[0]
export default function BindScreen() {
const { bind, setBind, clear, ready } = useBind()
const [paste, setPaste] = useState('')
const [msg, setMsg] = useState('')
const [scanOn, setScanOn] = useState(false)
const [perm, requestPerm] = useCameraPermissions()
const scannedRef = useRef(false)
const [etBusy, setEtBusy] = useState(false)
const etEmbedded = Platform.OS === 'android' && isExpoEasyTierVpnAvailable()
const applyPayload = useCallback(
async (p: BindPayload | null) => {
if (!p) {
setMsg('无法解析,请确认扫描的是 PC 端“生成绑定二维码”的 JSON。')
return
}
await setBind(p)
setMsg(
etEmbedded
? '配对已保存。EasyTier 参数已写入本机,可继续申请 VPN 权限。'
: '配对已保存。请使用开发构建或正式 APK 完成 Android 原生组网。'
)
setScanOn(false)
scannedRef.current = false
},
[etEmbedded, setBind],
)
const onBarCode = useCallback(
async ({ data }: { data: string }) => {
if (scannedRef.current) return
scannedRef.current = true
const p = parseBindPayload(data)
await applyPayload(p)
},
[applyPayload],
)
const onPasteApply = useCallback(async () => {
const p = parseBindPayload(paste.trim())
await applyPayload(p)
}, [paste, applyPayload])
const copyEtJson = useCallback(async () => {
if (!bind) return
const chunk = {
networkName: bind.easytier.networkName,
networkSecret: bind.easytier.networkSecret,
peerUrls: bind.easytier.peerUrls,
}
await Clipboard.setStringAsync(JSON.stringify(chunk, null, 2))
setMsg('已复制 EasyTier 组网 JSON。')
}, [bind])
const copyFull = useCallback(async () => {
if (!bind) return
await Clipboard.setStringAsync(JSON.stringify(bind, null, 2))
setMsg('已复制完整绑定 JSON。')
}, [bind])
const testConn = useCallback(async () => {
if (!bind) return
try {
const r = await fetch(`${bind.baseUrl}/health`, {
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
})
const j = await r.json().catch(() => ({}))
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
} catch (e) {
Alert.alert('连接失败', e instanceof Error ? e.message : String(e))
}
}, [bind])
const retryEmbeddedTunnel = useCallback(async () => {
if (!bind || !etEmbedded) return
setEtBusy(true)
try {
const r = await saveAndPrepareTunnel(bind.easytier)
if (r.needsVpnPermission) {
setMsg('请在系统弹窗中允许本应用建立 VPN授权后再点“检查 VPN 权限”。')
} else if (r.ok && r.vpnPermissionGranted) {
setMsg(
'VPN 权限已就绪EasyTier 参数也已保存。桌面端已默认附带官方共享引导节点,外网下更容易发现对端。'
)
} else {
setMsg(`组网准备结果:${JSON.stringify(r)}`)
}
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setEtBusy(false)
}
}, [bind, etEmbedded])
const checkVpn = useCallback(async () => {
if (!etEmbedded) return
setEtBusy(true)
try {
const ok = await checkVpnPermissionGranted()
setMsg(
ok
? 'VPN 权限已授予。若仍无法访问 API请确认当前安装包已包含完整原生 EasyTier 数据面。'
: '尚未授予 VPN 权限,请先在系统设置中允许本应用建立 VPN。'
)
} finally {
setEtBusy(false)
}
}, [etEmbedded])
if (!ready) {
return (
<SafeAreaView style={styles.safe}>
<Text style={styles.muted}>...</Text>
</SafeAreaView>
)
}
const bootstrapPeer = bind?.easytier.peerUrls[0] ?? DEFAULT_BOOTSTRAP_PEER
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}></Text>
<Text style={styles.lead}>
PC - `baseUrl``token` EasyTier
EasyTier
</Text>
{bind && (
<LinearGradient colors={['#1e3a5f', theme.bgCard]} style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.mono}>{bind.baseUrl}</Text>
<Text style={styles.mutedSmall}> · {bind.easytier.networkName}</Text>
<Text style={styles.mutedSmall}> · {bootstrapPeer}</Text>
{bind.easytier.peerUrls.length > 1 && (
<Text style={styles.mutedSmall}> · {bind.easytier.peerUrls.length - 1} </Text>
)}
<View style={styles.row}>
<Pressable style={styles.btn} onPress={() => void testConn()}>
<Text style={styles.btnText}> API</Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnDanger]}
onPress={() => {
Alert.alert('清除配对', '确定要清除当前保存的绑定信息吗?', [
{ text: '取消', style: 'cancel' },
{ text: '清除', style: 'destructive', onPress: () => void clear() },
])
}}
>
<Text style={styles.btnText}></Text>
</Pressable>
</View>
{etEmbedded ? (
<View style={styles.etRow}>
<Pressable
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
disabled={etBusy}
onPress={() => void retryEmbeddedTunnel()}
>
<Text style={styles.btnSecondaryText}></Text>
</Pressable>
<Pressable
style={[styles.btnSecondary, etBusy && styles.btnDisabled]}
disabled={etBusy}
onPress={() => void checkVpn()}
>
<Text style={styles.btnSecondaryText}> VPN </Text>
</Pressable>
</View>
) : (
<Text style={styles.mutedSmall}>
{Platform.OS === 'ios'
? 'iOS 仍需单独接入 Network Extension 与 EasyTier 原生库。'
: '当前运行环境未加载原生模块,例如 Expo Go。请使用开发构建或正式 APK。'}
</Text>
)}
</LinearGradient>
)}
<View style={styles.block}>
<Text style={styles.h2}></Text>
<Text style={styles.body}>
FFmpeg PC EasyTier
`tcp://public.easytier.top:11010` `peerUrls` IP
</Text>
<Text style={styles.body}>
Android VPN App EasyTier
EasyTier Android
</Text>
<Pressable onPress={() => void Linking.openURL(ET_RELEASE)} style={styles.linkRow}>
<Ionicons name="open-outline" size={18} color={theme.accent} />
<Text style={styles.link}>EasyTier </Text>
</Pressable>
{bind && (
<View style={styles.etActions}>
<Pressable style={styles.btnSecondary} onPress={() => void copyEtJson()}>
<Text style={styles.btnSecondaryText}> JSON</Text>
</Pressable>
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
<Text style={styles.btnSecondaryText}> JSON</Text>
</Pressable>
</View>
)}
</View>
<View style={styles.block}>
<Text style={styles.h2}></Text>
{!scanOn ? (
<Pressable
style={styles.btnPrimary}
onPress={async () => {
const r = await requestPerm()
if (!r.granted) {
setMsg('需要相机权限才能扫码。')
return
}
scannedRef.current = false
setScanOn(true)
}}
>
<Ionicons name="qr-code-outline" size={22} color="#fff" style={{ marginRight: 8 }} />
<Text style={styles.btnPrimaryText}> PC </Text>
</Pressable>
) : perm?.granted ? (
<View style={styles.camWrap}>
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={onBarCode}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
/>
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
<Text style={styles.btnPrimaryText}></Text>
</Pressable>
</View>
) : (
<Text style={styles.muted}></Text>
)}
</View>
<View style={styles.block}>
<Text style={styles.h2}> JSON</Text>
<TextInput
style={styles.input}
multiline
placeholder="粘贴 PC 端显示的绑定 JSON"
placeholderTextColor={theme.muted}
value={paste}
onChangeText={setPaste}
/>
<Pressable style={styles.btnPrimary} onPress={() => void onPasteApply()}>
<Text style={styles.btnPrimaryText}></Text>
</Pressable>
</View>
{!!msg && <Text style={styles.msg}>{msg}</Text>}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 20, paddingBottom: 40 },
h1: { fontSize: 22, fontWeight: '700', color: theme.text, marginBottom: 8 },
lead: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 16 },
h2: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 },
body: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 10 },
block: { marginBottom: 22 },
card: {
borderRadius: 12,
padding: 16,
marginBottom: 18,
borderWidth: 1,
borderColor: theme.border,
},
cardTitle: { fontSize: 13, fontWeight: '600', color: theme.accent, marginBottom: 8 },
mono: { fontFamily: 'monospace', fontSize: 13, color: theme.text, marginBottom: 6 },
muted: { color: theme.muted, fontSize: 14 },
mutedSmall: { color: theme.muted, fontSize: 12, marginTop: 6 },
row: { flexDirection: 'row', gap: 10, marginTop: 12 },
etRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 },
etActions: { gap: 10, marginTop: 12 },
btn: {
backgroundColor: theme.bgElevated,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
},
btnDisabled: { opacity: 0.55 },
btnDanger: { backgroundColor: 'rgba(245,101,101,0.15)' },
btnText: { color: theme.text, fontWeight: '600', fontSize: 14 },
btnPrimary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#3d7eff',
paddingVertical: 14,
borderRadius: 10,
},
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
btnSecondary: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 12,
alignItems: 'center',
backgroundColor: theme.bgCard,
flex: 1,
minWidth: '45%',
},
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
input: {
minHeight: 120,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 12,
color: theme.text,
fontFamily: 'monospace',
fontSize: 12,
marginBottom: 12,
backgroundColor: theme.bgCard,
},
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000' },
camera: { height: 280, width: '100%' },
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
linkRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginVertical: 8 },
link: { color: theme.accent, fontSize: 14 },
msg: { color: theme.success, fontSize: 14, marginTop: 8 },
})

300
app/(tabs)/desk.tsx Normal file
View File

@@ -0,0 +1,300 @@
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 },
})

814
app/(tabs)/index.tsx Normal file
View File

@@ -0,0 +1,814 @@
import { useCallback, useMemo, useState } from 'react'
import {
View,
Text,
ScrollView,
TextInput,
Pressable,
StyleSheet,
ActivityIndicator,
Alert,
RefreshControl,
Linking,
} from 'react-native'
import { Picker } from '@react-native-picker/picker'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { useRecorder } from '@/hooks/useRecorder'
import { theme } from '@/constants/theme'
import {
douyinRoomAndHref,
parseYoutubeStreamKeySuffix,
proChannelPm2Process,
} from '@/lib/youtubeConfigUtils'
function ctrlStyle(action: string): object {
switch (action) {
case 'start':
return styles.ctrl_start
case 'stop':
return styles.ctrl_stop
case 'restart':
return styles.ctrl_restart
case 'status':
return styles.ctrl_status
default:
return {}
}
}
const variantColor: Record<string, string> = {
online: theme.success,
stopped: theme.danger,
error: theme.danger,
warn: theme.warn,
offline: theme.danger,
loading: theme.muted,
unknown: theme.accent,
empty: theme.accent,
}
function ProLiveSwitchGrid({
rows,
committedChannelKeys,
proChannelEditors,
highlightedChannelId,
loadingAction,
onSelectChannel,
onStart,
onStop,
onRestart,
}: {
rows: { channelId: string; channelName: string; douyinUrl: string; live: boolean }[]
committedChannelKeys: Record<string, string[]>
proChannelEditors: Record<string, { keys: string[]; urlText: string }>
highlightedChannelId: string
loadingAction: string | null
onSelectChannel: (channelId: string) => void
onStart: (channelId: string) => void
onStop: (channelId: string) => void
onRestart: (channelId: string) => void
}) {
const pmBusy = loadingAction !== null
return (
<View style={styles.proGrid}>
{rows.map((row) => {
const saved = committedChannelKeys[row.channelId]?.[0] ?? ''
const ed = proChannelEditors[row.channelId]
const activeKey = (saved || ed?.keys?.[0] || '').trim()
const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`)
const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '')
const stopDisabled = pmBusy || !row.live
const rowSelected = row.channelId === highlightedChannelId
return (
<View
key={row.channelId}
style={[styles.proSwitchRow, rowSelected && styles.proSwitchRowOn]}
>
<Pressable onPress={() => onSelectChannel(row.channelId)}>
<Text style={styles.proKeySuffix} numberOfLines={1}>
{keySuffix || '—'}
</Text>
<View style={[styles.proTag, row.live ? styles.proTagOn : styles.proTagOff]}>
<Text style={styles.proTagText}>{row.live ? '直播中' : '未开播'}</Text>
</View>
<Text style={styles.proMid} numberOfLines={2}>
{room} · {row.channelName}
</Text>
</Pressable>
{href ? (
<Pressable onPress={() => void Linking.openURL(href)}>
<Text style={styles.proLink}></Text>
</Pressable>
) : null}
<View style={styles.proActions}>
<Pressable
style={[styles.proBtn, styles.proBtnStart, (pmBusy || row.live) && styles.ctrlDisabled]}
disabled={pmBusy || row.live}
onPress={() => onStart(row.channelId)}
>
<Text style={styles.proBtnText}></Text>
</Pressable>
<Pressable
style={[styles.proBtn, styles.proBtnStop, stopDisabled && styles.ctrlDisabled]}
disabled={stopDisabled}
onPress={() => onStop(row.channelId)}
>
<Text style={styles.proBtnText}></Text>
</Pressable>
<Pressable
style={[styles.proBtn, styles.proBtnRestart, pmBusy && styles.ctrlDisabled]}
disabled={pmBusy}
onPress={() => onRestart(row.channelId)}
>
<Text style={styles.proBtnText}></Text>
</Pressable>
</View>
</View>
)
})}
</View>
)
}
function LiveSwitchGrid({
loadingAction,
onAction,
disabledAll,
}: {
loadingAction: string | null
onAction: (a: 'start' | 'stop' | 'restart' | 'status') => void
disabledAll?: boolean
}) {
return (
<View style={styles.grid}>
{(
[
['start', '开始直播'],
['stop', '停止直播'],
['restart', '重新开始'],
['status', '刷新状态'],
] as const
).map(([action, label]) => (
<Pressable
key={action}
style={[
styles.ctrl,
ctrlStyle(action),
loadingAction === action && styles.btnDisabled,
disabledAll && styles.ctrlDisabled,
]}
disabled={!disabledAll && loadingAction !== null && action !== 'status'}
onPress={() => {
if (disabledAll) {
Alert.alert('未绑定', '请先在「远程」页完成配对后再操作。')
return
}
if (loadingAction !== null && action !== 'status') return
void onAction(action)
}}
>
<Text style={[styles.ctrlText, action === 'status' && { color: theme.text }]}>{label}</Text>
</Pressable>
))}
</View>
)
}
export default function RecordScreen() {
const { bind } = useBind()
const r = useRecorder()
const unbound = !bind
const [refreshing, setRefreshing] = useState(false)
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
const onRefresh = useCallback(async () => {
if (!bind) return
setRefreshing(true)
await r.refreshProcessList()
setRefreshing(false)
}, [bind, r.refreshProcessList])
if (r.bootError && bind) {
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<View style={styles.fatalWrap}>
<Text style={styles.err}>{r.bootError}</Text>
<Text style={styles.fatalHint}>
WiFi / EasyTier API
</Text>
<Pressable style={styles.retryBtn} onPress={() => void r.refreshProcessList()}>
<Text style={styles.retryBtnText}></Text>
</Pressable>
</View>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
bind ? (
<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} tintColor={theme.accent} />
) : undefined
}
>
{bind && r.transientLoadError ? (
<View style={styles.warnBanner}>
<Text style={styles.warnBannerText}>
{r.transientLoadError}
{'\n'}
2
</Text>
</View>
) : null}
{unbound && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
{bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}>
PC PM2 FFmpeg /
</Text>
</View>
)}
<View style={styles.toolbar}>
<View style={[styles.pickerWrap, unbound && styles.pickerDisabled]}>
<Picker
enabled={!unbound && !r.emptyHint}
selectedValue={unbound ? '' : r.currentProcess}
onValueChange={(v) => r.setCurrentProcess(String(v))}
style={styles.picker}
dropdownIconColor={theme.text}
>
{unbound || !r.entries.length ? (
<Picker.Item label="(未配对)" value="" color={theme.muted} />
) : (
r.entries.map((e) => (
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
))
)}
</Picker>
</View>
<Pressable
style={[styles.proRow, unbound && styles.pickerDisabled]}
disabled={unbound}
onPress={() => {
if (unbound) {
Alert.alert('未绑定', '配对后可切换多频道 Pro。')
return
}
void r.togglePro(!r.multiChannelPro)
}}
>
<Text style={styles.proLabel}>Pro</Text>
<Text style={[styles.proVal, r.multiChannelPro && { color: theme.accent }]}>
{r.multiChannelPro ? '开' : '关'}
</Text>
</Pressable>
</View>
<View style={[styles.pill, { borderColor: statusColor }]}>
<View style={[styles.dot, { backgroundColor: statusColor }]} />
<Text style={[styles.pillText, { color: statusColor }]}>{r.statusMeta.line}</Text>
</View>
{bind && r.emptyHint ? (
<View style={styles.emptyCard}>
<Text style={styles.emptyTitle}></Text>
<Text style={styles.hint}>{r.emptyHint}</Text>
</View>
) : unbound ? (
<>
<View style={[styles.card, styles.cardAccent]}>
<Text style={styles.cardTitle}></Text>
<LiveSwitchGrid
loadingAction={r.loadingAction}
disabledAll
onAction={() => {}}
/>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>YouTube </Text>
<Text style={styles.hintSmall}> PC youtube.ini</Text>
<TextInput
style={[styles.area, styles.inputDisabled]}
multiline
editable={false}
value=""
placeholder="串流密钥、分辨率等"
placeholderTextColor={theme.muted}
/>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}></Text>
<TextInput
style={[styles.area, styles.inputDisabled]}
multiline
editable={false}
value=""
placeholder="配对后显示"
placeholderTextColor={theme.muted}
/>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>OBS </Text>
<Text style={styles.hintSmall}>OBS </Text>
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}> · </Text>
<ScrollView style={styles.logBox} nestedScrollEnabled>
<Text style={styles.log}>{r.logText || '—'}</Text>
</ScrollView>
</View>
</>
) : (
<>
<View style={[styles.card, styles.cardAccent]}>
<Text style={styles.cardTitle}></Text>
{r.isYoutube && r.multiChannelPro && r.proLiveRows.length > 0 ? (
<ProLiveSwitchGrid
rows={r.proLiveRows}
committedChannelKeys={r.committedChannelKeys}
proChannelEditors={r.proChannelEditors}
highlightedChannelId={r.proChannelId}
loadingAction={r.loadingAction}
onSelectChannel={(cid) => r.setProChannelId(cid)}
onStart={(cid) => void r.runProChannelStart(cid)}
onStop={(cid) => void r.runPm2Action('stop', proChannelPm2Process(cid))}
onRestart={(cid) => void r.runProChannelRestart(cid)}
/>
) : r.isYoutube && r.multiChannelPro && r.proLiveRows.length === 0 ? (
<Text style={styles.hintSmall}>
{r.proChannelsList.length > 0
? '暂无已保存的线路密钥。请在「当前频道」中保存串流密钥后,对应行将显示于此。'
: '暂无频道。'}
</Text>
) : (
<LiveSwitchGrid
loadingAction={r.loadingAction}
onAction={(a) => void r.runPm2Action(a)}
/>
)}
</View>
{r.isYoutube && r.multiChannelPro && (
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
{r.proChannelsError ? (
<Text style={styles.err}>{r.proChannelsError}</Text>
) : (
<>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.proChannelId}
onValueChange={(v) => r.setProChannelId(String(v))}
style={styles.picker}
dropdownIconColor={theme.text}
>
{r.proChannelsList.map((c) => (
<Picker.Item
key={c.id}
label={`${c.name} (${c.keyPreview || '无密钥'})`}
value={c.id}
color={theme.text}
/>
))}
</Picker>
</View>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
disabled={!!r.newChannelDraft || r.addChannelBusy}
onPress={() => r.setNewChannelDraft({ keyText: '' })}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnSec]}
disabled={r.proChannelsList.length <= 1 || r.deleteChannelBusyId !== null}
onPress={() => void r.deleteProChannel(r.proChannelId)}
>
<Text style={styles.btnSecText}>
{r.deleteChannelBusyId === r.proChannelId ? '…' : '删除当前频道'}
</Text>
</Pressable>
</View>
{r.newChannelDraft ? (
<View style={styles.newChBox}>
<Text style={styles.fieldLbl}></Text>
<TextInput
style={styles.inputSingle}
value={r.newChannelDraft.keyText}
onChangeText={(v) => r.setNewChannelDraft({ keyText: v })}
placeholder="粘贴密钥"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => r.setNewChannelDraft(null)}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.addChannelBusy && styles.btnDisabled]}
disabled={r.addChannelBusy}
onPress={() => void r.submitNewChannelDraft()}
>
<Text style={styles.btnText}>{r.addChannelBusy ? '…' : '提交'}</Text>
</Pressable>
</View>
</View>
) : null}
</>
)}
</View>
)}
{r.isYoutube && r.multiChannelPro && !r.proChannelsError && r.proChannelsList.length > 0 && (
<>
<View style={styles.card}>
<Text style={styles.cardTitle}>YouTube 线</Text>
<TextInput
style={styles.area}
multiline
value={r.proChannelEditors[r.proChannelId]?.keys?.[0] ?? ''}
onChangeText={r.setProChannelKeyText}
placeholderTextColor={theme.muted}
/>
<Pressable
style={[
styles.btn,
(r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId) && styles.btnDisabled,
]}
disabled={r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId}
onPress={() => void r.saveProChannelKey()}
>
{r.saveProKeyLoading || r.saveProKeyLoadingId === r.proChannelId ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}></Text>
)}
</Pressable>
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>线</Text>
<TextInput
style={styles.area}
multiline
value={r.proChannelEditors[r.proChannelId]?.urlText ?? ''}
onChangeText={r.setProChannelUrlText}
placeholderTextColor={theme.muted}
/>
<View style={styles.row}>
<Pressable style={[styles.btn, styles.btnSec]} onPress={() => void r.reloadProUrlFromServer()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => void r.saveProUrlConfig()}
disabled={r.saveUrlLoading}
>
<Text style={styles.btnSecText}>{r.saveUrlLoading ? '…' : '保存地址'}</Text>
</Pressable>
</View>
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
</View>
</>
)}
{r.isYoutube && !r.multiChannelPro && (
<View style={styles.card}>
<Text style={styles.cardTitle}>YouTube </Text>
<Text style={styles.fieldLbl}></Text>
<TextInput
style={styles.inputSingle}
value={r.ytIniModel.keys[0] ?? ''}
onChangeText={(v) =>
r.setYtIniModel((m) => ({ ...m, keys: [v], activeIndex: 0 }))
}
placeholder="粘贴或编辑串流密钥"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.fieldLbl}></Text>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.ytIniModel.options.output_resolution ?? '720p'}
onValueChange={(v) =>
r.setYtIniModel((m) => ({
...m,
options: { ...m.options, output_resolution: String(v) },
}))
}
style={styles.picker}
dropdownIconColor={theme.text}
>
<Picker.Item label="720p" value="720p" color={theme.text} />
<Picker.Item label="1080p" value="1080p" color={theme.text} />
</Picker>
</View>
<Text style={styles.fieldLbl}>使 GPUNVENC</Text>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.ytIniModel.options.use_gpu ?? (r.gpuCaps?.nvenc_available ? '是' : '无')}
onValueChange={(v) =>
r.setYtIniModel((m) => ({
...m,
options: { ...m.options, use_gpu: String(v) },
}))
}
style={styles.picker}
dropdownIconColor={theme.text}
>
{r.gpuCaps?.nvenc_available ? (
<>
<Picker.Item label="是" value="是" color={theme.text} />
<Picker.Item label="否" value="否" color={theme.text} />
<Picker.Item label="无" value="无" color={theme.text} />
</>
) : (
<Picker.Item label="无(未检测到 NVENC" value="无" color={theme.text} />
)}
</Picker>
</View>
<Text style={styles.fieldLbl}></Text>
<View style={styles.pickerWrap}>
<Picker
selectedValue={r.ytIniModel.options.audio_denoise ?? '否'}
onValueChange={(v) =>
r.setYtIniModel((m) => ({
...m,
options: { ...m.options, audio_denoise: String(v) },
}))
}
style={styles.picker}
dropdownIconColor={theme.text}
>
<Picker.Item label="否" value="否" color={theme.text} />
<Picker.Item label="是" value="是" color={theme.text} />
</Picker>
</View>
<View style={styles.row}>
<Pressable style={[styles.btn, styles.btnSec]} onPress={() => void r.loadYoutubeIniModel()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.saveYoutubeLoading && styles.btnDisabled]}
disabled={r.saveYoutubeLoading}
onPress={() => void r.saveYoutubeIniModel()}
>
{r.saveYoutubeLoading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.btnText}></Text>
)}
</Pressable>
</View>
<Text style={styles.hintSmall}>{r.youtubeStatus}</Text>
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && (
<Text style={styles.hintSmall}>Google PC </Text>
)}
</View>
)}
{((r.isYoutube && !r.multiChannelPro) || r.isTiktok) && (
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}></Text>
<TextInput
style={styles.area}
multiline
value={r.urlText}
onChangeText={r.setUrlText}
placeholderTextColor={theme.muted}
/>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => void r.loadConfig('/get_url_config', r.setUrlText, r.setUrlStatus)}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.saveUrlLoading && styles.btnDisabled]}
disabled={r.saveUrlLoading}
onPress={() => {
r.setSaveUrlLoading(true)
void r.saveConfig('/save_url_config', r.urlText, r.setUrlStatus, () =>
r.setSaveUrlLoading(false),
)
}}
>
<Text style={styles.btnText}></Text>
</Pressable>
</View>
<Text style={styles.hintSmall}>{r.urlStatus}</Text>
</View>
)}
{!r.UI_SHOW_YOUTUBE_GOOGLE_OAUTH && r.isObs && (
<View style={styles.card}>
<Text style={styles.cardTitle}>OBS </Text>
<Text style={styles.hintSmall}> OBS </Text>
<Text style={styles.mono}>srt://live.local:10080/live/obs</Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<Text style={styles.hintSmall}> 2 · </Text>
<ScrollView style={styles.logBox} nestedScrollEnabled>
<Text style={styles.log}>{r.logText || '—'}</Text>
</ScrollView>
</View>
</>
)}
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
fatalWrap: { flex: 1, padding: 20, justifyContent: 'center' },
fatalHint: { fontSize: 13, color: theme.muted, lineHeight: 20, marginTop: 12, marginBottom: 20 },
retryBtn: {
alignSelf: 'flex-start',
backgroundColor: '#3d7eff',
paddingVertical: 12,
paddingHorizontal: 18,
borderRadius: 10,
},
retryBtnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
warnBanner: {
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(246,173,85,0.12)',
borderWidth: 1,
borderColor: 'rgba(246,173,85,0.35)',
marginBottom: 14,
},
warnBannerText: { fontSize: 13, color: theme.warn, lineHeight: 20 },
scroll: { padding: 16, paddingBottom: 32 },
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 },
toolbar: { marginBottom: 12 },
proGrid: { gap: 10 },
proSwitchRow: {
padding: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgCard,
gap: 8,
},
proSwitchRowOn: { borderColor: 'rgba(91,157,255,0.55)', backgroundColor: 'rgba(91,157,255,0.08)' },
proKeySuffix: { fontFamily: 'monospace', fontSize: 13, color: theme.accent, fontWeight: '600' },
proTag: { alignSelf: 'flex-start', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 999 },
proTagOn: { backgroundColor: 'rgba(46,160,67,0.2)' },
proTagOff: { backgroundColor: 'rgba(110,118,129,0.2)' },
proTagText: { fontSize: 12, fontWeight: '600', color: theme.text },
proMid: { fontSize: 13, color: theme.text, lineHeight: 20 },
proLink: { fontSize: 12, color: theme.accent, marginTop: 6, fontWeight: '600' },
proActions: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
proBtn: { paddingVertical: 10, paddingHorizontal: 12, borderRadius: 8, minWidth: 72, alignItems: 'center' },
proBtnStart: { backgroundColor: '#0e7a0d' },
proBtnStop: { backgroundColor: '#d1342c' },
proBtnRestart: { backgroundColor: '#e68600' },
proBtnText: { color: '#fff', fontWeight: '700', fontSize: 12 },
newChBox: { marginTop: 12, paddingTop: 12, borderTopWidth: 1, borderTopColor: theme.border },
pickerWrap: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.bgCard,
},
pickerDisabled: { opacity: 0.65 },
picker: { color: theme.text },
proRow: {
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: 12,
paddingVertical: 10,
paddingHorizontal: 12,
backgroundColor: theme.bgCard,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
},
proLabel: { color: theme.text, fontWeight: '600' },
proVal: { color: theme.muted, fontWeight: '600' },
pill: {
flexDirection: 'row',
alignItems: 'center',
alignSelf: 'flex-start',
gap: 8,
paddingVertical: 8,
paddingHorizontal: 12,
borderRadius: 999,
borderWidth: 1,
marginBottom: 14,
},
dot: { width: 8, height: 8, borderRadius: 4 },
pillText: { fontSize: 13, fontWeight: '600' },
emptyCard: {
padding: 20,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: theme.border,
alignItems: 'center',
},
emptyTitle: { fontSize: 15, fontWeight: '600', color: theme.muted, marginBottom: 10 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: theme.border,
marginBottom: 12,
},
cardAccent: {
borderColor: 'rgba(91,157,255,0.35)',
backgroundColor: 'rgba(91,157,255,0.06)',
},
cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 10 },
fieldLbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6, marginTop: 4 },
inputSingle: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
fontSize: 14,
marginBottom: 10,
backgroundColor: theme.bg,
},
area: {
minHeight: 100,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
fontFamily: 'monospace',
fontSize: 12,
marginBottom: 10,
backgroundColor: theme.bg,
},
inputDisabled: { opacity: 0.85 },
row: { flexDirection: 'row', gap: 10, flexWrap: 'wrap' },
btn: {
backgroundColor: '#3d7eff',
paddingVertical: 10,
paddingHorizontal: 18,
borderRadius: 10,
minWidth: 100,
alignItems: 'center',
},
btnDisabled: { opacity: 0.55 },
btnText: { color: '#fff', fontWeight: '700' },
btnSec: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
btnSecText: { color: theme.text, fontWeight: '600' },
hint: { color: theme.muted, fontSize: 14, lineHeight: 22, textAlign: 'center' },
hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8, marginBottom: 8 },
err: { color: theme.danger, fontSize: 14 },
mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13, marginTop: 6 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' },
ctrlDisabled: { opacity: 0.5 },
ctrl_start: { backgroundColor: '#0e7a0d' },
ctrl_stop: { backgroundColor: '#d1342c' },
ctrl_restart: { backgroundColor: '#e68600' },
ctrl_status: { backgroundColor: theme.bgElevated, borderWidth: 1, borderColor: theme.border },
ctrlText: { color: '#fff', fontWeight: '700', fontSize: 14 },
logBox: { maxHeight: 280, backgroundColor: '#1e2128', borderRadius: 10, padding: 10 },
log: { fontFamily: 'monospace', fontSize: 11, color: '#d0d4dc', lineHeight: 18 },
})

423
app/(tabs)/network.tsx Normal file
View File

@@ -0,0 +1,423 @@
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 },
})

208
app/(tabs)/system.tsx Normal file
View File

@@ -0,0 +1,208 @@
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 },
})

392
app/(tabs)/wechat.tsx Normal file
View File

@@ -0,0 +1,392 @@
import { useCallback, useEffect, useState } from 'react'
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
Pressable,
Image,
Linking,
RefreshControl,
} from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { apiFetch } from '@/lib/api'
import { theme } from '@/constants/theme'
type WechatConfigView = {
configured: boolean
proxyUrl: string
apiKeyMasked: string
deviceType: string
proxy: string
webhookHost: string
webhookPort: number
webhookPath: string
accountId: string
session: { wcId?: string; nickName?: string }
}
export default function WeChatScreen() {
const { bind } = useBind()
const [cfg, setCfg] = useState<WechatConfigView | null>(null)
const [msg, setMsg] = useState('')
const [busy, setBusy] = useState(false)
const [apiKeyInput, setApiKeyInput] = useState('')
const [proxyUrlInput, setProxyUrlInput] = useState('')
const [deviceType, setDeviceType] = useState('ipad')
const [proxyLine, setProxyLine] = useState('10')
const [wId, setWId] = useState('')
const [qrUrl, setQrUrl] = useState('')
const [poll, setPoll] = useState(false)
const [proxyStatus, setProxyStatus] = useState<Record<string, unknown> | null>(null)
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setCfg(null)
return
}
setMsg('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/config')
if (!r.ok) throw new Error(String(r.status))
const j = (await r.json()) as WechatConfigView
setCfg(j)
setProxyUrlInput(j.proxyUrl || '')
setDeviceType(j.deviceType || 'ipad')
setProxyLine(j.proxy || '10')
setApiKeyInput('')
} catch (e) {
setCfg(null)
setMsg(e instanceof Error ? e.message : String(e))
}
}, [bind])
useEffect(() => {
void load()
}, [load])
useEffect(() => {
if (!poll || !wId || !bind) return
const tick = async () => {
try {
const r = await apiFetch(bind, '/chatbot/wechat/login/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ wId }),
})
const j = (await r.json()) as {
status?: string
wcId?: string
nickName?: string
verifyUrl?: string
error?: string
}
if (!r.ok) {
setMsg(j.error || String(r.status))
setPoll(false)
return
}
if (j.status === 'logged_in') {
setPoll(false)
setMsg(`已登录:${j.nickName || ''}${j.wcId || ''}`)
void load()
return
}
if (j.status === 'need_verify' && j.verifyUrl) {
setMsg(`需要辅助验证:${j.verifyUrl}`)
}
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
setPoll(false)
}
}
const id = setInterval(() => void tick(), 5000)
void tick()
return () => clearInterval(id)
}, [poll, wId, bind, load])
const saveConfig = async () => {
if (!bind) return
setBusy(true)
setMsg('')
try {
const body: Record<string, string | number> = {
proxyUrl: proxyUrlInput.trim(),
deviceType,
proxy: proxyLine.trim(),
}
if (apiKeyInput.trim()) body.apiKey = apiKeyInput.trim()
const r = await apiFetch(bind, '/chatbot/wechat/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!r.ok) {
const t = await r.text()
throw new Error(t || String(r.status))
}
await load()
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const loadProxyStatus = async () => {
if (!bind) return
setBusy(true)
setMsg('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/proxy/status')
const j = await r.json()
if (!r.ok) {
setMsg(typeof j.error === 'string' ? j.error : JSON.stringify(j))
setProxyStatus(null)
return
}
setProxyStatus(j as Record<string, unknown>)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
const startQr = async () => {
if (!bind) return
setBusy(true)
setMsg('')
setQrUrl('')
setWId('')
try {
const r = await apiFetch(bind, '/chatbot/wechat/qr/start', { method: 'POST' })
const j = (await r.json()) as { wId?: string; qrCodeUrl?: string; error?: string }
if (!r.ok) {
setMsg(j.error || String(r.status))
return
}
if (!j.wId || !j.qrCodeUrl) {
setMsg('未返回二维码')
return
}
setWId(j.wId)
setQrUrl(j.qrCodeUrl)
setPoll(true)
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
} finally {
setBusy(false)
}
}
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
}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}>openclaw-wechat API</Text>
{!bind && (
<View style={styles.banner}>
<Text style={styles.bannerText}> PC</Text>
</View>
)}
<View style={styles.card}>
<Text style={styles.cardTitle}> Chatbot</Text>
<Text style={styles.label}>proxyUrl</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxyUrlInput}
editable={!!bind}
onChangeText={setProxyUrlInput}
placeholder="http://your-proxy:3000"
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.label}>apiKey</Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={apiKeyInput}
editable={!!bind}
onChangeText={setApiKeyInput}
placeholder={cfg?.apiKeyMasked ? `已保存 ${cfg.apiKeyMasked}` : 'wc_live_…'}
placeholderTextColor={theme.muted}
secureTextEntry
autoCapitalize="none"
/>
<Text style={styles.label}>deviceType</Text>
<View style={styles.row}>
<Pressable
style={[styles.chip, deviceType === 'ipad' && styles.chipOn]}
onPress={() => setDeviceType('ipad')}
disabled={!bind || busy}
>
<Text style={styles.chipText}>ipad</Text>
</Pressable>
<Pressable
style={[styles.chip, deviceType === 'mac' && styles.chipOn]}
onPress={() => setDeviceType('mac')}
disabled={!bind || busy}
>
<Text style={styles.chipText}>mac</Text>
</Pressable>
</View>
<Text style={styles.label}>proxy </Text>
<TextInput
style={[styles.input, !bind && styles.inputDisabled]}
value={proxyLine}
editable={!!bind}
onChangeText={setProxyLine}
placeholderTextColor={theme.muted}
/>
<View style={styles.btnRow}>
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void saveConfig()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable style={[styles.btnSec, !bind && styles.disabled]} disabled={!bind || busy} onPress={() => void load()}>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btnSec, !bind && styles.disabled]}
disabled={!bind || busy}
onPress={() => void loadProxyStatus()}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
</View>
{proxyStatus ? (
<Text style={styles.pre}>{JSON.stringify(proxyStatus, null, 2)}</Text>
) : null}
{cfg?.session?.wcId ? (
<Text style={styles.hint}>{cfg.session.nickName || '—'}{cfg.session.wcId}</Text>
) : null}
<Pressable
style={[styles.btnPri, (!bind || busy || !cfg?.configured) && styles.disabled]}
disabled={!bind || busy || !cfg?.configured}
onPress={() => void startQr()}
>
<Text style={styles.btnPriText}></Text>
</Pressable>
{!cfg?.configured && bind ? (
<Text style={styles.hint}> apiKey proxyUrl</Text>
) : null}
{qrUrl ? (
<View style={styles.qrBlock}>
<Text style={styles.hint}>使</Text>
<Image source={{ uri: qrUrl }} style={styles.qrImg} resizeMode="contain" />
<Pressable onPress={() => void Linking.openURL(qrUrl)}>
<Text style={styles.link}></Text>
</Pressable>
</View>
) : null}
{!!msg && (
<Text style={[styles.msg, msg.startsWith('已登录') ? styles.msgOk : styles.msgErr]}>{msg}</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(100,149,237,0.1)',
borderWidth: 1,
borderColor: 'rgba(100,149,237,0.25)',
marginBottom: 14,
},
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: 'rgba(100,149,237,0.28)',
},
cardTitle: { fontSize: 16, fontWeight: '600', color: theme.text, 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.55 },
row: { flexDirection: 'row', gap: 10, marginBottom: 12 },
chip: {
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
chipOn: { borderColor: theme.accent, backgroundColor: 'rgba(91,157,255,0.12)' },
chipText: { color: theme.text, fontWeight: '600' },
btnRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginBottom: 12 },
btnSec: {
paddingVertical: 10,
paddingHorizontal: 12,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
backgroundColor: theme.bgElevated,
},
btnSecText: { color: theme.text, fontWeight: '600', fontSize: 13 },
btnPri: {
marginTop: 8,
paddingVertical: 12,
borderRadius: 10,
backgroundColor: '#3d7eff',
alignItems: 'center',
},
btnPriText: { color: '#fff', fontWeight: '700' },
disabled: { opacity: 0.45 },
pre: {
fontFamily: 'monospace',
fontSize: 11,
color: '#c8cdd5',
marginBottom: 12,
lineHeight: 16,
},
hint: { fontSize: 13, color: theme.muted, marginTop: 8, lineHeight: 20 },
qrBlock: { marginTop: 16, alignItems: 'center' },
qrImg: { width: 224, height: 224, marginVertical: 12, backgroundColor: '#fff' },
link: { color: theme.accent, fontSize: 14, textDecorationLine: 'underline' },
msg: { marginTop: 12, fontSize: 14 },
msgOk: { color: theme.success },
msgErr: { color: theme.danger },
})

38
app/+html.tsx Normal file
View File

@@ -0,0 +1,38 @@
import { ScrollViewStyleReset } from 'expo-router/html';
// This file is web-only and used to configure the root HTML for every
// web page during static rendering.
// The contents of this function only run in Node.js environments and
// do not have access to the DOM or browser APIs.
export default function Root({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta httpEquiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
{/*
Disable body scrolling on web. This makes ScrollView components work closer to how they do on native.
However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line.
*/}
<ScrollViewStyleReset />
{/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */}
<style dangerouslySetInnerHTML={{ __html: responsiveBackground }} />
{/* Add any additional <head> elements that you want globally available on web... */}
</head>
<body>{children}</body>
</html>
);
}
const responsiveBackground = `
body {
background-color: #fff;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #000;
}
}`;

40
app/+not-found.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { Text, View } from '@/components/Themed';
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<View style={styles.container}>
<Text style={styles.title}>This screen doesn't exist.</Text>
<Link href="/" style={styles.link}>
<Text style={styles.linkText}>Go to home screen!</Text>
</Link>
</View>
</>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
title: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
marginTop: 15,
paddingVertical: 15,
},
linkText: {
fontSize: 14,
color: '#2e78b7',
},
});

58
app/_layout.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { useEffect } from 'react';
import 'react-native-reanimated';
import { useColorScheme } from '@/components/useColorScheme';
import { BindProvider } from '@/context/BindContext';
export {
// Catch any errors thrown by the Layout component.
ErrorBoundary,
} from 'expo-router';
export const unstable_settings = {
initialRouteName: '(tabs)',
};
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function RootLayout() {
const [loaded, error] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
useEffect(() => {
if (error) throw error;
}, [error]);
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return <RootLayoutNav />;
}
function RootLayoutNav() {
const colorScheme = useColorScheme();
return (
<BindProvider>
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
</Stack>
</ThemeProvider>
</BindProvider>
);
}