This commit is contained in:
eric
2026-03-24 14:47:03 -05:00
parent b914f0947a
commit efcec61d36
64 changed files with 22242 additions and 32 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>
);
}