Files
gitlab-instance-0a899031_d2…/app/(tabs)/bind.tsx
2026-03-25 16:14:50 -05:00

365 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useRef, useState } from 'react'
import {
View,
Text,
TextInput,
Pressable,
ScrollView,
StyleSheet,
Alert,
Platform,
} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { CameraView, useCameraPermissions } from 'expo-camera'
import { Ionicons } from '@expo/vector-icons'
import { LinearGradient } from 'expo-linear-gradient'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useBind } from '@/context/BindContext'
import { parseBindPayload, type BindPayload } from '@/lib/types'
import { theme } from '@/constants/theme'
function normalizeHostInput(hostInput: string): { baseUrl: string; port: number } | null {
const s = hostInput.trim()
if (!s) return null
if (/^https?:\/\//i.test(s)) {
try {
const u = new URL(s)
const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80))
if (!Number.isFinite(port)) return null
return { baseUrl: u.origin, port }
} catch {
return null
}
}
const noPath = s.split('/')[0]
const i = noPath.lastIndexOf(':')
if (i <= 0) return null
const host = noPath.slice(0, i).trim()
const port = Number(noPath.slice(i + 1))
if (!host || !Number.isFinite(port) || port <= 0) return null
return { baseUrl: `http://${host}:${port}`, port }
}
export default function BindScreen() {
const { bind, setBind, clear, ready } = useBind()
const [msg, setMsg] = useState('')
const [hostText, setHostText] = useState('')
const [scanOn, setScanOn] = useState(false)
const [perm, requestPerm] = useCameraPermissions()
const scannedRef = useRef(false)
useEffect(() => {
if (!scanOn) scannedRef.current = false
}, [scanOn])
useEffect(() => {
if (!bind?.baseUrl) {
setHostText('')
return
}
const v = bind.baseUrl.replace(/^https?:\/\//i, '')
setHostText(v)
}, [bind?.baseUrl])
const applyPayload = useCallback(
async (p: BindPayload) => {
await setBind(p)
setMsg('已导入绑定(含 token。可在下方修改 IP/域名与端口后再点「保存」。')
},
[setBind],
)
const onBarcodeScanned = useCallback(
(result: { data: string }) => {
if (scannedRef.current) return
const p = parseBindPayload(result.data)
if (!p) {
setMsg('二维码内容不是有效的绑定 JSON需 v1、baseUrl 等字段)。')
return
}
scannedRef.current = true
void (async () => {
await applyPayload(p)
setScanOn(false)
})()
},
[applyPayload],
)
const onPasteJson = useCallback(async () => {
const raw = (await Clipboard.getStringAsync()).trim()
const p = parseBindPayload(raw)
if (!p) {
setMsg('剪贴板内容不是有效的绑定 JSON。')
return
}
await applyPayload(p)
}, [applyPayload])
const onSaveHost = useCallback(async () => {
const n = normalizeHostInput(hostText)
if (!n) {
setMsg('请输入 `ip/域名:端口`(或带 http:// 的 baseUrl。')
return
}
const p: BindPayload = { v: 1, baseUrl: n.baseUrl, port: n.port, token: bind?.token ?? '' }
await setBind(p)
setMsg('已保存绑定地址;未改动的 token 已保留。')
}, [hostText, bind?.token, setBind])
const onResetHost = useCallback(async () => {
setMsg('')
Alert.alert('清除配对', '确定要删除当前绑定地址吗?', [
{ text: '取消', style: 'cancel' },
{ text: '清除', style: 'destructive', onPress: () => void clear() },
])
}, [clear])
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
const url = `${bind.baseUrl}/health`
setMsg('正在测试 API 连接…')
const ac = new AbortController()
const t = setTimeout(() => ac.abort(), 15000)
try {
const r = await fetch(url, {
signal: ac.signal,
headers: bind.token ? { Authorization: `Bearer ${bind.token}` } : undefined,
})
const j = await r.json().catch(() => ({}))
setMsg('')
Alert.alert('连接测试', r.ok ? `OK: ${JSON.stringify(j)}` : `HTTP ${r.status}`)
} catch (e) {
const isAbort = e instanceof Error && e.name === 'AbortError'
setMsg('')
Alert.alert(
isAbort ? '连接超时' : '连接失败',
isAbort
? '15 秒内无响应。请确认手机能访问绑定里的 baseUrl:端口(或域名:端口PC 上 web2 服务已启动,且防火墙/网络路由允许(换网后可能需要重新扫码或改地址)。'
: e instanceof Error
? e.message
: String(e),
)
} finally {
clearTimeout(t)
}
}, [bind])
const openScan = useCallback(async () => {
setMsg('')
if (Platform.OS === 'web') {
setMsg('网页版请使用「从剪贴板导入 JSON」。')
return
}
if (!perm?.granted) {
const r = await requestPerm()
if (!r.granted) {
setMsg('需要相机权限才能扫码。')
return
}
}
setScanOn(true)
}, [perm?.granted, requestPerm])
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}></Text>
<Text style={styles.lead}>
PC `ip/域名:端口` token PC token
</Text>
{!!msg && <Text style={styles.msgBanner}>{msg}</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}>token{bind.token ? '已保存' : '无'}</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}> JSON</Text>
<Text style={styles.body}> PC baseUrlporttoken</Text>
<View style={styles.etRow}>
<Pressable style={styles.btnPrimary} onPress={() => void openScan()}>
<Ionicons name="qr-code-outline" size={20} color="#fff" />
<Text style={styles.btnPrimaryText}> </Text>
</Pressable>
<Pressable style={styles.btnSecondary} onPress={() => void onPasteJson()}>
<Text style={styles.btnSecondaryText}> JSON</Text>
</Pressable>
</View>
</View>
{scanOn && Platform.OS !== 'web' && (
<View style={styles.camWrap}>
<CameraView
style={styles.camera}
facing="back"
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={onBarcodeScanned}
/>
<Pressable style={styles.camClose} onPress={() => setScanOn(false)}>
<Text style={styles.btnText}></Text>
</Pressable>
</View>
)}
<View style={styles.block}>
<Text style={styles.h2}></Text>
<Text style={styles.body}>
`ip/域名:端口` `192.168.21.222:8001` token token PC
</Text>
<TextInput
style={styles.input}
placeholder="ip/域名:端口"
placeholderTextColor={theme.muted}
value={hostText}
onChangeText={setHostText}
autoCapitalize="none"
autoCorrect={false}
/>
<View style={styles.row}>
<Pressable style={styles.btn} onPress={() => void onSaveHost()}>
<Text style={styles.btnText}></Text>
</Pressable>
<Pressable style={[styles.btn, styles.btnDanger]} onPress={() => void onResetHost()}>
<Text style={styles.btnText}>/</Text>
</Pressable>
</View>
</View>
<View style={styles.block}>
<Text style={styles.h2}></Text>
<Text style={styles.body}> PC baseUrl token</Text>
{bind && (
<View style={styles.etActions}>
<Pressable style={styles.btnSecondary} onPress={() => void copyFull()}>
<Text style={styles.btnSecondaryText}> JSON</Text>
</Pressable>
</View>
)}
</View>
</ScrollView>
</SafeAreaView>
)
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: theme.bg },
scroll: { padding: 20, paddingBottom: 40 },
h1: { fontSize: 22, fontWeight: '700', color: theme.text, marginBottom: 8 },
lead: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 16 },
h2: { fontSize: 16, fontWeight: '600', color: theme.text, marginBottom: 8 },
body: { fontSize: 14, color: theme.muted, lineHeight: 22, marginBottom: 10 },
block: { marginBottom: 22 },
card: {
borderRadius: 12,
padding: 16,
marginBottom: 18,
borderWidth: 1,
borderColor: theme.border,
},
cardTitle: { fontSize: 13, fontWeight: '600', color: theme.accent, marginBottom: 8 },
mono: { fontFamily: 'monospace', fontSize: 13, color: theme.text, marginBottom: 6 },
muted: { color: theme.muted, fontSize: 14 },
mutedSmall: { color: theme.muted, fontSize: 12, marginTop: 6 },
row: { flexDirection: 'row', gap: 10, marginTop: 12 },
etRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 10, marginTop: 12 },
etActions: { gap: 10, marginTop: 12 },
btn: {
backgroundColor: theme.bgElevated,
paddingVertical: 10,
paddingHorizontal: 14,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.border,
},
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,
flex: 1,
minWidth: '45%',
},
btnPrimaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
btnSecondary: {
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
paddingVertical: 12,
paddingHorizontal: 12,
alignItems: 'center',
backgroundColor: theme.bgCard,
flex: 1,
minWidth: '45%',
},
btnSecondaryText: { color: theme.text, fontWeight: '600', fontSize: 13 },
input: {
minHeight: 52,
borderWidth: 1,
borderColor: theme.border,
borderRadius: 10,
padding: 12,
color: theme.text,
fontFamily: 'monospace',
fontSize: 14,
marginBottom: 12,
backgroundColor: theme.bgCard,
},
camWrap: { borderRadius: 12, overflow: 'hidden', backgroundColor: '#000', marginBottom: 18 },
camera: { height: 280, width: '100%' },
camClose: { padding: 12, alignItems: 'center', backgroundColor: '#111' },
msgBanner: {
color: theme.success,
fontSize: 14,
lineHeight: 22,
marginBottom: 14,
padding: 12,
borderRadius: 10,
backgroundColor: 'rgba(61,126,255,0.12)',
borderWidth: 1,
borderColor: theme.border,
},
})