From 3bc15997631124ebbda4579ed1d3f12fcf78b958 Mon Sep 17 00:00:00 2001 From: eric Date: Thu, 26 Mar 2026 01:59:04 -0500 Subject: [PATCH] 's' --- app/(tabs)/_layout.tsx | 18 +++- app/(tabs)/desk.tsx | 4 +- app/(tabs)/index.tsx | 122 +++++++++++++-------- app/(tabs)/network.tsx | 4 +- app/_layout.tsx | 19 ++-- components/PocketBaseAuthModal.tsx | 135 +++++++++++++++++++++++ context/AuthContext.tsx | 166 +++++++++++++++++++++++++++++ hooks/useRecorder.ts | 70 ++++++++++-- lib/api.ts | 14 ++- lib/authStorage.ts | 29 +++++ lib/liveTelemetry.ts | 21 ++++ lib/pocketbase.ts | 99 +++++++++++++++++ 12 files changed, 629 insertions(+), 72 deletions(-) create mode 100644 components/PocketBaseAuthModal.tsx create mode 100644 context/AuthContext.tsx create mode 100644 lib/authStorage.ts create mode 100644 lib/liveTelemetry.ts create mode 100644 lib/pocketbase.ts diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx index 6f5ec50..79372f6 100644 --- a/app/(tabs)/_layout.tsx +++ b/app/(tabs)/_layout.tsx @@ -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 ( = 0 ? `${g.latency_ms.toFixed(0)} ms` : '—'} · {g?.country ?? '—'} - 抖音 {y?.reachable ? '可达' : '不可达'} ·{' '} + 国内站点 {y?.reachable ? '可达' : '不可达'} ·{' '} {y?.latency_ms != null && y.latency_ms >= 0 ? `${y.latency_ms.toFixed(0)} ms` : '—'} · {y?.country ?? '—'} @@ -196,7 +196,7 @@ export default function DeskScreen() { 主播 {row.anchor ?? '—'} - 抖音 {row.douyinHint ?? '—'} + 源站 {row.douyinHint ?? '—'} 标题 {row.streamTitle ?? '—'} diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 6744261..1d7babf 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -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({ {href ? ( void Linking.openURL(href)}> - 在浏览器打开抖音页 + 在浏览器打开源站页 ) : null} @@ -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: () => ( + void (session ? logout() : openLogin())} + style={{ paddingHorizontal: 12, paddingVertical: 6 }} + > + + {session?.record?.email + ? `${String(session.record.email).split('@')[0]}…` + : '登录'} + + + ), + }) + }, [navigation, session, openLogin, logout]) const unbound = !bind const [refreshing, setRefreshing] = useState(false) @@ -258,49 +281,58 @@ export default function RecordScreen() { )} - - - - - - + + + + + + + + + + + {r.statusMeta.line} + + - - {r.statusMeta.line} - - - - - - 多频道 - - PRO + + + 多频道 + + PRO + + + + { + 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)" + /> + - - { - 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)" - /> - - - + + {bind ? ( @@ -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, diff --git a/app/(tabs)/network.tsx b/app/(tabs)/network.tsx index d3c069a..a55b681 100644 --- a/app/(tabs)/network.tsx +++ b/app/(tabs)/network.tsx @@ -246,7 +246,7 @@ export default function NetworkScreen() { {!bind && ( - 未绑定 PC:配对后可检测 Google / 抖音 与测速。 + 未绑定 PC:配对后可检测 Google / 国内站点 与测速。 )} @@ -268,7 +268,7 @@ export default function NetworkScreen() { onSolution={openSolution} /> - - - - - - + + + + + + + + + + ); } diff --git a/components/PocketBaseAuthModal.tsx b/components/PocketBaseAuthModal.tsx new file mode 100644 index 0000000..dabd31a --- /dev/null +++ b/components/PocketBaseAuthModal.tsx @@ -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(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 ( + + + + + 会员授权 + + 开始或重新开始直播前,请使用异度星球账号登录。授权保存在本机,可随时在首页退出。 + + {err ? {err} : null} + 邮箱 + + 密码 + + void onSubmit()} + > + {busy ? ( + + ) : ( + 登录并授权 + )} + + + void Linking.openURL(VIP_URL)}> + 注册 + + · + + 取消 + + + + + + ) +} + +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 }, +}) diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx new file mode 100644 index 0000000..81d7a68 --- /dev/null +++ b/context/AuthContext.tsx @@ -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 + logout: () => Promise + refreshSession: () => Promise + /** 用于远程 API 请求头 */ + pbToken: string | null + /** 开始/重启直播前调用;未登录则弹出登录;无权限则 false */ + ensureCanControlLive: () => Promise +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [session, setSession] = useState(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 => { + 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((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 {children} +} + +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) +} diff --git a/hooks/useRecorder.ts b/hooks/useRecorder.ts index f2ed9ef..b9d54b8 100644 --- a/hooks/useRecorder.ts +++ b/hooks/useRecorder.ts @@ -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([]) 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 & { 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 @@ -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 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 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 } diff --git a/lib/api.ts b/lib/api.ts index c9fabf3..3c9f3a4 100644 --- a/lib/api.ts +++ b/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 { 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(bind: BindPayload | null, path: string, init?: RequestInit): Promise { +export async function apiJson(bind: BindPayload | null, path: string, init?: ApiFetchInit): Promise { const res = await apiFetch(bind, path, init) if (!res.ok) { const t = await res.text() diff --git a/lib/authStorage.ts b/lib/authStorage.ts new file mode 100644 index 0000000..c24b04a --- /dev/null +++ b/lib/authStorage.ts @@ -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 { + 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 { + await AsyncStorage.setItem(KEY, JSON.stringify(s)) +} + +export async function clearPbSession(): Promise { + await AsyncStorage.removeItem(KEY) +} diff --git a/lib/liveTelemetry.ts b/lib/liveTelemetry.ts new file mode 100644 index 0000000..4611b36 --- /dev/null +++ b/lib/liveTelemetry.ts @@ -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 { + if (!bind || !pbToken?.trim() || !userId) return + let machine: Record = {} + try { + const r = await apiFetch(bind, '/host/machine_summary', { pbToken: pbToken.trim() }) + if (r.ok) machine = (await r.json()) as Record + } catch { + /* 忽略 */ + } + await pbCreateLiveSession(pbToken.trim(), userId, { process, event, machine }) +} diff --git a/lib/pocketbase.ts b/lib/pocketbase.ts new file mode 100644 index 0000000..615f059 --- /dev/null +++ b/lib/pocketbase.ts @@ -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 { + 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 + 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 { + 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 + 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 +} + +/** 需在 PocketBase 新建集合 `live_sessions`,见项目内说明 */ +export async function pbCreateLiveSession( + token: string, + userId: string, + payload: LiveSessionPayload, +): Promise { + 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) + } +}