'init'
Some checks failed
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled

This commit is contained in:
eric
2026-03-24 14:46:41 -05:00
parent c5a1a9a404
commit b77a8cadb2
164 changed files with 57060 additions and 58 deletions

View File

@@ -0,0 +1,64 @@
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="bind"
options={{
title: '配对',
tabBarIcon: ({ color }) => <Ionicons name="qr-code-outline" size={24} color={color} />,
}}
/>
<Tabs.Screen
name="index"
options={{
title: '录制',
tabBarIcon: ({ color }) => <Ionicons name="radio-outline" size={24} color={color} />,
}}
/>
<Tabs.Screen
name="desk"
options={{
title: '工作台',
tabBarIcon: ({ color }) => <Ionicons name="grid-outline" size={24} color={color} />,
}}
/>
<Tabs.Screen
name="network"
options={{
title: '网络',
tabBarIcon: ({ color }) => <Ionicons name="wifi-outline" size={24} color={color} />,
}}
/>
<Tabs.Screen
name="more"
options={{
title: '更多',
tabBarIcon: ({ color }) => <Ionicons name="menu-outline" size={24} color={color} />,
}}
/>
</Tabs>
)
}

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

@@ -0,0 +1,278 @@
import { useCallback, useRef, useState } from 'react'
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
StyleSheet,
Linking,
Alert,
} 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 { parseBindPayload, type BindPayload } from '@/lib/types'
import { theme } from '@/constants/theme'
const ET_RELEASE = 'https://github.com/EasyTier/EasyTier/releases'
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 applyPayload = useCallback(
async (p: BindPayload | null) => {
if (!p) {
setMsg('无法解析:请确认扫到的是 PC 端「生成绑定二维码」的 JSON。')
return
}
await setBind(p)
setMsg('已保存配对。请到「录制」页操作。')
setScanOn(false)
scannedRef.current = false
},
[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 字段(可粘贴到官方客户端)。')
}, [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])
if (!ready) {
return (
<SafeAreaView style={styles.safe}>
<Text style={styles.muted}></Text>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.scroll} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}> EasyTier</Text>
<Text style={styles.lead}>
PC PC 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>
<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>
</LinearGradient>
)}
<View style={styles.block}>
<Text style={styles.h2}>EasyTier </Text>
<Text style={styles.body}>
PC easytier-core EasyTier
<Text style={{ fontWeight: '700', color: theme.text }}></Text>
IP 访 API http://虚拟IP:8001
</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 Android</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 },
etActions: { gap: 10, marginTop: 12 },
btn: {
backgroundColor: theme.bgElevated,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
},
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,
alignItems: 'center',
backgroundColor: theme.bgCard,
},
btnSecondaryText: { color: theme.text, fontWeight: '600' },
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 },
})

View File

@@ -0,0 +1,76 @@
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 { theme } from '@/constants/theme'
export default function DeskScreen() {
const { bind } = useBind()
const [raw, setRaw] = useState<string>('')
const [err, setErr] = useState('')
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setErr('未绑定 PC')
setRaw('')
return
}
setErr('')
try {
const r = await apiFetch(bind, '/client/overview')
if (!r.ok) throw new Error(String(r.status))
const j = await r.json()
setRaw(JSON.stringify(j, null, 2))
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setRaw('')
}
}, [bind])
useEffect(() => {
void load()
}, [load])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} />}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}> /client/overview </Text>
{err ? <Text style={styles.err}>{err}</Text> : null}
{raw ? <Text style={styles.pre}>{raw}</Text> : !err ? <Text style={styles.muted}></Text> : null}
</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, marginBottom: 16, lineHeight: 22 },
pre: {
fontFamily: 'monospace',
fontSize: 11,
color: '#c8cdd5',
lineHeight: 18,
backgroundColor: theme.bgCard,
padding: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
},
err: { color: theme.danger, marginBottom: 12 },
muted: { color: theme.muted },
})

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

