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 ( 加载中… ) } return ( 配对与 EasyTier 在 PC 控制台「网络 → 远程控制」开启远程并生成二维码,用手机扫描;无需公网,请确保手机与 PC 在同一 EasyTier 虚拟网或局域网。 {bind && ( 当前已绑定 {bind.baseUrl} 组网名 · {bind.easytier.networkName} void testConn()}> 测试 API { Alert.alert('清除配对', '确定要清除本机保存的绑定信息?', [ { text: '取消', style: 'cancel' }, { text: '清除', style: 'destructive', onPress: () => void clear() }, ]) }} > 清除 )} EasyTier 无公网互通 PC 端已内置 easytier-core 并写入组网名与密钥。手机需运行官方 EasyTier 客户端并填入 相同 组网参数,获得虚拟 IP 后即可访问上方 API 地址(通常为 http://虚拟IP:8001)。 void Linking.openURL(ET_RELEASE)} style={styles.linkRow}> EasyTier 发行版(含 Android) {bind && ( void copyEtJson()}> 复制组网 JSON void copyFull()}> 复制完整绑定 JSON )} 扫码 {!scanOn ? ( { const r = await requestPerm() if (!r.granted) { setMsg('需要相机权限才能扫码') return } scannedRef.current = false setScanOn(true) }} > 打开相机扫 PC 二维码 ) : perm?.granted ? ( setScanOn(false)}> 关闭相机 ) : ( 无相机权限 )} 手动粘贴 JSON void onPasteApply()}> 解析并保存 {!!msg && {msg}} ) } 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 }, })