's'
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { Platform } from 'react-native'
|
||||||
import { Tabs } from 'expo-router'
|
import { Tabs } from 'expo-router'
|
||||||
import { Ionicons } from '@expo/vector-icons'
|
import { Ionicons } from '@expo/vector-icons'
|
||||||
|
|
||||||
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
|||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const colorScheme = useColorScheme()
|
const colorScheme = useColorScheme()
|
||||||
|
const dark = colorScheme === 'dark'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs
|
<Tabs
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
tabBarActiveTintColor: Colors[colorScheme].tint,
|
tabBarActiveTintColor: Colors[colorScheme].tint,
|
||||||
|
tabBarInactiveTintColor: dark ? 'rgba(255,255,255,0.38)' : 'rgba(0,0,0,0.35)',
|
||||||
|
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', letterSpacing: 0.2 },
|
||||||
tabBarStyle: {
|
tabBarStyle: {
|
||||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
backgroundColor: dark ? '#161a22' : '#fff',
|
||||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.06)',
|
||||||
|
height: Platform.OS === 'ios' ? 88 : 64,
|
||||||
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
headerStyle: {
|
headerStyle: {
|
||||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
backgroundColor: dark ? '#161a22' : '#fff',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOpacity: dark ? 0.25 : 0.08,
|
||||||
|
shadowRadius: 12,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
elevation: 4,
|
||||||
},
|
},
|
||||||
headerTintColor: Colors[colorScheme].text,
|
headerTintColor: Colors[colorScheme].text,
|
||||||
headerShown: useClientOnlyValue(false, true),
|
headerShown: useClientOnlyValue(false, true),
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ function SnapshotNet({ data }: { data: unknown }) {
|
|||||||
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
{g?.latency_ms != null && g.latency_ms >= 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.snapLine}>
|
<Text style={styles.snapLine}>
|
||||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -196,7 +196,7 @@ export default function DeskScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||||
<Text style={styles.liveLine} numberOfLines={2}>
|
<Text style={styles.liveLine} numberOfLines={2}>
|
||||||
抖音 {row.douyinHint ?? '—'}
|
源站 {row.douyinHint ?? '—'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.liveLine} numberOfLines={2}>
|
<Text style={styles.liveLine} numberOfLines={2}>
|
||||||
标题 {row.streamTitle ?? '—'}
|
标题 {row.streamTitle ?? '—'}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'
|
||||||
|
import { useNavigation } from 'expo-router'
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient'
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated'
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -15,6 +18,7 @@ import {
|
|||||||
import { Picker } from '@react-native-picker/picker'
|
import { Picker } from '@react-native-picker/picker'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { useRecorder } from '@/hooks/useRecorder'
|
import { useRecorder } from '@/hooks/useRecorder'
|
||||||
import { theme } from '@/constants/theme'
|
import { theme } from '@/constants/theme'
|
||||||
@@ -121,7 +125,7 @@ function ProLiveSwitchGrid({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
{href ? (
|
{href ? (
|
||||||
<Pressable onPress={() => void Linking.openURL(href)}>
|
<Pressable onPress={() => void Linking.openURL(href)}>
|
||||||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
<Text style={styles.proLink}>在浏览器打开源站页</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.proActions}>
|
<View style={styles.proActions}>
|
||||||
@@ -199,8 +203,27 @@ function LiveSwitchGrid({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RecordScreen() {
|
export default function RecordScreen() {
|
||||||
|
const navigation = useNavigation()
|
||||||
const { bind } = useBind()
|
const { bind } = useBind()
|
||||||
|
const { session, openLogin, logout } = useAuth()
|
||||||
const r = useRecorder()
|
const r = useRecorder()
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => void (session ? logout() : openLogin())}
|
||||||
|
style={{ paddingHorizontal: 12, paddingVertical: 6 }}
|
||||||
|
>
|
||||||
|
<Text style={{ color: theme.accent, fontWeight: '700', fontSize: 14 }}>
|
||||||
|
{session?.record?.email
|
||||||
|
? `${String(session.record.email).split('@')[0]}…`
|
||||||
|
: '登录'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}, [navigation, session, openLogin, logout])
|
||||||
const unbound = !bind
|
const unbound = !bind
|
||||||
const [refreshing, setRefreshing] = useState(false)
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
|
||||||
@@ -258,49 +281,58 @@ export default function RecordScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<View style={styles.toolbarCard}>
|
<Animated.View entering={FadeInDown.duration(420).springify()}>
|
||||||
<View style={styles.toolbarRow}>
|
<LinearGradient
|
||||||
<View style={styles.toolbarLeft}>
|
colors={['rgba(91,157,255,0.14)', 'rgba(20,22,28,0.92)']}
|
||||||
<View
|
start={{ x: 0, y: 0 }}
|
||||||
style={[
|
end={{ x: 1, y: 1 }}
|
||||||
styles.statusChip,
|
style={styles.toolbarGradient}
|
||||||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
>
|
||||||
]}
|
<View style={styles.toolbarCard}>
|
||||||
>
|
<View style={styles.toolbarRow}>
|
||||||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
<View style={styles.toolbarLeft}>
|
||||||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
<View
|
||||||
|
style={[
|
||||||
|
styles.statusChip,
|
||||||
|
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
||||||
|
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.statusLineText} numberOfLines={1}>
|
||||||
|
{r.statusMeta.line}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.statusLineText} numberOfLines={1}>
|
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||||
{r.statusMeta.line}
|
<View style={styles.proLabelRow}>
|
||||||
</Text>
|
<Text style={styles.proLabelZh}>多频道</Text>
|
||||||
</View>
|
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||||
</View>
|
<Text style={styles.proBadgeText}>PRO</Text>
|
||||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
</View>
|
||||||
<View style={styles.proLabelRow}>
|
</View>
|
||||||
<Text style={styles.proLabelZh}>多频道</Text>
|
<View style={styles.proSwitchWrap}>
|
||||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
<Switch
|
||||||
<Text style={styles.proBadgeText}>PRO</Text>
|
value={r.multiChannelPro}
|
||||||
|
disabled={unbound}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (unbound) return
|
||||||
|
void r.togglePro(v)
|
||||||
|
}}
|
||||||
|
trackColor={{
|
||||||
|
false: 'rgba(255,255,255,0.12)',
|
||||||
|
true: 'rgba(91, 157, 255, 0.45)',
|
||||||
|
}}
|
||||||
|
thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'}
|
||||||
|
ios_backgroundColor="rgba(255,255,255,0.1)"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.proSwitchWrap}>
|
|
||||||
<Switch
|
|
||||||
value={r.multiChannelPro}
|
|
||||||
disabled={unbound}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (unbound) return
|
|
||||||
void r.togglePro(v)
|
|
||||||
}}
|
|
||||||
trackColor={{
|
|
||||||
false: 'rgba(255,255,255,0.12)',
|
|
||||||
true: 'rgba(91, 157, 255, 0.45)',
|
|
||||||
}}
|
|
||||||
thumbColor={r.multiChannelPro ? '#7eb6ff' : '#5c6370'}
|
|
||||||
ios_backgroundColor="rgba(255,255,255,0.1)"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</LinearGradient>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
|
||||||
{bind ? (
|
{bind ? (
|
||||||
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
||||||
@@ -719,9 +751,13 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: 14,
|
marginBottom: 14,
|
||||||
},
|
},
|
||||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||||
toolbarCard: {
|
toolbarGradient: {
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
borderRadius: 14,
|
borderRadius: 16,
|
||||||
|
padding: 1,
|
||||||
|
},
|
||||||
|
toolbarCard: {
|
||||||
|
borderRadius: 15,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255,255,255,0.07)',
|
borderColor: 'rgba(255,255,255,0.07)',
|
||||||
backgroundColor: theme.bgElevated,
|
backgroundColor: theme.bgElevated,
|
||||||
|
|||||||
@@ -246,7 +246,7 @@ export default function NetworkScreen() {
|
|||||||
|
|
||||||
{!bind && (
|
{!bind && (
|
||||||
<View style={styles.banner}>
|
<View style={styles.banner}>
|
||||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 国内站点 与测速。</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
|
|||||||
onSolution={openSolution}
|
onSolution={openSolution}
|
||||||
/>
|
/>
|
||||||
<SiteCard
|
<SiteCard
|
||||||
title="抖音(国内参考)"
|
title="国内站点(参考)"
|
||||||
site={view.douyin}
|
site={view.douyin}
|
||||||
speed={view.speed.domestic}
|
speed={view.speed.domestic}
|
||||||
loading={loading && !!bind}
|
loading={loading && !!bind}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import { useEffect } from 'react';
|
|||||||
import 'react-native-reanimated';
|
import 'react-native-reanimated';
|
||||||
|
|
||||||
import { useColorScheme } from '@/components/useColorScheme';
|
import { useColorScheme } from '@/components/useColorScheme';
|
||||||
|
import { AuthProvider } from '@/context/AuthContext';
|
||||||
import { BindProvider } from '@/context/BindContext';
|
import { BindProvider } from '@/context/BindContext';
|
||||||
|
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
// Catch any errors thrown by the Layout component.
|
// Catch any errors thrown by the Layout component.
|
||||||
@@ -55,12 +57,15 @@ function RootLayoutNav() {
|
|||||||
const colorScheme = useColorScheme();
|
const colorScheme = useColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BindProvider>
|
<AuthProvider>
|
||||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
<BindProvider>
|
||||||
<Stack>
|
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
<Stack>
|
||||||
</Stack>
|
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||||
</ThemeProvider>
|
</Stack>
|
||||||
</BindProvider>
|
<PocketBaseAuthModal />
|
||||||
|
</ThemeProvider>
|
||||||
|
</BindProvider>
|
||||||
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
135
components/PocketBaseAuthModal.tsx
Normal file
135
components/PocketBaseAuthModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Linking,
|
||||||
|
Modal,
|
||||||
|
Platform,
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
} from 'react-native'
|
||||||
|
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
|
import { theme } from '@/constants/theme'
|
||||||
|
|
||||||
|
const VIP_URL = 'https://vip.hackrobot.cn/'
|
||||||
|
|
||||||
|
export function PocketBaseAuthModal() {
|
||||||
|
const { loginVisible, closeLogin, login } = useAuth()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [err, setErr] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
setErr(null)
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await login(email.trim(), password)
|
||||||
|
setPassword('')
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={loginVisible} animationType="fade" transparent onRequestClose={closeLogin}>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.backdrop}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.scrim} onPress={closeLogin} />
|
||||||
|
<View style={styles.sheet}>
|
||||||
|
<Text style={styles.title}>会员授权</Text>
|
||||||
|
<Text style={styles.sub}>
|
||||||
|
开始或重新开始直播前,请使用异度星球账号登录。授权保存在本机,可随时在首页退出。
|
||||||
|
</Text>
|
||||||
|
{err ? <Text style={styles.err}>{err}</Text> : null}
|
||||||
|
<Text style={styles.lbl}>邮箱</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
placeholderTextColor={theme.muted}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType="email-address"
|
||||||
|
/>
|
||||||
|
<Text style={styles.lbl}>密码</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
placeholder="••••••••"
|
||||||
|
placeholderTextColor={theme.muted}
|
||||||
|
secureTextEntry
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.btn, busy && styles.btnDisabled]}
|
||||||
|
disabled={busy}
|
||||||
|
onPress={() => void onSubmit()}
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.btnText}>登录并授权</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
<View style={styles.row}>
|
||||||
|
<Pressable onPress={() => void Linking.openURL(VIP_URL)}>
|
||||||
|
<Text style={styles.link}>注册</Text>
|
||||||
|
</Pressable>
|
||||||
|
<Text style={styles.sep}>·</Text>
|
||||||
|
<Pressable onPress={closeLogin}>
|
||||||
|
<Text style={styles.linkMuted}>取消</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
backdrop: { flex: 1, justifyContent: 'center', padding: 20 },
|
||||||
|
scrim: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.55)' },
|
||||||
|
sheet: {
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 20,
|
||||||
|
backgroundColor: theme.bgCard,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.border,
|
||||||
|
},
|
||||||
|
title: { fontSize: 18, fontWeight: '700', color: theme.text, marginBottom: 8 },
|
||||||
|
sub: { fontSize: 13, color: theme.muted, lineHeight: 20, marginBottom: 14 },
|
||||||
|
err: { color: theme.danger, fontSize: 13, marginBottom: 10 },
|
||||||
|
lbl: { fontSize: 12, fontWeight: '600', color: theme.muted, marginBottom: 6 },
|
||||||
|
input: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.border,
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 12,
|
||||||
|
color: theme.text,
|
||||||
|
marginBottom: 12,
|
||||||
|
backgroundColor: theme.bg,
|
||||||
|
},
|
||||||
|
btn: {
|
||||||
|
backgroundColor: theme.accent,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
btnDisabled: { opacity: 0.6 },
|
||||||
|
btnText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||||
|
row: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', marginTop: 16, gap: 8 },
|
||||||
|
link: { color: theme.accent, fontWeight: '600', fontSize: 14 },
|
||||||
|
linkMuted: { color: theme.muted, fontSize: 14 },
|
||||||
|
sep: { color: theme.muted },
|
||||||
|
})
|
||||||
166
context/AuthContext.tsx
Normal file
166
context/AuthContext.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
import { Alert } from 'react-native'
|
||||||
|
|
||||||
|
import { clearPbSession, loadPbSession, savePbSession, type StoredPbSession } from '@/lib/authStorage'
|
||||||
|
import {
|
||||||
|
isLiveAllowed,
|
||||||
|
pbAuthRefresh,
|
||||||
|
pbAuthWithPassword,
|
||||||
|
type PbUserRecord,
|
||||||
|
} from '@/lib/pocketbase'
|
||||||
|
|
||||||
|
type Ctx = {
|
||||||
|
session: StoredPbSession | null
|
||||||
|
ready: boolean
|
||||||
|
loginVisible: boolean
|
||||||
|
openLogin: () => void
|
||||||
|
closeLogin: () => void
|
||||||
|
login: (email: string, password: string) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refreshSession: () => Promise<boolean>
|
||||||
|
/** 用于远程 API 请求头 */
|
||||||
|
pbToken: string | null
|
||||||
|
/** 开始/重启直播前调用;未登录则弹出登录;无权限则 false */
|
||||||
|
ensureCanControlLive: () => Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<Ctx | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [session, setSession] = useState<StoredPbSession | null>(null)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [loginVisible, setLoginVisible] = useState(false)
|
||||||
|
const pendingGate = useRef<((ok: boolean) => void) | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void (async () => {
|
||||||
|
const s = await loadPbSession()
|
||||||
|
if (s) {
|
||||||
|
try {
|
||||||
|
const r = await pbAuthRefresh(s.token)
|
||||||
|
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||||
|
setSession(next)
|
||||||
|
await savePbSession(next)
|
||||||
|
} catch {
|
||||||
|
await clearPbSession()
|
||||||
|
setSession(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setReady(true)
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const openLogin = useCallback(() => setLoginVisible(true), [])
|
||||||
|
const closeLogin = useCallback(() => {
|
||||||
|
setLoginVisible(false)
|
||||||
|
const p = pendingGate.current
|
||||||
|
pendingGate.current = null
|
||||||
|
p?.(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const r = await pbAuthWithPassword(email, password)
|
||||||
|
if (!isLiveAllowed(r.record)) {
|
||||||
|
throw new Error('该账号暂无直播权限,请联系管理员')
|
||||||
|
}
|
||||||
|
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||||
|
setSession(next)
|
||||||
|
await savePbSession(next)
|
||||||
|
setLoginVisible(false)
|
||||||
|
const p = pendingGate.current
|
||||||
|
pendingGate.current = null
|
||||||
|
p?.(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await clearPbSession()
|
||||||
|
setSession(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refreshSession = useCallback(async () => {
|
||||||
|
if (!session?.token) return false
|
||||||
|
try {
|
||||||
|
const r = await pbAuthRefresh(session.token)
|
||||||
|
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||||
|
setSession(next)
|
||||||
|
await savePbSession(next)
|
||||||
|
return isLiveAllowed(r.record)
|
||||||
|
} catch {
|
||||||
|
await clearPbSession()
|
||||||
|
setSession(null)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [session?.token])
|
||||||
|
|
||||||
|
const ensureCanControlLive = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (session?.token) {
|
||||||
|
try {
|
||||||
|
const r = await pbAuthRefresh(session.token)
|
||||||
|
const next: StoredPbSession = { token: r.token, record: r.record }
|
||||||
|
setSession(next)
|
||||||
|
await savePbSession(next)
|
||||||
|
if (!isLiveAllowed(r.record)) {
|
||||||
|
Alert.alert('无权限', '账号已被管理员停用远程直播权限。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
await clearPbSession()
|
||||||
|
setSession(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
pendingGate.current = (ok) => resolve(ok)
|
||||||
|
setLoginVisible(true)
|
||||||
|
})
|
||||||
|
}, [session?.token])
|
||||||
|
|
||||||
|
const pbToken = session?.token ?? null
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
session,
|
||||||
|
ready,
|
||||||
|
loginVisible,
|
||||||
|
openLogin,
|
||||||
|
closeLogin,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshSession,
|
||||||
|
pbToken,
|
||||||
|
ensureCanControlLive,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
session,
|
||||||
|
ready,
|
||||||
|
loginVisible,
|
||||||
|
openLogin,
|
||||||
|
closeLogin,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshSession,
|
||||||
|
pbToken,
|
||||||
|
ensureCanControlLive,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): Ctx {
|
||||||
|
const c = useContext(AuthContext)
|
||||||
|
if (!c) throw new Error('useAuth outside AuthProvider')
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuthOptional(): Ctx | null {
|
||||||
|
return useContext(AuthContext)
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Alert } from 'react-native'
|
import { Alert } from 'react-native'
|
||||||
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { useBind } from '@/context/BindContext'
|
import { useBind } from '@/context/BindContext'
|
||||||
import { apiFetch, apiJson } from '@/lib/api'
|
import { apiFetch, apiJson } from '@/lib/api'
|
||||||
|
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
|
||||||
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
||||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||||
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
|
|||||||
|
|
||||||
export function useRecorder() {
|
export function useRecorder() {
|
||||||
const { bind } = useBind()
|
const { bind } = useBind()
|
||||||
|
const { pbToken, ensureCanControlLive, session } = useAuth()
|
||||||
|
const pbTokRef = useRef(pbToken)
|
||||||
|
pbTokRef.current = pbToken
|
||||||
const [entries, setEntries] = useState<Entry[]>([])
|
const [entries, setEntries] = useState<Entry[]>([])
|
||||||
const [currentProcess, setCurrentProcess] = useState('')
|
const [currentProcess, setCurrentProcess] = useState('')
|
||||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||||
@@ -135,7 +140,7 @@ export function useRecorder() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bindKey) return
|
if (!bindKey) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
void apiFetch(bindRef.current!, '/host/gpu_status')
|
void apiFetch(bindRef.current!, '/host/gpu_status', { pbToken: pbTokRef.current })
|
||||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||||||
.then((j: GpuCaps) => {
|
.then((j: GpuCaps) => {
|
||||||
if (!cancelled)
|
if (!cancelled)
|
||||||
@@ -166,7 +171,7 @@ export function useRecorder() {
|
|||||||
const loadProcessMonitor = useCallback(async () => {
|
const loadProcessMonitor = useCallback(async () => {
|
||||||
const b = bindRef.current
|
const b = bindRef.current
|
||||||
if (!b) throw new Error('尚未绑定 PC')
|
if (!b) throw new Error('尚未绑定 PC')
|
||||||
const res = await apiFetch(b, '/process_monitor')
|
const res = await apiFetch(b, '/process_monitor', { pbToken: pbTokRef.current })
|
||||||
if (!res.ok) throw new Error(String(res.status))
|
if (!res.ok) throw new Error(String(res.status))
|
||||||
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
||||||
const raw: Entry[] = (data.entries || []).map((e) => ({
|
const raw: Entry[] = (data.entries || []).map((e) => ({
|
||||||
@@ -186,6 +191,7 @@ export function useRecorder() {
|
|||||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||||
b,
|
b,
|
||||||
'/relay_pro/channels',
|
'/relay_pro/channels',
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||||
@@ -220,7 +226,7 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
setYoutubeStatus('正在将当前配置同步到多频道…')
|
setYoutubeStatus('正在将当前配置同步到多频道…')
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(b, '/relay_pro/channels')
|
const res = await apiFetch(b, '/relay_pro/channels', { pbToken: pbTokRef.current })
|
||||||
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
|
const d = (await res.json()) as { ok?: boolean; channels?: { id: string }[]; error?: string }
|
||||||
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
||||||
const firstId = d.channels[0].id
|
const firstId = d.channels[0].id
|
||||||
@@ -231,6 +237,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const kj = (await keyRes.json()) as { error?: string }
|
const kj = (await keyRes.json()) as { error?: string }
|
||||||
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
||||||
@@ -241,6 +248,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: urlText }),
|
body: JSON.stringify({ content: urlText }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const uj = (await urlRes.json()) as { error?: string }
|
const uj = (await urlRes.json()) as { error?: string }
|
||||||
@@ -258,6 +266,7 @@ export function useRecorder() {
|
|||||||
options: ytIniModel.options,
|
options: ytIniModel.options,
|
||||||
header: ytIniModel.header || '',
|
header: ytIniModel.header || '',
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
@@ -282,6 +291,7 @@ export function useRecorder() {
|
|||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||||
const data = (await res.json()) as { content?: string }
|
const data = (await res.json()) as { content?: string }
|
||||||
@@ -310,6 +320,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify({ content }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||||
@@ -330,6 +341,7 @@ export function useRecorder() {
|
|||||||
const res = await apiFetch(
|
const res = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
@@ -375,6 +387,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(modelToSave),
|
body: JSON.stringify(modelToSave),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
@@ -404,6 +417,7 @@ export function useRecorder() {
|
|||||||
keys: keysOne,
|
keys: keysOne,
|
||||||
activeIndex: 0,
|
activeIndex: 0,
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||||
@@ -450,6 +464,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ content: ed.urlText }),
|
body: JSON.stringify({ content: ed.urlText }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
const data = (await res.json()) as { error?: string; message?: string }
|
const data = (await res.json()) as { error?: string; message?: string }
|
||||||
@@ -469,6 +484,7 @@ export function useRecorder() {
|
|||||||
const ur = await apiFetch(
|
const ur = await apiFetch(
|
||||||
b,
|
b,
|
||||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!ur.ok) {
|
if (!ur.ok) {
|
||||||
setUrlStatus('读取失败')
|
setUrlStatus('读取失败')
|
||||||
@@ -518,9 +534,17 @@ export function useRecorder() {
|
|||||||
}
|
}
|
||||||
const proc = procForPm2(processOverride)
|
const proc = procForPm2(processOverride)
|
||||||
const isStatus = action === 'status'
|
const isStatus = action === 'status'
|
||||||
if (!isStatus) setLoadingAction(action)
|
if (!isStatus) {
|
||||||
|
if (action === 'start' || action === 'restart') {
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
|
}
|
||||||
|
setLoadingAction(action)
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`)
|
const res = await apiFetch(bindRef.current, `/${action}?process=${encodeURIComponent(proc)}`, {
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
|
|
||||||
@@ -551,6 +575,15 @@ export function useRecorder() {
|
|||||||
const actionLabel =
|
const actionLabel =
|
||||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||||
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
setLogText(`已对「${who}」执行:${actionLabel}\n\n${out}`)
|
||||||
|
if (action === 'start' || action === 'restart' || action === 'stop') {
|
||||||
|
void reportLiveControlEvent(
|
||||||
|
bindRef.current,
|
||||||
|
pbTokRef.current,
|
||||||
|
session?.record?.id,
|
||||||
|
proc,
|
||||||
|
action,
|
||||||
|
)
|
||||||
|
}
|
||||||
const statusProc = processOverride ?? proc
|
const statusProc = processOverride ?? proc
|
||||||
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
||||||
}
|
}
|
||||||
@@ -561,18 +594,22 @@ export function useRecorder() {
|
|||||||
if (!isStatus) setLoadingAction(null)
|
if (!isStatus) setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[bindRef, procForPm2, getEntry],
|
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const runProChannelStart = useCallback(
|
const runProChannelStart = useCallback(
|
||||||
async (channelId: string) => {
|
async (channelId: string) => {
|
||||||
if (!channelId) return
|
if (!channelId) return
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
const pm = proChannelPm2Process(channelId)
|
const pm = proChannelPm2Process(channelId)
|
||||||
setLoadingAction('start')
|
setLoadingAction('start')
|
||||||
try {
|
try {
|
||||||
const saved = await saveProChannelKeysFor(channelId)
|
const saved = await saveProChannelKeysFor(channelId)
|
||||||
if (!saved) return
|
if (!saved) return
|
||||||
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`)
|
const res = await apiFetch(bindRef.current!, `/start?process=${encodeURIComponent(pm)}`, {
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const e = getEntry(pm)
|
const e = getEntry(pm)
|
||||||
@@ -584,6 +621,7 @@ export function useRecorder() {
|
|||||||
.filter((x): x is string => typeof x === 'string')
|
.filter((x): x is string => typeof x === 'string')
|
||||||
.join('\n') || '已完成'
|
.join('\n') || '已完成'
|
||||||
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
||||||
|
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
|
||||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||||
@@ -591,18 +629,22 @@ export function useRecorder() {
|
|||||||
setLoadingAction(null)
|
setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const runProChannelRestart = useCallback(
|
const runProChannelRestart = useCallback(
|
||||||
async (channelId: string) => {
|
async (channelId: string) => {
|
||||||
if (!channelId) return
|
if (!channelId) return
|
||||||
|
const allowed = await ensureCanControlLive()
|
||||||
|
if (!allowed) return
|
||||||
const pm = proChannelPm2Process(channelId)
|
const pm = proChannelPm2Process(channelId)
|
||||||
setLoadingAction('restart')
|
setLoadingAction('restart')
|
||||||
try {
|
try {
|
||||||
const saved = await saveProChannelKeysFor(channelId)
|
const saved = await saveProChannelKeysFor(channelId)
|
||||||
if (!saved) return
|
if (!saved) return
|
||||||
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`)
|
const res = await apiFetch(bindRef.current!, `/restart?process=${encodeURIComponent(pm)}`, {
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
|
})
|
||||||
if (!res.ok) throw new Error('无法连接到本机服务')
|
if (!res.ok) throw new Error('无法连接到本机服务')
|
||||||
const data = (await res.json()) as Record<string, unknown>
|
const data = (await res.json()) as Record<string, unknown>
|
||||||
const e = getEntry(pm)
|
const e = getEntry(pm)
|
||||||
@@ -614,6 +656,7 @@ export function useRecorder() {
|
|||||||
.filter((x): x is string => typeof x === 'string')
|
.filter((x): x is string => typeof x === 'string')
|
||||||
.join('\n') || '已完成'
|
.join('\n') || '已完成'
|
||||||
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
||||||
|
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
|
||||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||||
@@ -621,7 +664,7 @@ export function useRecorder() {
|
|||||||
setLoadingAction(null)
|
setLoadingAction(null)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||||
)
|
)
|
||||||
|
|
||||||
const submitNewChannelDraft = useCallback(async () => {
|
const submitNewChannelDraft = useCallback(async () => {
|
||||||
@@ -637,6 +680,7 @@ export function useRecorder() {
|
|||||||
name: `频道 ${proChannelsList.length + 1}`,
|
name: `频道 ${proChannelsList.length + 1}`,
|
||||||
youtubeKey: key,
|
youtubeKey: key,
|
||||||
}),
|
}),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
||||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||||
@@ -675,6 +719,7 @@ export function useRecorder() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ channelId }),
|
body: JSON.stringify({ channelId }),
|
||||||
|
pbToken: pbTokRef.current,
|
||||||
})
|
})
|
||||||
const d = (await res.json()) as { error?: string; message?: string }
|
const d = (await res.json()) as { error?: string; message?: string }
|
||||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||||
@@ -848,6 +893,7 @@ export function useRecorder() {
|
|||||||
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
const d = await apiJson<{ ok?: boolean; error?: string; channels?: typeof proChannelsList }>(
|
||||||
bind,
|
bind,
|
||||||
'/relay_pro/channels',
|
'/relay_pro/channels',
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
if (!d.ok) {
|
if (!d.ok) {
|
||||||
@@ -878,7 +924,7 @@ export function useRecorder() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const r = await apiFetch(bind, '/relay_pro/live_status')
|
const r = await apiFetch(bind, '/relay_pro/live_status', { pbToken: pbTokRef.current })
|
||||||
if (!r.ok || cancelled) return
|
if (!r.ok || cancelled) return
|
||||||
const j = (await r.json()) as ProRelayStatusPayload
|
const j = (await r.json()) as ProRelayStatusPayload
|
||||||
if (!cancelled) setProRelayStatus(j)
|
if (!cancelled) setProRelayStatus(j)
|
||||||
@@ -910,6 +956,7 @@ export function useRecorder() {
|
|||||||
const dr = await apiFetch(
|
const dr = await apiFetch(
|
||||||
b,
|
b,
|
||||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!dr.ok) continue
|
if (!dr.ok) continue
|
||||||
const cd = (await dr.json()) as {
|
const cd = (await dr.json()) as {
|
||||||
@@ -951,6 +998,7 @@ export function useRecorder() {
|
|||||||
const ur = await apiFetch(
|
const ur = await apiFetch(
|
||||||
bind,
|
bind,
|
||||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
||||||
|
{ pbToken: pbTokRef.current },
|
||||||
)
|
)
|
||||||
if (!ur.ok || cancelled) return
|
if (!ur.ok || cancelled) return
|
||||||
const j = (await ur.json()) as { content?: string }
|
const j = (await ur.json()) as { content?: string }
|
||||||
|
|||||||
14
lib/api.ts
14
lib/api.ts
@@ -6,22 +6,28 @@ function joinUrl(base: string, path: string): string {
|
|||||||
return `${b}${p}`
|
return `${b}${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
|
||||||
|
|
||||||
export async function apiFetch(
|
export async function apiFetch(
|
||||||
bind: BindPayload | null,
|
bind: BindPayload | null,
|
||||||
path: string,
|
path: string,
|
||||||
init?: RequestInit,
|
init?: ApiFetchInit,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
if (!bind) {
|
if (!bind) {
|
||||||
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
return Promise.reject(new Error('尚未绑定 PC(请先在「配对」扫码)'))
|
||||||
}
|
}
|
||||||
const headers = new Headers(init?.headers)
|
const { pbToken, ...rest } = init ?? {}
|
||||||
|
const headers = new Headers(rest.headers)
|
||||||
if (bind.token?.trim()) {
|
if (bind.token?.trim()) {
|
||||||
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
|
headers.set('Authorization', `Bearer ${bind.token.trim()}`)
|
||||||
}
|
}
|
||||||
return fetch(joinUrl(bind.baseUrl, path), { ...init, headers })
|
if (pbToken?.trim()) {
|
||||||
|
headers.set('X-PB-Token', pbToken.trim())
|
||||||
|
}
|
||||||
|
return fetch(joinUrl(bind.baseUrl, path), { ...rest, headers })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: RequestInit): Promise<T> {
|
export async function apiJson<T>(bind: BindPayload | null, path: string, init?: ApiFetchInit): Promise<T> {
|
||||||
const res = await apiFetch(bind, path, init)
|
const res = await apiFetch(bind, path, init)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const t = await res.text()
|
const t = await res.text()
|
||||||
|
|||||||
29
lib/authStorage.ts
Normal file
29
lib/authStorage.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||||
|
import type { PbUserRecord } from './pocketbase'
|
||||||
|
|
||||||
|
const KEY = 'pb_auth_session_v1'
|
||||||
|
|
||||||
|
export type StoredPbSession = {
|
||||||
|
token: string
|
||||||
|
record: PbUserRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPbSession(): Promise<StoredPbSession | null> {
|
||||||
|
try {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const j = JSON.parse(raw) as StoredPbSession
|
||||||
|
if (!j?.token || !j?.record?.id) return null
|
||||||
|
return j
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function savePbSession(s: StoredPbSession): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(KEY, JSON.stringify(s))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPbSession(): Promise<void> {
|
||||||
|
await AsyncStorage.removeItem(KEY)
|
||||||
|
}
|
||||||
21
lib/liveTelemetry.ts
Normal file
21
lib/liveTelemetry.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import type { BindPayload } from '@/lib/types'
|
||||||
|
import { pbCreateLiveSession } from '@/lib/pocketbase'
|
||||||
|
|
||||||
|
export async function reportLiveControlEvent(
|
||||||
|
bind: BindPayload | null,
|
||||||
|
pbToken: string | null,
|
||||||
|
userId: string | undefined,
|
||||||
|
process: string,
|
||||||
|
event: 'start' | 'restart' | 'stop',
|
||||||
|
): Promise<void> {
|
||||||
|
if (!bind || !pbToken?.trim() || !userId) return
|
||||||
|
let machine: Record<string, unknown> = {}
|
||||||
|
try {
|
||||||
|
const r = await apiFetch(bind, '/host/machine_summary', { pbToken: pbToken.trim() })
|
||||||
|
if (r.ok) machine = (await r.json()) as Record<string, unknown>
|
||||||
|
} catch {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
await pbCreateLiveSession(pbToken.trim(), userId, { process, event, machine })
|
||||||
|
}
|
||||||
99
lib/pocketbase.ts
Normal file
99
lib/pocketbase.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
/** PocketBase REST(与 payjsapi 同源实例 https://pocketbase.hackrobot.cn) */
|
||||||
|
|
||||||
|
export const POCKETBASE_URL = 'https://pocketbase.hackrobot.cn'
|
||||||
|
|
||||||
|
export type PbUserRecord = {
|
||||||
|
id: string
|
||||||
|
email?: string
|
||||||
|
name?: string
|
||||||
|
live_allowed?: boolean
|
||||||
|
[k: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PbAuthResponse = {
|
||||||
|
token: string
|
||||||
|
record: PbUserRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinPb(path: string): string {
|
||||||
|
const b = POCKETBASE_URL.replace(/\/$/, '')
|
||||||
|
const p = path.startsWith('/') ? path : `/${path}`
|
||||||
|
return `${b}${p}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbAuthWithPassword(email: string, password: string): Promise<PbAuthResponse> {
|
||||||
|
const res = await fetch(joinPb('/api/collections/users/auth-with-password'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ identity: email.trim(), password }),
|
||||||
|
})
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof data.message === 'string'
|
||||||
|
? data.message
|
||||||
|
: typeof data.data === 'object' && data.data && typeof (data.data as { message?: string }).message === 'string'
|
||||||
|
? (data.data as { message: string }).message
|
||||||
|
: `登录失败 (${res.status})`
|
||||||
|
throw new Error(msg)
|
||||||
|
}
|
||||||
|
const token = String(data.token ?? '')
|
||||||
|
const record = data.record as PbUserRecord | undefined
|
||||||
|
if (!token || !record?.id) throw new Error('登录响应异常')
|
||||||
|
return { token, record }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pbAuthRefresh(token: string): Promise<PbAuthResponse> {
|
||||||
|
const res = await fetch(joinPb('/api/collections/users/auth-refresh'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token.trim()}` },
|
||||||
|
})
|
||||||
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(typeof data.message === 'string' ? data.message : `刷新失败 (${res.status})`)
|
||||||
|
}
|
||||||
|
const newToken = String(data.token ?? '')
|
||||||
|
const record = data.record as PbUserRecord | undefined
|
||||||
|
if (!newToken || !record?.id) throw new Error('刷新响应异常')
|
||||||
|
return { token: newToken, record }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLiveAllowed(record: PbUserRecord | null | undefined): boolean {
|
||||||
|
if (!record) return false
|
||||||
|
return record.live_allowed !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LiveSessionPayload = {
|
||||||
|
process: string
|
||||||
|
event: 'start' | 'restart' | 'stop'
|
||||||
|
machine: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 需在 PocketBase 新建集合 `live_sessions`,见项目内说明 */
|
||||||
|
export async function pbCreateLiveSession(
|
||||||
|
token: string,
|
||||||
|
userId: string,
|
||||||
|
payload: LiveSessionPayload,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(joinPb('/api/collections/live_sessions/records'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token.trim()}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: userId,
|
||||||
|
process: payload.process,
|
||||||
|
event: payload.event,
|
||||||
|
machine: payload.machine,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const t = await res.text()
|
||||||
|
if (res.status === 404) {
|
||||||
|
console.warn('[live_sessions] 集合未创建或规则未放行:', t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.warn('[live_sessions] 写入失败:', res.status, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user