@@ -0,0 +1,410 @@
import { useMemo } from 'react'
import {
View,
Text,
ScrollView,
TextInput,
Pressable,
StyleSheet,
ActivityIndicator,
} 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'
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,
}
export default function RecordScreen() {
const { bind } = useBind()
const r = useRecorder()
const statusColor = useMemo(() => variantColor[r.statusMeta.variant] ?? theme.muted, [r.statusMeta.variant])
if (r.bootError && bind) {
return (
<SafeAreaView style={styles.safe}>
<Text style={styles.err}>{r.bootError}</Text>
</SafeAreaView>
)
}
if (!bind) {
return (
<SafeAreaView style={styles.safe}>
<Text style={styles.hint}> PC</Text>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView contentContainerStyle={styles.scroll}>
<View style={styles.toolbar}>
<Text style={styles.label}></Text>
<View style={styles.pickerWrap}>
<Picker
enabled={!r.emptyHint}
selectedValue={r.currentProcess}
onValueChange={(v) => r.setCurrentProcess(String(v))}
style={styles.picker}
dropdownIconColor={theme.text}
>
{r.entries.map((e) => (
<Picker.Item key={e.pm2} label={e.label} value={e.pm2} color={theme.text} />
))}
</Picker>
</View>
{r.isYoutube && (
<Pressable
style={styles.proRow}
onPress={() => 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>
{r.emptyHint ? (
<Text style={styles.hint}>{r.emptyHint}</Text>
) : (
<>
{r.ent && (
<View style={styles.meta}>
<Text style={styles.metaLabel}></Text>
<Text style={styles.metaName}>{r.ent.label}</Text>
<Text style={styles.metaFile}>{r.ent.script}</Text>
</View>
)}
{r.isYoutube && r.multiChannelPro && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Pro 线</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>
)}
{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.proYoutubeKey}
onChangeText={r.setProYoutubeKey}
placeholderTextColor={theme.muted}
/>
<Pressable
style={[styles.btn, r.saveProKeyLoading && styles.btnDisabled]}
disabled={r.saveProKeyLoading}
onPress={() => void r.saveProChannelKey()}
>
{r.saveProKeyLoading ? (
<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.urlText}
onChangeText={r.setUrlText}
placeholderTextColor={theme.muted}
/>
<View style={styles.row}>
<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.ini</Text>
<TextInput
style={styles.area}
multiline
value={r.youtubeText}
onChangeText={r.setYoutubeText}
placeholderTextColor={theme.muted}
/>
<View style={styles.row}>
<Pressable
style={[styles.btn, styles.btnSec]}
onPress={() => void r.loadConfig('/get_config', r.setYoutubeText, r.setYoutubeStatus)}
>
<Text style={styles.btnSecText}></Text>
</Pressable>
<Pressable
style={[styles.btn, r.saveYoutubeLoading && styles.btnDisabled]}
disabled={r.saveYoutubeLoading}
onPress={() => {
r.setSaveYoutubeLoading(true)
void r.saveConfig('/save_config', r.youtubeText, r.setYoutubeStatus, () =>
r.setSaveYoutubeLoading(false),
)
}}
>
<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>
<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.mono}>srt://live.local:10080/live/obs</Text>
</View>
)}
<View style={[styles.card, styles.cardAccent]}>
<Text style={styles.cardTitle}></Text>
<View style={styles.grid}>
{(
[
['start', '开始录制'],
['stop', '停止录制'],
['restart', '重新开始'],
['status', '刷新状态'],
] as const
).map(([action, label]) => (
<Pressable
key={action}
style={[styles.ctrl, ctrlStyle(action), r.loadingAction === action && styles.btnDisabled]}
disabled={r.loadingAction !== null && action !== 'status'}
onPress={() => void r.runPm2Action(action)}
>
<Text
style={[
styles.ctrlText,
action === 'status' && { color: theme.text },
]}
>
{label}
</Text>
</Pressable>
))}
</View>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></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 },
scroll: { padding: 16, paddingBottom: 32 },
toolbar: { marginBottom: 12 },
label: { fontSize: 12, color: theme.muted, marginBottom: 6 },
pickerWrap: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
overflow: 'hidden',
backgroundColor: theme.bgCard,
},
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' },
meta: {
padding: 12,
borderRadius: 10,
backgroundColor: theme.bgElevated,
borderWidth: 1,
borderColor: theme.border,
marginBottom: 12,
},
metaLabel: { fontSize: 12, color: theme.muted },
metaName: { fontSize: 15, fontWeight: '600', color: theme.text, marginTop: 4 },
metaFile: { fontSize: 11, fontFamily: 'monospace', color: theme.muted, marginTop: 4 },
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 },
area: {
minHeight: 100,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 10,
color: theme.text,
fontFamily: 'monospace',
fontSize: 12,
marginBottom: 10,
backgroundColor: theme.bg,
},
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 },
hintSmall: { color: theme.muted, fontSize: 12, marginTop: 8 },
err: { color: theme.danger, fontSize: 14 },
mono: { fontFamily: 'monospace', color: theme.accent, fontSize: 13 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
ctrl: { flexGrow: 1, minWidth: '42%', paddingVertical: 14, borderRadius: 10, alignItems: 'center' },
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 },
})

177
mobile/app/(tabs)/more.tsx Normal file
View File

