This commit is contained in:
eric
2026-03-26 01:59:04 -05:00
parent aba40c8cb7
commit 3bc1599763
12 changed files with 629 additions and 72 deletions

View File

@@ -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),

View File

@@ -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 ?? '—'}

View File

@@ -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,

View File

@@ -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}

View File

@@ -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>
); );
} }

View 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
View 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)
}

View File

@@ -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 }

View File

@@ -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
View 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
View 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
View 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)
}
}