365 lines
12 KiB
TypeScript
365 lines
12 KiB
TypeScript
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 端「远程控制」面板中的二维码内容一致(含 baseUrl、port、token)。</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,
|
||
},
|
||
})
|