@@ -0,0 +1,177 @@
import { useCallback, useEffect, useState } from 'react'
import {
View,
Text,
ScrollView,
StyleSheet,
TextInput,
Pressable,
Switch,
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 ProxyState = { enabled: boolean; http: string; socks: string }
export default function MoreScreen() {
const { bind } = useBind()
const [proxy, setProxy] = useState<ProxyState>({ enabled: false, http: '', socks: '' })
const [metrics, setMetrics] = useState<string>('')
const [msg, setMsg] = useState('')
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) return
setMsg('')
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 || ''),
})
}
const mr = await apiFetch(bind, '/system_metrics')
if (mr.ok) {
const m = await mr.json()
setMetrics(JSON.stringify(m, null, 2))
}
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
}
}, [bind])
useEffect(() => {
void load()
}, [load])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
const saveProxy = useCallback(async () => {
if (!bind) return
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))
setMsg('代理已保存')
} catch (e) {
setMsg(e instanceof Error ? e.message : String(e))
}
}, [bind, proxy])
if (!bind) {
return (
<SafeAreaView style={styles.safe}>
<Text style={styles.muted}></Text>
</SafeAreaView>
)
}
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} />}
>
<Text style={styles.h1}></Text>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
<View style={styles.row}>
<Text style={styles.label}></Text>
<Switch value={proxy.enabled} onValueChange={(v) => setProxy((p) => ({ ...p, enabled: v }))} />
</View>
<Text style={styles.label}>HTTP</Text>
<TextInput
style={styles.input}
value={proxy.http}
onChangeText={(t) => setProxy((p) => ({ ...p, http: t }))}
placeholder="http://..."
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Text style={styles.label}>SOCKS</Text>
<TextInput
style={styles.input}
value={proxy.socks}
onChangeText={(t) => setProxy((p) => ({ ...p, socks: t }))}
placeholder="socks5://..."
placeholderTextColor={theme.muted}
autoCapitalize="none"
/>
<Pressable style={styles.btn} onPress={() => void saveProxy()}>
<Text style={styles.btnText}></Text>
</Pressable>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}></Text>
{metrics ? <Text style={styles.pre}>{metrics}</Text> : <Text style={styles.muted}></Text>}
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}> Chatbot</Text>
<Text style={styles.muted}>
Chatbot Windows
</Text>
</View>
{!!msg && <Text style={styles.ok}>{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: 16 },
card: {
padding: 14,
borderRadius: 12,
backgroundColor: theme.bgCard,
borderWidth: 1,
borderColor: theme.border,
marginBottom: 14,
},
cardTitle: { fontSize: 15, fontWeight: '600', color: theme.text, marginBottom: 12 },
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', 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,
},
btn: {
backgroundColor: '#3d7eff',
paddingVertical: 12,
borderRadius: 10,
alignItems: 'center',
},
btnText: { color: '#fff', fontWeight: '700' },
pre: {
fontFamily: 'monospace',
fontSize: 10,
color: '#c8cdd5',
lineHeight: 16,
},
muted: { color: theme.muted, fontSize: 13, lineHeight: 20 },
ok: { color: theme.success, marginTop: 8 },
})

View File

@@ -0,0 +1,98 @@
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 { theme } from '@/constants/theme'
export default function NetworkScreen() {
const { bind } = useBind()
const [raw, setRaw] = useState<string>('')
const [err, setErr] = useState('')
const [refreshing, setRefreshing] = useState(false)
const load = useCallback(async () => {
if (!bind) {
setErr('未绑定')
setRaw('')
return
}
setErr('')
try {
const r = await apiFetch(bind, '/network_status')
if (!r.ok) throw new Error(String(r.status))
const j = await r.json()
setRaw(JSON.stringify(j, null, 2))
} catch (e) {
setErr(e instanceof Error ? e.message : String(e))
setRaw('')
}
}, [bind])
useEffect(() => {
void load()
}, [load])
const onRefresh = useCallback(async () => {
setRefreshing(true)
await load()
setRefreshing(false)
}, [load])
return (
<SafeAreaView style={styles.safe} edges={['bottom']}>
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={() => void onRefresh()} />}
>
<Text style={styles.h1}></Text>
<Text style={styles.lead}>Google / EasyTier </Text>
{bind && (
<View style={styles.et}>
<Text style={styles.etTitle}>EasyTier</Text>
<Text style={styles.mono}> {bind.easytier.networkName}</Text>
<Text style={styles.mutedSmall}>
peer PC 访 {bind.baseUrl}
</Text>
</View>
)}
{err ? <Text style={styles.err}>{err}</Text> : null}
{raw ? <Text style={styles.pre}>{raw}</Text> : !err && bind ? <Text style={styles.muted}></Text> : null}
</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, marginBottom: 16, lineHeight: 22 },
et: {
padding: 14,
borderRadius: 12,
backgroundColor: 'rgba(124,92,255,0.08)',
borderWidth: 1,
borderColor: 'rgba(124,92,255,0.25)',
marginBottom: 16,
},
etTitle: { fontSize: 14, fontWeight: '600', color: theme.text, marginBottom: 8 },
mono: { fontFamily: 'monospace', fontSize: 12, color: theme.accent },
mutedSmall: { fontSize: 12, color: theme.muted, marginTop: 8, lineHeight: 18 },
pre: {
fontFamily: 'monospace',
fontSize: 11,
color: '#c8cdd5',
lineHeight: 18,
backgroundColor: theme.bgCard,
padding: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: theme.border,
},
err: { color: theme.danger, marginBottom: 12 },
muted: { color: theme.muted },
})