'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 },
})

38
mobile/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
mobile/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
mobile/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>
);
}