's'
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { Tabs } from 'expo-router'
|
||||
import { Ionicons } from '@expo/vector-icons'
|
||||
|
||||
@@ -8,17 +9,28 @@ import { useClientOnlyValue } from '@/components/useClientOnlyValue'
|
||||
|
||||
export default function TabLayout() {
|
||||
const colorScheme = useColorScheme()
|
||||
const dark = colorScheme === 'dark'
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
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: {
|
||||
backgroundColor: colorScheme === 'dark' ? '#1b1f28' : '#fff',
|
||||
borderTopColor: 'rgba(255,255,255,0.08)',
|
||||
backgroundColor: dark ? '#161a22' : '#fff',
|
||||
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: {
|
||||
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,
|
||||
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 ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.snapLine}>
|
||||
抖音 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '}
|
||||
{y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -196,7 +196,7 @@ export default function DeskScreen() {
|
||||
</Text>
|
||||
<Text style={styles.liveLine}>主播 {row.anchor ?? '—'}</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
抖音 {row.douyinHint ?? '—'}
|
||||
源站 {row.douyinHint ?? '—'}
|
||||
</Text>
|
||||
<Text style={styles.liveLine} numberOfLines={2}>
|
||||
标题 {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 {
|
||||
View,
|
||||
Text,
|
||||
@@ -15,6 +18,7 @@ import {
|
||||
import { Picker } from '@react-native-picker/picker'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { useRecorder } from '@/hooks/useRecorder'
|
||||
import { theme } from '@/constants/theme'
|
||||
@@ -121,7 +125,7 @@ function ProLiveSwitchGrid({
|
||||
</Pressable>
|
||||
{href ? (
|
||||
<Pressable onPress={() => void Linking.openURL(href)}>
|
||||
<Text style={styles.proLink}>在浏览器打开抖音页</Text>
|
||||
<Text style={styles.proLink}>在浏览器打开源站页</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.proActions}>
|
||||
@@ -199,8 +203,27 @@ function LiveSwitchGrid({
|
||||
}
|
||||
|
||||
export default function RecordScreen() {
|
||||
const navigation = useNavigation()
|
||||
const { bind } = useBind()
|
||||
const { session, openLogin, logout } = useAuth()
|
||||
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 [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
@@ -258,49 +281,58 @@ export default function RecordScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.toolbarCard}>
|
||||
<View style={styles.toolbarRow}>
|
||||
<View style={styles.toolbarLeft}>
|
||||
<View
|
||||
style={[
|
||||
styles.statusChip,
|
||||
{ borderLeftColor: statusColor, backgroundColor: statusBg },
|
||||
]}
|
||||
>
|
||||
<View style={[styles.statusDotOuter, { borderColor: statusColor }]}>
|
||||
<View style={[styles.statusDotInner, { backgroundColor: statusColor }]} />
|
||||
<Animated.View entering={FadeInDown.duration(420).springify()}>
|
||||
<LinearGradient
|
||||
colors={['rgba(91,157,255,0.14)', 'rgba(20,22,28,0.92)']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.toolbarGradient}
|
||||
>
|
||||
<View style={styles.toolbarCard}>
|
||||
<View style={styles.toolbarRow}>
|
||||
<View style={styles.toolbarLeft}>
|
||||
<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>
|
||||
<Text style={styles.statusLineText} numberOfLines={1}>
|
||||
{r.statusMeta.line}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||
<View style={styles.proLabelRow}>
|
||||
<Text style={styles.proLabelZh}>多频道</Text>
|
||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||
<Text style={styles.proBadgeText}>PRO</Text>
|
||||
<View style={[styles.proCluster, unbound && styles.proClusterDisabled]}>
|
||||
<View style={styles.proLabelRow}>
|
||||
<Text style={styles.proLabelZh}>多频道</Text>
|
||||
<View style={[styles.proBadge, r.multiChannelPro && styles.proBadgeOn]}>
|
||||
<Text style={styles.proBadgeText}>PRO</Text>
|
||||
</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 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>
|
||||
</Animated.View>
|
||||
|
||||
{bind ? (
|
||||
<View style={[styles.pickerWrap, r.emptyHint && styles.pickerDisabled, styles.pickerRowBelow]}>
|
||||
@@ -719,9 +751,13 @@ const styles = StyleSheet.create({
|
||||
marginBottom: 14,
|
||||
},
|
||||
bannerText: { fontSize: 13, color: theme.text, lineHeight: 20 },
|
||||
toolbarCard: {
|
||||
toolbarGradient: {
|
||||
marginBottom: 12,
|
||||
borderRadius: 14,
|
||||
borderRadius: 16,
|
||||
padding: 1,
|
||||
},
|
||||
toolbarCard: {
|
||||
borderRadius: 15,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.07)',
|
||||
backgroundColor: theme.bgElevated,
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function NetworkScreen() {
|
||||
|
||||
{!bind && (
|
||||
<View style={styles.banner}>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 抖音 与测速。</Text>
|
||||
<Text style={styles.bannerText}>未绑定 PC:配对后可检测 Google / 国内站点 与测速。</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function NetworkScreen() {
|
||||
onSolution={openSolution}
|
||||
/>
|
||||
<SiteCard
|
||||
title="抖音(国内参考)"
|
||||
title="国内站点(参考)"
|
||||
site={view.douyin}
|
||||
speed={view.speed.domestic}
|
||||
loading={loading && !!bind}
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useEffect } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { AuthProvider } from '@/context/AuthContext';
|
||||
import { BindProvider } from '@/context/BindContext';
|
||||
import { PocketBaseAuthModal } from '@/components/PocketBaseAuthModal';
|
||||
|
||||
export {
|
||||
// Catch any errors thrown by the Layout component.
|
||||
@@ -55,12 +57,15 @@ function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
return (
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</ThemeProvider>
|
||||
</BindProvider>
|
||||
<AuthProvider>
|
||||
<BindProvider>
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<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 { Alert } from 'react-native'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { useBind } from '@/context/BindContext'
|
||||
import { apiFetch, apiJson } from '@/lib/api'
|
||||
import { reportLiveControlEvent } from '@/lib/liveTelemetry'
|
||||
import type { GpuCaps, YoutubeIniModel } from '@/lib/pcTypes'
|
||||
import { getMultiChannelPro, setMultiChannelPro } from '@/lib/storage'
|
||||
import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from '@/lib/scriptUtils'
|
||||
@@ -72,6 +74,9 @@ function pickProcessForEntries(filtered: Entry[], multi: boolean): string {
|
||||
|
||||
export function useRecorder() {
|
||||
const { bind } = useBind()
|
||||
const { pbToken, ensureCanControlLive, session } = useAuth()
|
||||
const pbTokRef = useRef(pbToken)
|
||||
pbTokRef.current = pbToken
|
||||
const [entries, setEntries] = useState<Entry[]>([])
|
||||
const [currentProcess, setCurrentProcess] = useState('')
|
||||
const [statusMeta, setStatusMeta] = useState<{ line: string; variant: StatusVariant }>({
|
||||
@@ -135,7 +140,7 @@ export function useRecorder() {
|
||||
useEffect(() => {
|
||||
if (!bindKey) return
|
||||
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((j: GpuCaps) => {
|
||||
if (!cancelled)
|
||||
@@ -166,7 +171,7 @@ export function useRecorder() {
|
||||
const loadProcessMonitor = useCallback(async () => {
|
||||
const b = bindRef.current
|
||||
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))
|
||||
const data = (await res.json()) as { entries?: { script: string; label?: string; pm2?: string }[] }
|
||||
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 }>(
|
||||
b,
|
||||
'/relay_pro/channels',
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!d.ok) {
|
||||
setProChannelsError(d.error || '无法加载 Pro 频道')
|
||||
@@ -220,7 +226,7 @@ export function useRecorder() {
|
||||
}
|
||||
setYoutubeStatus('正在将当前配置同步到多频道…')
|
||||
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 }
|
||||
if (!d.ok || !d.channels?.length) throw new Error(d.error || '无法加载频道列表')
|
||||
const firstId = d.channels[0].id
|
||||
@@ -231,6 +237,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId: firstId, keys, activeIndex: 0 }),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const kj = (await keyRes.json()) as { error?: string }
|
||||
if (!keyRes.ok) throw new Error(kj.error || '同步密钥失败')
|
||||
@@ -241,6 +248,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: urlText }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const uj = (await urlRes.json()) as { error?: string }
|
||||
@@ -258,6 +266,7 @@ export function useRecorder() {
|
||||
options: ytIniModel.options,
|
||||
header: ytIniModel.header || '',
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
} catch {
|
||||
@@ -282,6 +291,7 @@ export function useRecorder() {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`${endpoint}?process=${encodeURIComponent(currentProcess)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!res.ok) throw new Error(`连接失败 ${res.status}`)
|
||||
const data = (await res.json()) as { content?: string }
|
||||
@@ -310,6 +320,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
if (!res.ok) throw new Error(`保存失败 ${res.status}`)
|
||||
@@ -330,6 +341,7 @@ export function useRecorder() {
|
||||
const res = await apiFetch(
|
||||
bind,
|
||||
`/youtube/ini_model?process=${encodeURIComponent(currentProcess)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
const data = (await res.json()) as Partial<YoutubeIniModel> & { error?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
@@ -375,6 +387,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(modelToSave),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
@@ -404,6 +417,7 @@ export function useRecorder() {
|
||||
keys: keysOne,
|
||||
activeIndex: 0,
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
|
||||
@@ -450,6 +464,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: ed.urlText }),
|
||||
pbToken: pbTokRef.current,
|
||||
},
|
||||
)
|
||||
const data = (await res.json()) as { error?: string; message?: string }
|
||||
@@ -469,6 +484,7 @@ export function useRecorder() {
|
||||
const ur = await apiFetch(
|
||||
b,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(proChannelId)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!ur.ok) {
|
||||
setUrlStatus('读取失败')
|
||||
@@ -518,9 +534,17 @@ export function useRecorder() {
|
||||
}
|
||||
const proc = procForPm2(processOverride)
|
||||
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 {
|
||||
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('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
|
||||
@@ -551,6 +575,15 @@ export function useRecorder() {
|
||||
const actionLabel =
|
||||
action === 'start' ? '开始' : action === 'stop' ? '停止' : action === 'restart' ? '重新开始' : action
|
||||
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
|
||||
setTimeout(() => void runPm2Action('status', statusProc), 1500)
|
||||
}
|
||||
@@ -561,18 +594,22 @@ export function useRecorder() {
|
||||
if (!isStatus) setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[bindRef, procForPm2, getEntry],
|
||||
[bindRef, procForPm2, getEntry, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const runProChannelStart = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!channelId) return
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
const pm = proChannelPm2Process(channelId)
|
||||
setLoadingAction('start')
|
||||
try {
|
||||
const saved = await saveProChannelKeysFor(channelId)
|
||||
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('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const e = getEntry(pm)
|
||||
@@ -584,6 +621,7 @@ export function useRecorder() {
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
setLogText(`已对「${who}」执行:开始直播\n\n${out}`)
|
||||
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'start')
|
||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
@@ -591,18 +629,22 @@ export function useRecorder() {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const runProChannelRestart = useCallback(
|
||||
async (channelId: string) => {
|
||||
if (!channelId) return
|
||||
const allowed = await ensureCanControlLive()
|
||||
if (!allowed) return
|
||||
const pm = proChannelPm2Process(channelId)
|
||||
setLoadingAction('restart')
|
||||
try {
|
||||
const saved = await saveProChannelKeysFor(channelId)
|
||||
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('无法连接到本机服务')
|
||||
const data = (await res.json()) as Record<string, unknown>
|
||||
const e = getEntry(pm)
|
||||
@@ -614,6 +656,7 @@ export function useRecorder() {
|
||||
.filter((x): x is string => typeof x === 'string')
|
||||
.join('\n') || '已完成'
|
||||
setLogText(`已对「${who}」执行:重新开始\n\n${out}`)
|
||||
void reportLiveControlEvent(bindRef.current, pbTokRef.current, session?.record?.id, pm, 'restart')
|
||||
setTimeout(() => void runPm2Action('status', pm), 1500)
|
||||
} catch (err) {
|
||||
setLogText(`操作失败:${err instanceof Error ? err.message : String(err)}`)
|
||||
@@ -621,7 +664,7 @@ export function useRecorder() {
|
||||
setLoadingAction(null)
|
||||
}
|
||||
},
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor],
|
||||
[getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive, session?.record?.id],
|
||||
)
|
||||
|
||||
const submitNewChannelDraft = useCallback(async () => {
|
||||
@@ -637,6 +680,7 @@ export function useRecorder() {
|
||||
name: `频道 ${proChannelsList.length + 1}`,
|
||||
youtubeKey: key,
|
||||
}),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const d = (await res.json()) as { error?: string; message?: string; channelId?: string }
|
||||
if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`)
|
||||
@@ -675,6 +719,7 @@ export function useRecorder() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ channelId }),
|
||||
pbToken: pbTokRef.current,
|
||||
})
|
||||
const d = (await res.json()) as { error?: string; message?: string }
|
||||
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 }>(
|
||||
bind,
|
||||
'/relay_pro/channels',
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (cancelled) return
|
||||
if (!d.ok) {
|
||||
@@ -878,7 +924,7 @@ export function useRecorder() {
|
||||
let cancelled = false
|
||||
const poll = async () => {
|
||||
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
|
||||
const j = (await r.json()) as ProRelayStatusPayload
|
||||
if (!cancelled) setProRelayStatus(j)
|
||||
@@ -910,6 +956,7 @@ export function useRecorder() {
|
||||
const dr = await apiFetch(
|
||||
b,
|
||||
`/relay_pro/channel_detail?channel_id=${encodeURIComponent(c.id)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!dr.ok) continue
|
||||
const cd = (await dr.json()) as {
|
||||
@@ -951,6 +998,7 @@ export function useRecorder() {
|
||||
const ur = await apiFetch(
|
||||
bind,
|
||||
`/relay_pro/url_config?channel_id=${encodeURIComponent(cid)}`,
|
||||
{ pbToken: pbTokRef.current },
|
||||
)
|
||||
if (!ur.ok || cancelled) return
|
||||
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}`
|
||||
}
|
||||
|
||||
export type ApiFetchInit = RequestInit & { pbToken?: string | null }
|
||||
|
||||
export async function apiFetch(
|
||||
bind: BindPayload | null,
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
init?: ApiFetchInit,
|
||||
): Promise<Response> {
|
||||
if (!bind) {
|
||||
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()) {
|
||||
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)
|
||||
if (!res.ok) {
|
||||
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